import { config } from '../config.js' import { aboMemo, bestellbar } from './aboStatus.js' import { featureConfig } from './featureRegistry.js' import { istObjekt } from './termine/ablauf.js' import { holeOderNull, 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 /** * Sicht der Zustimmung fuer den Browser (Bestellseite, Antwort bei veralteter Fassung): Fassung, Knopf und alle Texte; ob ein Punkt * einen Haken braucht, steht je Punkt. Fuer feature.consent und, beim Gratisweg, freiweg(feature).consent. */ export const oeffentlicheConsent = (consent) => ({ version: consent.version, knopf: consent.knopf, items: consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })), }) const fehlt = () => bestellFehler(400, ZUSTIMMUNG_FEHLT, 'zustimmung_fehlt') 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) { // die Fassung des geprueften Eintrags (beim Gratisweg die Gratis-Zustimmung), nicht die des Registers throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheConsent(feature.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.) * @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') } // 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 }) } const INKLUSIVE_TEXT = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.' /** * Pruefstufe 7b: Funktionen mit aboInklusive (Oeffnungszeiten live) kosten im laufenden Wartungs-Abo nichts. Laeuft das Abo und ist * es nicht gekuendigt (bestellbar ok) oder nur gerade nicht aktiv (nicht_aktiv: Probezeit, offene Zahlung), gibt es keinen Grund zu * bezahlen: 409 inklusive mit dem Hinweis aufs Einschalten; body.grund ist null (Abo laeuft) oder 'nicht_aktiv' (wie zeiten.grund in * GET /api/funktionen). kein_abo und gekuendigt lassen den 30-Euro-Weg offen. Wie pruefeAbo: gleicher Memo je Anfrage, ein Lesefehler * wirft (500, die Route zeigt nur ihren allgemeinen Text), im Zweifel nicht bestellbar; ein Ergebnis ohne gueltigen Grund ist ein Fehler. * Funktionen ohne aboInklusive (News, Terminplaner) lassen es unberuehrt und lesen das Abo hier nicht. */ export async function pruefeInklusive(feature, customerId) { if (feature.aboInklusive !== true) return let ergebnis try { ergebnis = await orderDeps.aboMemo().bestellbar(customerId) } catch (err) { throw Object.assign(new Error(`Abo-Prüfung fehlgeschlagen: ${err?.message}`), { status: 500, cause: err }) } if (ergebnis?.ok === true) throw bestellFehler(409, INKLUSIVE_TEXT, 'inklusive', { grund: null }) const grund = ergebnis?.grund if (grund === 'nicht_aktiv') throw bestellFehler(409, INKLUSIVE_TEXT, 'inklusive', { grund }) if (grund === 'kein_abo' || grund === 'gekuendigt') return throw new Error('Abo-Prüfung ohne gültiges Ergebnis') } const GRATIS_ABO_TEXT = { kein_abo_gekuendigt: (name, preis) => `Ohne laufendes Wartungs-Abo kannst Du ${name} für einmalig ${preis} bestellen.`, nicht_aktiv: () => 'Einschaltbar, sobald Dein Abo aktiv ist.', } const GERADE_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.' /** * Abo-Pruefung des Gratisweges "Einschalten" von Oeffnungszeiten live (Spec 3.2 Schritt 1): immer frisch gelesen (bestellbar direkt, * nie aboMemo), gedacht fuer den Aufruf unter der Sperre feature-order. Laeuft das Wartungs-Abo und ist es aktiv, geschieht nichts. * kein_abo und gekuendigt: 409 (code und body.grund wie der Grund) mit dem Hinweis auf den 30-Euro-Weg; nicht_aktiv: 409 "Einschaltbar, * sobald Dein Abo aktiv ist." Ein Lesefehler ist 503 (fail-closed, nie stillschweigend erlaubt); im Log stehen nur Art und Status. */ export async function pruefeAboFuerEinschalten(customerId) { const feature = featureConfig('zeiten') let ergebnis try { ergebnis = await bestellbar(customerId) } catch (err) { console.error(`[funktionen] Abo für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) throw Object.assign(new Error(GERADE_NICHT_PRUEFBAR), { status: 503 }) } if (ergebnis?.ok === true) return const grund = ergebnis?.grund if (grund === 'nicht_aktiv') throw bestellFehler(409, GRATIS_ABO_TEXT.nicht_aktiv(), grund, { grund }) if (grund === 'kein_abo' || grund === 'gekuendigt') { throw bestellFehler(409, GRATIS_ABO_TEXT.kein_abo_gekuendigt(feature.label.name, feature.priceLabel), grund, { grund }) } throw new Error('Abo-Prüfung ohne gültiges Ergebnis') }