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.')
|
||||
|
||||
@@ -25,10 +25,17 @@ async function termineBestellpruefung({ customerId, projectId, feature }) {
|
||||
return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der
|
||||
* Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion().
|
||||
*/
|
||||
export const BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.'
|
||||
|
||||
/**
|
||||
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
|
||||
* (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung und B11 freischaltbar. News hat keine
|
||||
* Standard-Haken. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert.
|
||||
* (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
|
||||
* und im Nachweis gespeichert wird) und B11 freischaltbar. News hat keine Standard-Haken, also auch keinen belehrung-Eintrag
|
||||
* im Nachweis. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert.
|
||||
*/
|
||||
const STANDARD_HAKEN = {
|
||||
news: {},
|
||||
@@ -36,6 +43,7 @@ const STANDARD_HAKEN = {
|
||||
verfuegbar: (email) => termineFuerKunde(email),
|
||||
bestellpruefung: termineBestellpruefung,
|
||||
unterlagenBereit: async () => true,
|
||||
belehrung: async () => BELEHRUNG_PLATZHALTER,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -229,13 +229,24 @@ export async function paidFeatureOrders(customerId) {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Wortlaut der Zustimmung am Dokument (consentText): die Texte der Haken-Items der Funktion, durch eine Leerzeile getrennt.
|
||||
* Eine bequeme Kopie fuer das Team; massgeblich ist der vollstaendige Nachweis in featureConsents.
|
||||
*/
|
||||
export function hakenTexte(feature) {
|
||||
return feature.consent.items
|
||||
.filter((item) => item.haken)
|
||||
.map((item) => item.text)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/** Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion. */
|
||||
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
|
||||
assertKey(key)
|
||||
const feature = featureConfig(key)
|
||||
const doc = await ensureFeatureDoc(customerId, projectId, key)
|
||||
if (doc.state === 'setup' || doc.state === 'active') {
|
||||
throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet')
|
||||
}
|
||||
if (doc.state === 'disabled') throw httpError(409, DISABLED_MESSAGE)
|
||||
if (doc.state === 'setup' || doc.state === 'active') throw httpError(409, feature.orderedConflictMessage)
|
||||
if (doc.state === 'disabled') throw httpError(409, feature.disabledMessage)
|
||||
return updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
featureKey: key,
|
||||
...stateFields('ordered'),
|
||||
|
||||
Reference in new issue
Block a user