Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register
Rest der Route POST /api/funktionen/:key/checkout unter der Sperre feature-order:<Kunde>:<Projekt>: Zustand aus dem Register (setup/active, disabled, ordered mit offener Sitzung, "Zahlung fortsetzen"), Stripe-Sitzung mit der Zeit des Klicks, Zustimmungsnachweis (Serverfassungen und -texte) vor markOrdered und vor der URL. Standard-Haken belehrung mit BELEHRUNG_PLATZHALTER fuer den Terminplaner. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f6ae0f7e0
commit
7fcb268644
5 files changed
+534
-43
No files matched your search
+71
-23
@@ -6,14 +6,15 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { withLocks } from '../services/keyedLock.js'
|
||||
import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js'
|
||||
import {
|
||||
DISABLED_MESSAGE,
|
||||
assertOwnProject,
|
||||
backfillInvoiceLinks,
|
||||
getFeatureDoc,
|
||||
hakenTexte,
|
||||
listCustomerFeatures,
|
||||
markOrdered,
|
||||
runActivation,
|
||||
} from '../services/siteFeatures.js'
|
||||
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
|
||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||
import { featureConfig, featureHaken, featureSperre } from '../services/featureRegistry.js'
|
||||
import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
|
||||
@@ -120,9 +121,10 @@ router.get('/', requireSession, async (req, res) => {
|
||||
* Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal
|
||||
* (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft
|
||||
* oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409.
|
||||
* Die Meldung bei 409 steht im Register der Funktion (orderedConflictMessage).
|
||||
*/
|
||||
async function openOrderSession(doc) {
|
||||
const conflict = () => Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
|
||||
async function openOrderSession(doc, feature) {
|
||||
const conflict = () => Object.assign(new Error(feature.orderedConflictMessage), { status: 409 })
|
||||
if (!doc.stripeSessionId) return null
|
||||
let session
|
||||
try {
|
||||
@@ -137,11 +139,45 @@ async function openOrderSession(doc) {
|
||||
throw conflict()
|
||||
}
|
||||
|
||||
const ZUSTIMMUNG_NOETIG = 'Bitte stimme der Erklärung zu, um zu bestellen.'
|
||||
|
||||
/** "Name <E-Mail>" der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */
|
||||
const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '')
|
||||
|
||||
/**
|
||||
* Zustimmungsnachweis zu einer Stripe-Sitzung speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem
|
||||
* Client), dazu die Belehrung aus dem Haken und die Zeit des Klicks. Jeder Fehler hier ist ein Serverfehler: die Route
|
||||
* antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext steht nur im Log.
|
||||
*/
|
||||
async function speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt }) {
|
||||
const belehrung = (await haken.belehrung?.()) ?? null
|
||||
const texte = nachweisTexte(feature, { belehrung })
|
||||
try {
|
||||
await speichereZustimmung({
|
||||
customerId,
|
||||
projectId,
|
||||
featureKey: feature.key,
|
||||
stripeSessionId: sessionId,
|
||||
fassungen: zustimmungsfassungen(feature),
|
||||
texte,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
konto: kontoLabel(actingPerson(req.session)),
|
||||
at: klickAt,
|
||||
})
|
||||
} catch (err) {
|
||||
throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err })
|
||||
}
|
||||
}
|
||||
|
||||
// Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news.
|
||||
// Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung,
|
||||
// kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter,
|
||||
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo.
|
||||
router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
// Zeit des Klicks: Eingang der Anfrage, vor jeder Sperre und jedem Stripe-Aufruf. Sie ist der eine Zeitpunkt von Nachweis,
|
||||
// Stripe-Metadaten (consentAt) und Dokument und damit der Anker der Widerrufsfrist (UTC mit Z)
|
||||
const klickAt = new Date().toISOString()
|
||||
try {
|
||||
// 1 Kundenansicht
|
||||
if (req.session.viewAs) {
|
||||
@@ -163,7 +199,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
await assertOwnProject(customerId, projectId)
|
||||
if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') {
|
||||
throw bestellFehler(400, 'Bitte stimme der Erklärung zu, um zu bestellen.', 'zustimmung_fehlt')
|
||||
throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
||||
}
|
||||
// 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste)
|
||||
const sperre = featureSperre(key, req.session.email)
|
||||
@@ -188,37 +224,49 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
// 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv
|
||||
await pruefeAbo(feature, customerId)
|
||||
|
||||
// Task B7 ersetzt den Rest: Sperre, Zustand, Stripe-Sitzung, Nachweis und markOrdered je Funktion aus dem Register.
|
||||
// Bis dahin laeuft nur News; fuer andere Funktionen entsteht weder Sitzung noch Dokument.
|
||||
if (key !== 'news') {
|
||||
throw bestellFehler(503, 'Die Bestellung ist für diese Funktion noch nicht freigeschaltet.', 'bestellung_nicht_freigeschaltet')
|
||||
}
|
||||
// Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine
|
||||
// zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
|
||||
// Zustand, Sitzung, Nachweis und Eintrag als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine zwei Sitzungen
|
||||
// erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
|
||||
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
||||
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv/abgeschaltet -> 409, eine offene Bestellung
|
||||
// richtet sich nach ihrer gespeicherten Stripe-Sitzung
|
||||
const existing = await getFeatureDoc(customerId, projectId, 'news')
|
||||
// a Zustand vor jedem Stripe-Aufruf: bezahlt/aktiv und abgeschaltet sind 409 (Meldungen aus dem Register)
|
||||
const existing = await getFeatureDoc(customerId, projectId, key)
|
||||
if (existing?.state === 'setup' || existing?.state === 'active') {
|
||||
throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
|
||||
throw Object.assign(new Error(feature.orderedConflictMessage), { status: 409 })
|
||||
}
|
||||
if (existing?.state === 'disabled') throw Object.assign(new Error(DISABLED_MESSAGE), { status: 409 })
|
||||
if (existing?.state === 'ordered') {
|
||||
const open = await openOrderSession(existing)
|
||||
if (open) return open
|
||||
if (existing?.state === 'disabled') throw Object.assign(new Error(feature.disabledMessage), { status: 409 })
|
||||
|
||||
const nachweis = (sessionId) => speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt })
|
||||
|
||||
// b eine offene Bestellung richtet sich nach ihrer gespeicherten Stripe-Sitzung: offen -> dieselbe Zahlungsseite
|
||||
const open = existing?.state === 'ordered' ? await openOrderSession(existing, feature) : null
|
||||
if (open) {
|
||||
// d Liegt zur Sitzung noch kein Nachweis, wird er vor der URL nachgetragen. "Zahlung fortsetzen" (ohne Zustimmung)
|
||||
// gibt die URL nur her, wenn er schon liegt, sonst stimmt der Kunde im Formular neu zu
|
||||
if (!(await zustimmungFuerSitzung(open.id))) {
|
||||
if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
||||
await nachweis(open.id)
|
||||
}
|
||||
return open
|
||||
}
|
||||
// keine offene Sitzung (abgelaufen, nie angelegt): "Zahlung fortsetzen" legt nie eine neue an, der Kunde stimmt im Formular neu zu
|
||||
if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
||||
|
||||
// c neue Sitzung: die Stripe-Sitzung entsteht zuerst, weil der Nachweis ihre ID traegt
|
||||
const customer = await getDocument(config.collections.customers, customerId)
|
||||
const now = new Date().toISOString()
|
||||
const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now })
|
||||
const created = await createFeatureCheckout(customer, { projectId, key, consentAt: klickAt })
|
||||
// d Nachweis vor allem anderen: scheitert er, gibt es keine URL und kein ordered (die unerreichbare Stripe-Sitzung laeuft
|
||||
// nach 24 h ab, ein Storno ist nicht noetig)
|
||||
await nachweis(created.id)
|
||||
// e erst nach dem Nachweis wird die Bestellung eingetragen
|
||||
await markOrdered({
|
||||
customerId,
|
||||
projectId,
|
||||
key: 'news',
|
||||
key,
|
||||
sessionId: created.id,
|
||||
consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') },
|
||||
consent: { text: hakenTexte(feature), at: klickAt, ip: req.ip, userAgent: req.get('user-agent') },
|
||||
})
|
||||
return created
|
||||
})
|
||||
// f die URL geht erst jetzt an den Browser
|
||||
res.json({ url: session.url })
|
||||
} catch (err) {
|
||||
fail(res, err, 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')
|
||||
|
||||
Reference in new issue
Block a user