Checkout je Funktion: Vorpruefungen in der Reihenfolge der Spezifikation

POST /api/funktionen/:key/checkout ersetzt /news/checkout (der News-Pfad bleibt als
key = news erhalten). Stufen ohne Seiteneffekt: 1 Kundenansicht (403), 2 Schluessel
gegen das Register (404), 3 Zustimmung (400 zustimmung_fehlt, 409 fassung_veraltet mit
der Serverfassung; News weiter mit consent: true), 4 Projekt (400/403; bei fortsetzen
muss das Dokument ordered sein), 5 Schalter (409 nicht_verfuegbar), 5b Unterlagen
(503 unterlagen_fehlen), 6 Bestellpruefung (409 bestellung_offen mit offen), 7 Wartungs-Abo
(409 kein_abo | gekuendigt | nicht_aktiv, Lesefehler 500). fail() gibt err.body weiter.

featureOrder.js: pruefeZustimmung, istFortsetzen, aboMeldung (einzige Quelle der
Abo-Hinweistexte), ladeEntwurf, pruefeAbo, orderDeps. featureRegistry.js: featureSperre
und die Standard-Haken verfuegbar, bestellpruefung (mitAbo: false, ohne abo) und
unterlagenBereit des Terminplaners. Der Rest der Route (Sperre, Zustand, Sitzung,
Nachweis) bleibt bis Task B7 nur fuer News; andere Funktionen enden mit 503.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 12:25:09 +00:00
1 parent d568a3798d
commit ded6bb2a77
5 files changed
+960 -11

No files matched your search

+51 -6
View File
@@ -15,6 +15,8 @@ import {
runActivation,
} from '../services/siteFeatures.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'
import {
ELIGIBILITY_MESSAGES,
@@ -32,9 +34,10 @@ const DAY_MS = 24 * 60 * 60 * 1000
// Eigene Fehler (httpError) tragen einen Text fuer den Kunden. Fehler von Appwrite (err.type) oder
// unerwartete Fehler werden nur geloggt; der Kunde sieht einen verstaendlichen Hinweis statt der Rohmeldung.
// err.body (Checkout-Vorpruefungen: code, consent, offen, grund) geht mit in die Antwort, error bleibt der Text.
function fail(res, err, fallback) {
const own = ((err.status >= 400 && err.status < 500) || err.status === 503) && !err.type
if (own) return res.status(err.status).json({ error: err.message })
if (own) return res.status(err.status).json({ ...err.body, error: err.message })
console.error('[funktionen]', err.status || '', err.type || '', err.message)
return res.status(500).json({ error: fallback })
}
@@ -134,20 +137,62 @@ async function openOrderSession(doc) {
throw conflict()
}
router.post('/news/checkout', requireSession, async (req, res) => {
// 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) => {
try {
// 1 Kundenansicht
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
}
const { projectId, consent } = req.body || {}
if (consent !== true) {
return res.status(400).json({ error: 'Bitte stimme der Erklärung zu, um zu bestellen.' })
}
// 2 Funktionsschluessel (analyse hat einen eigenen Weg und steht nicht im Register)
const key = req.params.key
const feature = featureConfig(key)
if (!feature) return res.status(404).json({ error: 'Unbekannte Funktion' })
const body = req.body || {}
// 3 Zustimmung: alle Pflicht-Haken und die aktuelle Fassung; bei "Zahlung fortsetzen" entfaellt sie (Stufe 4 prueft dafuer ordered)
const fortsetzen = istFortsetzen(body)
if (!fortsetzen) pruefeZustimmung(feature, body)
// 4 Projekt
const { projectId } = body
if (typeof projectId !== 'string' || !projectId) {
return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
}
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')
}
// 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste)
const sperre = featureSperre(key, req.session.email)
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
// 5b Vertragsunterlagen abrufbar (vor jeder Stripe-Sitzung)
const haken = featureHaken(key)
if (haken.unterlagenBereit && !(await haken.unterlagenBereit())) {
throw bestellFehler(
503,
'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.',
'unterlagen_fehlen'
)
}
// 6 Bestellpruefung: offene Punkte des Entwurfs. Ohne abo: den Abo-Punkt (h) wendet der Checkout nicht an, das tut Stufe 7
if (haken.bestellpruefung) {
const offen = await haken.bestellpruefung({ customerId, projectId, feature })
if (!Array.isArray(offen)) throw new TypeError('bestellpruefung muss eine Liste liefern')
if (offen.length > 0) {
throw bestellFehler(409, 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.', 'bestellung_offen', { offen })
}
}
// 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
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
+147
View File
@@ -0,0 +1,147 @@
import { config } from '../config.js'
import { getDocument } from './appwriteAdmin.js'
import { aboMemo } from './aboStatus.js'
import { oeffentlicheAnsicht } from './featureRegistry.js'
import { ladeEinstellung } from './termine/kontext.js'
// Bausteine der Bestell-Vorpruefungen (POST /api/funktionen/:key/checkout, Task B6): Zustimmung, Abo-Hinweistexte,
// Entwurf des Terminplaners. Die Route in routes/funktionen.js legt die Reihenfolge fest; jede Stufe hier ist
// ohne Seiteneffekt (liest nur, schreibt nie, legt keine Stripe-Sitzung an).
/** Einhaengepunkt fuer Tests: der Abo-Speicher je Anfrage (Tests zaehlen die Aufrufe, um die Reihenfolge zu pruefen). */
export const orderDeps = { aboMemo }
/**
* Fehler mit Status, Kundentext und Code. code steht am Fehler und im Antwortkoerper (err.body.code); weitere
* Felder fuer den Browser (consent, offen, grund) kommen in body. routes/funktionen.js fail() gibt body weiter.
*/
export function bestellFehler(status, message, code, extra = {}) {
const err = new Error(message)
err.status = status
if (code) {
err.code = code
err.body = { code, ...extra }
} else if (Object.keys(extra).length > 0) {
err.body = { ...extra }
}
return err
}
const ZUSTIMMUNG_FEHLT = 'Bitte stimme der Erklärung zu, um zu bestellen.'
const FASSUNG_VERALTET = 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.'
const MAX_HAKEN = 50
const fehlt = () => bestellFehler(400, ZUSTIMMUNG_FEHLT, 'zustimmung_fehlt')
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const hatWert = (wert) => wert !== undefined && wert !== null
/**
* "Zahlung fortsetzen" aus der Statuskarte: { projectId, fortsetzen: true } ohne Zustimmung. Enthaelt der Koerper auch
* zustimmung (oder die News-Altform consent: true), gilt die Zustimmung und sie wird geprueft.
*/
export function istFortsetzen(eingabe) {
return eingabe?.fortsetzen === true && !hatWert(eingabe.zustimmung) && eingabe.consent !== true
}
/**
* Pruefstufe 3: Sind alle Pflicht-Haken der Funktion gesetzt und passt die Fassung zur Serverfassung?
* eingabe ist der Anfragekoerper: { zustimmung: { version, haken: [id, …] } } oder, nur bei Funktionen mit genau einem
* Haken-Item (News), die Altform { consent: true }. Gibt die angenommenen Haken-IDs in Registerreihenfolge zurueck.
* Wirft 400 zustimmung_fehlt (fehlt, unvollstaendig, unbekannte ID) und 409 fassung_veraltet (err.body.consent ist die
* aktuelle Fassung mit allen Texten, wie die Bestellseite sie zeigt). Die Fassung geht vor den Haken: wer einen alten
* Wortlaut gesehen hat, soll erst den neuen lesen.
*/
export function pruefeZustimmung(feature, eingabe) {
const ids = feature.consent.items.filter((item) => item.haken).map((item) => item.id)
if (!hatWert(eingabe?.zustimmung)) {
// Altform: ein einziger Haken (News) laesst sich mit consent: true bestaetigen
if (eingabe?.consent === true && ids.length === 1) return { akzeptiert: [...ids] }
throw fehlt()
}
if (!istObjekt(eingabe.zustimmung)) throw fehlt()
const { version, haken } = eingabe.zustimmung
if (typeof version !== 'string' || !Array.isArray(haken) || haken.length > MAX_HAKEN) throw fehlt()
if (version !== feature.consent.version) {
throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheAnsicht(feature.key).consent })
}
if (!haken.every((id) => typeof id === 'string' && ids.includes(id))) throw fehlt()
const gesetzt = new Set(haken)
if (!ids.every((id) => gesetzt.has(id))) throw fehlt()
return { akzeptiert: [...ids] }
}
// Abo-Hinweistexte (Du-Form, Akkusativ der Funktion aus mails.formen.akk, z. B. "den Terminplaner"). Einzige Quelle:
// Bestellseite, Vorschau (Gate-Punkt (h)), Entwurfsrechte und Abo-Abgleich importieren sie von hier.
const ABO_TEXTE = {
kein_abo: (akk) => `Für ${akk} brauchst du ein laufendes Wartungs-Abo.`,
gekuendigt: (akk) => `Dein Wartungs-Abo ist gekündigt. Für ${akk} brauchst du ein laufendes Abo.`,
nicht_aktiv: (akk) =>
`Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du ${akk} bestellen.`,
unbekannt: () => 'Das Abo konnte gerade nicht geprüft werden.',
}
/**
* Hinweistext zu einem Abo-Grund.
* @param {object} feature Registereintrag (config.features.<key>)
* @param {'kein_abo'|'gekuendigt'|'nicht_aktiv'|'unbekannt'} grund jeder andere Wert gilt wie 'unbekannt'
*/
export function aboMeldung(feature, grund) {
const text = Object.hasOwn(ABO_TEXTE, grund) ? ABO_TEXTE[grund] : ABO_TEXTE.unbekannt
return text(feature?.mails?.formen?.akk || 'diese Funktion')
}
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
async function holeOderNull(collectionId, id) {
try {
return await getDocument(collectionId, id)
} catch (err) {
if (dokumentFehlt(err)) return null
throw err
}
}
// Fragebogen aus termineConfig.fragebogen (JSON-Text): nur ein Objekt zaehlt, fehlend oder kaputt ist null
function liesFragebogen(text) {
if (typeof text !== 'string' || text === '') return null
try {
const wert = JSON.parse(text)
return istObjekt(wert) ? wert : null
} catch {
return null
}
}
/**
* Entwurf des Terminplaners (Einstellung und Fragebogen aus termineConfig, Dokument-ID = ID des portalFeatures-Dokuments).
* Fehlende oder unlesbare Teile sind null; Lesefehler der Datenbank werden weitergereicht.
* @param {string} featureId
* @returns {Promise<{ einstellung: object|null, fragebogen: object|null }>}
*/
export async function ladeEntwurf(featureId) {
const [gelesen, doc] = await Promise.all([ladeEinstellung(featureId), holeOderNull(config.collections.termineConfig, featureId)])
return { einstellung: gelesen?.einstellung ?? null, fragebogen: liesFragebogen(doc?.fragebogen) }
}
/**
* Pruefstufe 7: Wartungs-Abo, wenn die Funktion es verlangt (requiresWartungsAbo). Wirft 409 mit code und body.grund
* (kein_abo | gekuendigt | nicht_aktiv, ohne Praefix) und dem Text aus aboMeldung. Ein Lesefehler wirft den Fehler der
* Datenbank weiter (die Route antwortet 500 mit allgemeinem Text): im Zweifel nicht bestellbar.
*/
export async function pruefeAbo(feature, customerId) {
if (feature.requiresWartungsAbo !== true) return
let ergebnis
try {
ergebnis = await orderDeps.aboMemo().bestellbar(customerId)
} catch (err) {
// egal mit welchem Status die Datenbank antwortet: 500, die Route zeigt nur ihren allgemeinen Text
throw Object.assign(new Error(`Abo-Prüfung fehlgeschlagen: ${err?.message}`), { status: 500, cause: err })
}
if (ergebnis?.ok === true) return
const grund = ergebnis?.grund
if (!Object.hasOwn(ABO_TEXTE, grund) || grund === 'unbekannt') throw new Error('Abo-Prüfung ohne gültiges Ergebnis')
throw bestellFehler(409, aboMeldung(feature, grund), grund, { grund })
}
+43 -5
View File
@@ -1,19 +1,42 @@
import { config } from '../config.js'
import { termineFuerKunde } from './termine/schalter.js'
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js).
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch: Standard-Haken
// laden ihre Dienste per dynamischem import() innerhalb der Funktion (sonst entstuenden Importzyklen).
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahme:
// termine/schalter.js, das nur config liest): Standard-Haken laden ihre Dienste per dynamischem import()
// innerhalb der Funktion (sonst entstuenden Importzyklen).
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
/**
* Standard-Haken je Funktion (Funktionen, nicht Daten). Leer in Task B1; es fuellen sie B6 (verfuegbar,
* bestellpruefung, unterlagenBereit), B7 (belehrung) und B11 (freischaltbar). News hat keine Standard-Haken.
* Bestellpruefung des Terminplaners im Checkout: die offenen Punkte des Entwurfs (Einstellung und Fragebogen aus
* termineConfig; ohne Dokument beides null) nach termine/fragebogen.js. Ohne abo und mit mitAbo: false: den Abo-Punkt (h)
* wendet der Checkout nicht an, die Abo-Stufe (7) der Route liefert kein_abo | gekuendigt | nicht_aktiv.
*/
async function termineBestellpruefung({ customerId, projectId, feature }) {
const [{ getFeatureDoc }, { ladeEntwurf }, { bestellpruefung }] = await Promise.all([
import('./siteFeatures.js'),
import('./featureOrder.js'),
import('./termine/fragebogen.js'),
])
const doc = await getFeatureDoc(customerId, projectId, 'termine')
const { einstellung, fragebogen } = doc ? await ladeEntwurf(doc.$id) : { einstellung: null, fragebogen: null }
return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })
}
/**
* 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.
*/
const STANDARD_HAKEN = {
news: {},
termine: {},
termine: {
verfuegbar: (email) => termineFuerKunde(email),
bestellpruefung: termineBestellpruefung,
unterlagenBereit: async () => true,
},
}
/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */
@@ -43,6 +66,21 @@ export function featureHaken(key) {
return haken
}
const NICHT_VERFUEGBAR = 'Diese Funktion ist noch nicht verfügbar.'
/**
* Pruefstufe 5 des Checkouts: Text, warum die Funktion diesem Kunden (noch) nicht angeboten wird, sonst null.
* Gefragt wird der Haken verfuegbar(email) (Terminplaner: Schalter TERMINE_ENABLED und Pilotliste); Funktionen ohne
* Haken (News) sind immer verfuegbar. Ein unbekannter Schluessel ist nicht verfuegbar (fail-closed).
*/
export function featureSperre(key, email) {
const eintrag = featureConfig(key)
if (!eintrag) return NICHT_VERFUEGBAR
const { verfuegbar } = featureHaken(key)
if (typeof verfuegbar !== 'function' || verfuegbar(email)) return null
return eintrag.unavailableMessage || NICHT_VERFUEGBAR
}
const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null)
/**