diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 7bf3c18..811b338 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -20,6 +20,7 @@ import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from import { termineFuerKunde } from '../services/termine/schalter.js' import { zeitenFuerKunde } from '../services/zeiten/schalter.js' import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js' +import { starteBehalten } from '../services/zeiten/behalten.js' import { aboMemo } from '../services/aboStatus.js' import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js' import { bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js' @@ -374,6 +375,48 @@ router.post('/zeiten/einschalten', requireSession, async (req, res) => { } }) +const BEHALTEN_ERROR = 'Das Behalten konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' + +// Behalten (Spec 3.3): nach dem Ende des Wartungs-Abos Öffnungszeiten live fuer einmalig 30 € behalten, ohne neuen Einbau. Vorpruefungen wie im +// Checkout, jede Stufe ohne Seiteneffekt: 1 Kundenansicht, 2 Zustimmung (die der Bestellung, beide Haken), 3 Projekt (eigen; eine laufende Funktion +// zu behalten ist keine Neubestellung, ein archiviertes Projekt bleibt erlaubt), 4 Schalter/Pilot, 5 Unterlagen. Ob behalten werden darf, entscheidet +// das Dokument unter der Sperre (starteBehalten: aboEndeAt, sonst 409); den Zustand aendert die Route nicht, erst die bezahlte Sitzung (Webhook). +// Die Route steht nicht auf der Erlaubnisliste der Kundenansicht (Mitarbeiter bestellen nicht fuer den Kunden); die Pruefung hier ist die zweite Sicherung. +router.post('/zeiten/behalten', requireSession, async (req, res) => { + // Zeit des Klicks: Eingang der Anfrage, Anker des Nachweises und der Metadaten (consentAt) und damit der Widerrufsfrist + const now = new Date() + try { + // 1 Kundenansicht + if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' }) + const feature = featureConfig('zeiten') + const body = req.body || {} + // 2 Zustimmung: alle Pflicht-Haken der Bestellung und die aktuelle Fassung + pruefeZustimmung(feature, body) + // 3 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) + // 4 Schalter (ZEITEN_ENABLED und Pilotliste) + const sperre = featureSperre('zeiten', req.session.email) + if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar') + // 5 Vertragsunterlagen abrufbar + const haken = featureHaken('zeiten') + await pruefeUnterlagen(haken) + + const customer = await getDocument(config.collections.customers, customerId) + const { url } = await starteBehalten({ + customer, + projectId, + now, + nachweis: (sessionId) => speichereNachweisAusAnfrage(req, { feature, haken, customerId, projectId, sessionId, klickAt: now.toISOString() }), + }) + res.json({ url }) + } catch (err) { + fail(res, err, BEHALTEN_ERROR) + } +}) + const analyseGuard = [requireSession, requireAnalyseEnabled] router.post('/analyse/activate', analyseGuard, async (req, res) => { diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index cc859dc..d7b03ee 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -324,7 +324,7 @@ function offenerFall(doc) { } /** Neuester Eintrag zuerst, hoechstens 300 Zeichen (wie die Notiz im Ticketsystem). */ -function notizVorn(previous, entry) { +export function notizVorn(previous, entry) { const neu = entry.slice(0, NOTE_MAX) const aelter = String(previous || '') return aelter ? `${neu} | ${aelter}`.slice(0, NOTE_MAX) : neu @@ -757,7 +757,7 @@ export async function customerOf(customerId) { } /** Link zur Stripe-Rechnung; fehlt sie noch oder ist sie nicht lesbar, geht die Mail ohne Link raus. */ -async function invoiceOf(invoiceRef) { +export async function invoiceOf(invoiceRef) { const id = typeof invoiceRef === 'string' ? invoiceRef : invoiceRef?.id if (!id) return { invoiceId: '', invoiceUrl: '' } try { @@ -818,7 +818,7 @@ async function teamNotice(doc, customer, { failed = false, keinAbo = false } = { } /** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */ -async function unmatchedNotice(session) { +export async function unmatchedNotice(session) { const { customerId, projectId, featureKey } = session.metadata || {} const customer = customerId ? await customerOf(customerId).catch(() => null) : null await sendT16( @@ -920,14 +920,18 @@ export function meldeZahlungFall(doc, fall, { customer = null } = {}) { * am (frisch gelesenen) Dokument der offene Fall 'abo_ende' steht - so auch, wenn der Abgleich vor dem Webhook-Versand zuerst meldet; * ticket_offen, wenn das Dokument ein Ticket hat (kein Ticket und keine spaete Zahlung: keiner von beiden); rechnung mit Rechnungslink. * Genau einmal je Dokument (siehe meldeEinmal), wirft nie. + * ticketOffen und rechnungUrl ueberschreiben, was sonst am Dokument steht (Ticket vorhanden, invoiceUrl): bei einer Zahlung, die nicht zu + * dessen Ticket und Rechnung gehoert (Behalten, zeiten/behalten.js), sagt T19 sonst "Ticket offen" und zeigt die Rechnung einer anderen Zahlung. + * rechnungUrl '' heisst "keine Rechnung"; null (Vorgabe) nimmt die des Dokuments. * @param {object} doc portalFeatures-Dokument - * @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean }} [options] + * @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean, ticketOffen?: boolean|null, rechnungUrl?: string|null }} [options] * @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>} */ -export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) { +export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false, ticketOffen = null, rechnungUrl = null } = {}) { return meldeEinmal(doc, 'T19 Erstattung faellig', async (frisch) => { const kopf = await meldeKopf(frisch, customer) const woid = frisch.ticketId ? await ticketWoidOf(frisch).catch(() => frisch.ticketId) : '' + const rechnung = rechnungUrl ?? frisch.invoiceUrl ?? '' return sendAdminNotice({ template: 'T19', values: { @@ -936,12 +940,12 @@ export async function meldeErstattung(doc, { customer = null, sitzung = doc.stri betrag: featureConfig(frisch.featureKey)?.priceLabel || '', sitzung: sitzung || '', woid, - rechnung_url: frisch.invoiceUrl || '', + rechnung_url: rechnung, }, blocks: { zahlung_nach_ende: nachAboEnde || frisch.zahlungMeldung === 'abo_ende', - ticket_offen: Boolean(frisch.ticketId), - rechnung: Boolean(frisch.invoiceUrl), + ticket_offen: ticketOffen ?? Boolean(frisch.ticketId), + rechnung: Boolean(rechnung), }, }) }) @@ -968,7 +972,18 @@ async function meldeFallZurZahlung(doc, fall, session) { if (!ergebnis.sent && !ergebnis.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (${fall}) nicht gesendet`) } +/** + * Zahlung "fuer 30 EUR behalten" (Oeffnungszeiten live nach dem Abo-Ende, Spec 3.3): eigener Weg in zeiten/behalten.js, nie ueber + * markPaid und adoptPaidSession (die schalten nur aus 'ordered' ein und meldeten die Zahlung einer laufenden Funktion als ungeklaert). + * Dynamischer Import: zeiten/* importiert dieses Modul. + */ +async function onBehaltenPaid(session) { + const { bucheBehalten } = await import('./zeiten/behalten.js') + await bucheBehalten(session) +} + async function onFeaturePaid(session) { + if (session.metadata?.zweck === 'behalten') return onBehaltenPaid(session) const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice) const paid = { sessionId: session.id, invoiceId, invoiceUrl } let { doc, created, fall } = await markPaid(paid) @@ -1027,16 +1042,24 @@ export async function handleFeatureCheckoutEvent(event) { await onFeaturePaid(session) return case 'checkout.session.async_payment_failed': { + if (session.metadata?.zweck === 'behalten') return verwerfeBehaltenSitzung(session) const doc = await markCheckoutFailed(session.id) if (doc && doc.state === 'off') await teamNotice(doc, await customerOf(doc.customerId), { failed: true }) return } case 'checkout.session.expired': + if (session.metadata?.zweck === 'behalten') return verwerfeBehaltenSitzung(session) await markCheckoutFailed(session.id) return } } +/** Behalten-Zahlung abgelaufen oder fehlgeschlagen: die offene Sitzung am Dokument wird frei (Dynamischer Import wie onBehaltenPaid). */ +async function verwerfeBehaltenSitzung(session) { + const { verwerfeBehalten } = await import('./zeiten/behalten.js') + await verwerfeBehalten(session) +} + /** * Selbstheilung der Oeffnungszeiten (Spec 3.2): fuer jedes Dokument der Zeiten in 'setup' holt holeZeitenNach fehlende Schritte * nach (Neubau, Einbau-Ticket, K24; es sperrt je Dokument selbst und fasst Kauf, aktiv und aus nicht an). Hierher und nicht in diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index c0ffc80..735e599 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -655,15 +655,22 @@ export async function createAboCheckout(customer, consent, productKey, offer = n * 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die Rechnung erzeugt Stripe * selbst und traegt featureKey und projectId in ihren Metadaten. Nur die Zuordnung steht in den Metadaten der Sitzung, * der Zustimmungswortlaut nicht. + * zweck: 'behalten' (Oeffnungszeiten live nach dem Abo-Ende, Spec 3.3) steht in den Metadaten der Sitzung und der Rechnung, damit der + * Webhook die Zahlung nicht als Neubestellung verbucht, und die Rueckkehr traegt `behalten=1` statt `bestellt=1`; ohne zweck bleibt alles wie es war. * @returns {Promise<{ id: string, url: string }>} */ -export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) { +export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt, zweck = null }) { const feature = featureConfig(key) if (!feature) { const err = new Error('Unbekannte Funktion.') err.status = 400 throw err } + if (zweck !== null && zweck !== 'behalten') { + const err = new Error('Unbekannter Zweck.') + err.status = 400 + throw err + } const stripe = getStripe() const [priceId, stripeCustomerId] = await Promise.all([ensurePrice(feature.product), getOrCreateStripeCustomer(customer)]) // Rueckkehr ins Dashboard: bei Funktionen mit returnProjekt (Terminplaner) mit dem Projekt, damit die Seite es oeffnet @@ -683,12 +690,12 @@ export async function createFeatureCheckout(customer, { projectId, key = 'news', invoice_data: { description: catalogFor(feature.product).name, footer: config.stripe.invoiceFooter, - metadata: { featureKey: key, projectId }, + metadata: { featureKey: key, projectId, ...(zweck ? { zweck } : {}) }, }, }, - metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) }, + metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}), ...(zweck ? { zweck } : {}) }, expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60, - success_url: `${returnUrl}?bestellt=1${projekt ? `&projekt=${projekt}` : ''}`, + success_url: `${returnUrl}?${zweck === 'behalten' ? 'behalten' : 'bestellt'}=1${projekt ? `&projekt=${projekt}` : ''}`, cancel_url: `${returnUrl}${projekt ? `?projekt=${projekt}` : ''}`, } let session diff --git a/server/services/zeiten/behalten.js b/server/services/zeiten/behalten.js new file mode 100644 index 0000000..219cfe1 --- /dev/null +++ b/server/services/zeiten/behalten.js @@ -0,0 +1,259 @@ +import { config } from '../../config.js' +import { getDocument, updateDocument } from '../appwriteAdmin.js' +import { featureConfig } from '../featureRegistry.js' +import { withLocks } from '../keyedLock.js' +import { sendCustomerMail } from '../mailer.js' +import { bestellMail, customerOf, getFeatureDoc, invoiceOf, meldeErstattung, notizVorn, projectName, stateFields, unlockedByVon, unmatchedNotice } from '../siteFeatures.js' +import { createFeatureCheckout, retrieveCheckoutSession } from '../stripeBilling.js' +import { stosseNeubauAn } from './neubau.js' +import { ladeZeiten } from './speicher.js' + +/** + * "Fuer 30 EUR behalten": nach dem Ende des Wartungs-Abos behaelt der Kunde Oeffnungszeiten live fuer einmalig 30 EUR, ohne neuen Einbau + * und ohne neues Ticket (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 3.3). Eigener Modus neben Bestellen und Einschalten: + * markOrdered und markPaid antworten bei setup/active/disabled mit 409 bzw. als Anomalie. + * + * starteBehalten Stripe-Checkout mit zweck 'behalten' und behaltenSessionId am Dokument. Der Zustand bleibt unveraendert, die Leisten + * bleiben waehrend der Zahlung live. Unter der Sperre `feature-order::` (wie Checkout und Einschalten). + * bucheBehalten Webhook: passt die Sitzung zu behaltenSessionId, wird das Dokument unter `feature-admin:` zum Kauf (unlockedBy + * kauf, Frist weg) und, wenn es abgeschaltet war, wieder eingeschaltet (Neubau nach der Sperre). K15 mit Behalten-Wortlaut + * nach der Sperre. Passt sie nicht (schon behalten, Daten geloescht, vom Team abgeschaltet): nichts wird gebucht, T19 + * "Erstattung faellig" geht nach der Sperre ans Team. + * verwerfeBehalten Webhook: Sitzung abgelaufen oder SEPA fehlgeschlagen => behaltenSessionId wird frei. + * + * Sperren (keyedLock) sind nicht wiederbetretbar: stosseNeubauAn, K15 und T19 laufen nie innerhalb von `feature-admin` oder `feature-order`. + */ + +const KEY = 'zeiten' + +const konflikt = (text) => Object.assign(new Error(text), { status: 409 }) + +const NICHT_MOEGLICH = 'Behalten ist gerade nicht möglich. Das geht nur nach dem Ende deines Wartungs-Abos, solange deine Öffnungszeiten noch nicht gelöscht sind.' +const ZAHLUNG_LAEUFT = 'Deine Zahlung wird gerade bestätigt. Bitte schau in einigen Minuten noch einmal vorbei.' +const STRIPE_NICHT_ERREICHBAR = 'Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.' + +/** Sind Eingaben des Kunden gespeichert (oder nur Team-Einstellungen bzw. gar nichts)? Lesefehler werden weitergereicht. */ +async function datenDa(featureId) { + return (await ladeZeiten(featureId))?.daten != null +} + +/** + * Darf der Kunde die Funktion jetzt behalten? In Einrichtung oder aktiv: sobald das Ende des Abos erkannt ist (aboEndeAt). Abgeschaltet: + * nur nach dem Abo-Ende (disabledReason abo_ende, nicht: Team, vor der Einrichtung, Kunde geloescht) und solange die Eingaben noch da sind. + */ +async function behaltenMoeglich(doc) { + if (!doc) return false + if (doc.state === 'setup' || doc.state === 'active') return Boolean(doc.aboEndeAt) + if (doc.state === 'disabled' && doc.disabledReason === 'abo_ende') return datenDa(doc.$id) + return false +} + +/** Die gespeicherte Behalten-Sitzung bei Stripe: offen => { url }; abgelaufen oder unbekannt => null (neue Sitzung); sonst 409 bzw. 503. */ +async function offeneSitzung(sessionId) { + let session + try { + session = await retrieveCheckoutSession(sessionId) + } catch (err) { + if (err?.statusCode === 404 || err?.code === 'resource_missing') return null + console.warn(`[zeiten] Behalten: Sitzung nicht abrufbar: ${err?.name ?? 'Fehler'} ${err?.statusCode ?? err?.status ?? ''}`.trim()) + throw Object.assign(new Error(STRIPE_NICHT_ERREICHBAR), { status: 503 }) + } + if (session.status === 'open' && session.url) return { url: session.url } + if (session.status === 'expired') return null + // abgeschlossen: Zahlung eingegangen oder (SEPA) unterwegs; der Webhook bucht oder gibt die Sitzung frei + throw konflikt(ZAHLUNG_LAEUFT) +} + +/** + * Behalten-Checkout starten (Spec 3.3). Die Vorpruefungen der Route (Kundenansicht, Zustimmung, Projekt, Schalter, Unterlagen) liegen + * davor; hier entscheidet das Dokument. Unter der Sperre `feature-order::`: zwei schnelle Klicks legen eine Sitzung an, ein + * zweiter Klick bekommt die offene Zahlungsseite zurueck (eine abgelaufene wird ersetzt). Reihenfolge wie im Checkout: erst die Stripe-Sitzung + * (der Nachweis traegt ihre ID, sie ist der Anker der Widerrufsfrist), dann der Nachweis, dann behaltenSessionId; scheitert der Nachweis, + * gibt es keine URL und kein behaltenSessionId (die Sitzung verfaellt nach 24 h). Der Zustand des Dokuments aendert sich nicht. + * @param {{ customer: { $id: string }, projectId: string, nachweis: (sessionId: string) => Promise, now?: Date }} p + * customer: Kundendokument; nachweis: speichert den Zustimmungsnachweis zur Stripe-Sitzung; now: Zeit des Klicks (Metadaten consentAt) + * @returns {Promise<{ url: string }>} + * @throws {Error} 409 (nicht moeglich, Zahlung laeuft), 503 (Stripe nicht erreichbar), sonst Fehler von Appwrite, Stripe und Nachweis + */ +export async function starteBehalten({ customer, projectId, nachweis, now = new Date() } = {}) { + if (typeof customer?.$id !== 'string' || customer.$id === '') throw new TypeError('starteBehalten: customer fehlt') + if (typeof projectId !== 'string' || projectId === '') throw new TypeError('starteBehalten: projectId fehlt') + if (typeof nachweis !== 'function') throw new TypeError('starteBehalten: nachweis fehlt') + const customerId = customer.$id + + return withLocks([`feature-order:${customerId}:${projectId}`], async () => { + const doc = await getFeatureDoc(customerId, projectId, KEY) + if (!(await behaltenMoeglich(doc))) throw konflikt(NICHT_MOEGLICH) + + // eine schon begonnene Zahlung: dieselbe Seite nochmal, nie eine zweite Sitzung daneben + if (doc.behaltenSessionId) { + const offen = await offeneSitzung(doc.behaltenSessionId) + if (offen) return offen + } + + const created = await createFeatureCheckout(customer, { projectId, key: KEY, consentAt: now.toISOString(), zweck: 'behalten' }) + await nachweis(created.id) + await updateDocument(config.collections.portalFeatures, doc.$id, { behaltenSessionId: created.id }) + return { url: created.url } + }) +} + +/** Passt die bezahlte Sitzung (mit passender behaltenSessionId) noch zum Zustand? Abgeschaltet nur nach dem Abo-Ende und mit Eingaben. */ +async function passtZumZustand(doc) { + if (doc.state === 'setup' || doc.state === 'active') return true + if (doc.state === 'disabled' && doc.disabledReason === 'abo_ende') return datenDa(doc.$id) + return false +} + +/** Zeitpunkt des Klicks aus den Metadaten der Sitzung (consentAt), sonst jetzt. */ +function bestelltAm(session) { + const ms = Date.parse(session.metadata?.consentAt) + return new Date(Number.isNaN(ms) ? Date.now() : ms).toISOString() +} + +/** Das Projekt fuer den Neubau; nicht lesbar oder geloescht ergibt null (der Neubau meldet dann manuell). */ +async function ladeProjekt(projectId) { + try { + return await getDocument(config.collections.websiteProjects, projectId) + } catch (err) { + if (err?.status !== 404) console.error(`[zeiten] Behalten: Projekt ${projectId} fuer den Neubau nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + return null + } +} + +/** + * Buchung unter `feature-admin:`: das Dokument zum Kauf machen. Schon gebucht (stripeSessionId ist diese Sitzung, Wiederholung des + * Webhooks) => { schonGebucht }. Passt die Sitzung nicht => nichts gebucht; der Vermerk mit der Sitzung (einmal je Sitzung, der Schutz vor + * einem zweiten T19 bei Wiederholung) und das Zuruecksetzen eines frueheren Markers 'gemeldet' (sonst verschluckte meldeEinmal dieses T19) + * stehen in einem Schreibzugriff. + */ +async function buche(session, { customerId, projectId, invoice }) { + const feature = featureConfig(KEY) + return withLocks([`feature-admin:${projectId}`], async () => { + const doc = await getFeatureDoc(customerId, projectId, KEY) + if (!doc) return { fall: 'unpassend', doc: null } + if (doc.stripeSessionId === session.id && unlockedByVon(doc) === 'kauf') return { fall: 'behalten', doc, schonGebucht: true } + + const jetzt = new Date().toISOString() + if (doc.behaltenSessionId !== session.id || !(await passtZumZustand(doc))) { + if (String(doc.adminNote || '').includes(session.id)) return { fall: 'unpassend', doc } + const vermerkt = await updateDocument(config.collections.portalFeatures, doc.$id, { + adminNote: notizVorn(doc.adminNote, `${jetzt} System: Behalten-Zahlung (Sitzung ${session.id}) passt nicht, Erstattung fällig`), + ...(doc.zahlungMeldung === 'gemeldet' ? { zahlungMeldung: '' } : {}), + }) + return { fall: 'unpassend', doc: vermerkt } + } + + const warAus = doc.state === 'disabled' + const gebucht = await updateDocument(config.collections.portalFeatures, doc.$id, { + // abgeschaltet: zurueck auf active, wenn die Funktion schon einmal freigeschaltet war (activatedAt, das Ticket war geschlossen), sonst + // setup; Grund und Marker der Abschaltung enden damit (wie activate), activatedAt bleibt. zahlungMeldung bleibt unberuehrt. + ...(warAus ? { ...stateFields(doc.activatedAt ? 'active' : 'setup'), disabledAt: null, disabledReason: null, aboEndeMailAt: null } : {}), + featureKey: KEY, + unlockedBy: 'kauf', + unlockedByPurchase: true, + purchaseStatus: 'paid', + orderedAt: bestelltAm(session), + aboEndeAt: null, + behaltenSessionId: '', + stripeSessionId: session.id, + stripeInvoiceId: invoice.invoiceId || '', + invoiceUrl: invoice.invoiceUrl || '', + adminNote: notizVorn(doc.adminNote, `${jetzt} System: Nach dem Abo-Ende für ${feature.priceLabel} behalten (Sitzung ${session.id})`), + }) + return { fall: 'behalten', doc: gebucht, warAus } + }) +} + +/** Nach der Buchung, ausserhalb der Sperre: Neubau (nur wenn die Funktion abgeschaltet war), dann K15. Wirft nie: gebucht ist gebucht. */ +async function nachBuchung(doc, { warAus, invoice }) { + if (warAus) { + const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId)) + try { + await updateDocument(config.collections.portalFeatures, doc.$id, { einbau }) + } catch (err) { + console.error(`[zeiten] Behalten: einbau für Projekt ${doc.projectId} nicht gespeichert: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + } + } + try { + const customer = await customerOf(doc.customerId) + if (!customer?.email) return + await sendCustomerMail( + customer.email, + bestellMail(featureConfig(KEY), { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl: invoice.invoiceUrl, behalten: true }) + ) + } catch (err) { + console.error('[zeiten] K15 (Behalten) fehlgeschlagen:', err?.message) + } +} + +/** + * Eine bezahlte Behalten-Sitzung verbuchen (Webhook-Zweig zweck 'behalten', vor markPaid und adoptPaidSession); idempotent. + * @param {{ id: string, invoice?: unknown, metadata?: { customerId?: string, projectId?: string, featureKey?: string, consentAt?: string } }} session + * bezahlte Checkout-Sitzung (checkout.session.completed mit payment_status paid bzw. async_payment_succeeded) + * @returns {Promise<{ fall: 'behalten'|'unpassend' }>} behalten: gebucht (auch bei einer Wiederholung); unpassend: nichts gebucht, das Team + * bekommt T19 (bzw. T16 ungeklaert, wenn es zur Sitzung gar kein Dokument gibt) + * @throws {Error} bei Lesefehlern und Schreibfehlern der Buchung sowie wenn T19 weder raus ist noch schon draussen war: der Webhook + * antwortet 500 und Stripe wiederholt + */ +export async function bucheBehalten(session) { + const { customerId, projectId, featureKey } = session?.metadata ?? {} + if (typeof session?.id !== 'string' || session.id === '' || featureKey !== KEY || !customerId || !projectId) { + await meldeOhneDokument(session) + return { fall: 'unpassend' } + } + // die Rechnung vor der Sperre (Netzwerkanfrage); nicht lesbar => ohne Link + const invoice = await invoiceOf(session.invoice) + + const ergebnis = await buche(session, { customerId, projectId, invoice }) + if (ergebnis.schonGebucht) return { fall: 'behalten' } + if (ergebnis.fall === 'behalten') { + await nachBuchung(ergebnis.doc, { warAus: ergebnis.warAus, invoice }) + return { fall: 'behalten' } + } + + // unpassend, ausserhalb der Sperre: T19 an das Team + if (!ergebnis.doc) { + await meldeOhneDokument(session) + return { fall: 'unpassend' } + } + console.error( + `[funktionen] BEZAHLTE Behalten-Sitzung ${session.id} passt nicht zur Funktion zeiten (Kunde ${customerId}, Projekt ${projectId}, Zustand ${ergebnis.doc.state}) - nichts gebucht, Erstattung wird gemeldet` + ) + const customer = await customerOf(customerId).catch((err) => { + console.error('[funktionen] Kunde für die Zahlungsmeldung nicht lesbar:', err.message) + return null + }) + // Die Zahlung gehoert zu keinem Ticket und nicht zur Rechnung am Dokument: T19 sagt "Zahlung nach dem Abo-Ende" und nennt deren Rechnung + const gemeldet = await meldeErstattung(ergebnis.doc, { customer, sitzung: session.id, nachAboEnde: true, ticketOffen: false, rechnungUrl: invoice.invoiceUrl }) + if (!gemeldet.sent && !gemeldet.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (Behalten passt nicht) nicht gesendet`) + return { fall: 'unpassend' } +} + +/** Bezahlte Sitzung ohne Dokument (oder ohne verwertbare Metadaten): T16 "ungeklaert", wie adoptPaidSession; ein Fehler dabei wird nur geloggt. */ +async function meldeOhneDokument(session) { + console.error( + `[funktionen] BEZAHLTE Behalten-Sitzung ${session?.id} ohne passende Funktion (Kunde ${session?.metadata?.customerId}, Projekt ${session?.metadata?.projectId}) - bitte von Hand pruefen` + ) + try { + await unmatchedNotice(session) + } catch (err) { + console.error('[funktionen] Hinweis zur ungeklaerten Zahlung fehlgeschlagen:', err?.message) + } +} + +/** + * Behalten-Sitzung abgelaufen oder die SEPA-Zahlung fehlgeschlagen: behaltenSessionId wird frei, wenn es diese Sitzung ist (Stripe meldet + * eine fehlgeschlagene Lastschrift als abgeschlossene Sitzung, der Kunde kaeme sonst nie mehr zu einer neuen). Der Zustand bleibt. + * @returns {Promise} true, wenn die Sitzung freigegeben wurde + */ +export async function verwerfeBehalten(session) { + const { customerId, projectId, featureKey } = session?.metadata ?? {} + if (typeof session?.id !== 'string' || session.id === '' || featureKey !== KEY || !customerId || !projectId) return false + return withLocks([`feature-admin:${projectId}`], async () => { + const doc = await getFeatureDoc(customerId, projectId, KEY) + if (!doc || doc.behaltenSessionId !== session.id) return false + await updateDocument(config.collections.portalFeatures, doc.$id, { behaltenSessionId: '' }) + return true + }) +} diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 4c948bb..e39c9f4 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -133,6 +133,18 @@ test('Einschalten der Öffnungszeiten ist in der Kundenansicht gesperrt (Route g assert.equal(res.body.code, 'view_blocked') }) +// "Fuer 30 EUR behalten" (POST /api/funktionen/zeiten/behalten): ebenfalls eine Bestellung im Namen des Kunden, nicht auf der Erlaubnisliste. +test('Behalten der Öffnungszeiten ist in der Kundenansicht gesperrt (Route gefunden, nicht erlaubt, 403 view_blocked)', async () => { + const routes = writeRoutes(MOUNTS) + assert.ok(routes.some(({ method, path: p }) => method === 'POST' && p === '/api/funktionen/zeiten/behalten'), 'Route gefunden') + const p = '/api/funktionen/zeiten/behalten' + assert.ok(!allowed('POST', p), p) + assert.ok(!allowed('POST', `${p}/`), `${p}/`) + const res = await call('POST', p, { ...kv(), body: { projectId: 'p1', zustimmung: { version: '2026-10-04', haken: ['leistung', 'beginn'] } } }) + assert.equal(res.status, 403) + assert.equal(res.body.code, 'view_blocked') +}) + test('Erlaubnisliste: genau News, Terminplaner, Öffnungszeiten, Analyse-Link, Ende, Logout und Login', () => { const ok = [ ['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'], diff --git a/test/zeitenBehalten.test.js b/test/zeitenBehalten.test.js new file mode 100644 index 0000000..e11dcb6 --- /dev/null +++ b/test/zeitenBehalten.test.js @@ -0,0 +1,760 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// "Fuer 30 EUR behalten" (Aufgabe 20, Spec 3.3): nach dem Ende des Wartungs-Abos behaelt der Kunde Oeffnungszeiten live fuer einmalig +// 30 EUR, ohne neues Einbau-Ticket. POST /api/funktionen/zeiten/behalten (starteBehalten), Webhook-Zweig zweck 'behalten' +// (bucheBehalten in zeiten/behalten.js, vor markPaid/adoptPaidSession) und createFeatureCheckout mit zweck. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + featureConsents: schemaKeys('featureConsents'), + termineConfig: schemaKeys('termineConfig'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + // Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null) + datetimes: { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], + featureConsents: ['acceptedAt'], + }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, post, postWebhook } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, seedFixtures } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') +const { default: aboRoutes } = await import('../server/routes/abo.js') +const sf = await import('../server/services/siteFeatures.js') +const { zustimmungFuerSitzung } = await import('../server/services/featureConsents.js') +const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') +const { speichereZeiten, speichereTeam, loescheZeiten } = await import('../server/services/zeiten/speicher.js') +const { bucheBehalten } = await import('../server/services/zeiten/behalten.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') + +const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +after(() => setMailTransportForTests(null)) + +const Z = config.features.zeiten +const MODUS = { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] } +const c1 = customerCookie('c1') +const SITE_KEY = 'abcdefghijklmnop' +const ABO_ENDE = '2026-10-01T06:00:00.000Z' +const ZUVOR = '2026-09-20T10:00:00.000Z' +const NICHT_MOEGLICH = 'Behalten ist gerade nicht möglich. Das geht nur nach dem Ende deines Wartungs-Abos, solange deine Öffnungszeiten noch nicht gelöscht sind.' +const SUBJECT = { + K15: 'Deine Bestellung: Öffnungszeiten live', + T16: 'Neue Funktion bestellt: Öffnungszeiten live', + T19: '[Abo] Erstattung fällig: Öffnungszeiten live-Einrichtung', +} + +const projekt = (id, over = {}) => + aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over }) + +const bezahlteHaken = () => Z.consent.items.filter((item) => item.haken).map((item) => item.id) +const koerper = (projectId = 'p1', extra = {}) => ({ projectId, zustimmung: { version: Z.consent.version, haken: bezahlteHaken() }, ...extra }) +const behalten = (body, cookie = c1) => post('/api/funktionen/zeiten/behalten', body, { cookie }) +const checkout = (body, cookie = c1) => post('/api/funktionen/zeiten/checkout', body, { cookie }) + +const sessionAufrufe = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') +const preisDerSitzung = (aufruf) => st.prices.get(aufruf.body['line_items[0][price]']) +const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`) +const tickets = () => [...aw.docs('workorders').values()] +const nachweise = () => [...aw.docs('featureConsents').values()] +/** Appwrite liefert Datumsfelder als "...+00:00": verglichen wird der Zeitpunkt. */ +const zeit = (wert) => (wert ? Date.parse(wert) : null) +const mailsMit = (subject) => sink.sent.filter((m) => m.subject === subject) +const spuren = () => ({ sitzungen: sessionAufrufe().length, nachweise: nachweise().length, mails: sink.sent.length, tickets: tickets().length, neubauten: rebuilds.length }) +const keineSpuren = () => assert.deepEqual(spuren(), { sitzungen: 0, nachweise: 0, mails: 0, tickets: 0, neubauten: 0 }) +const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } }) +const webhook = (type, session) => postWebhook(event(type, session)) + +/** Ist die Sperre gerade frei? Eine gehaltene Sperre laesst die Probe warten: nach 60 ms gilt sie als gehalten. */ +const sperreFrei = (key) => Promise.race([withLocks([key], async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 60))]) + +/** Dokument der Zeiten wie nach dem Einschalten im Wartungs-Abo, dessen Abo inzwischen endete (aboEndeAt gesetzt). */ +const seedeZeiten = (over = {}) => + aw.seed('portalFeatures', { + $id: 'f_p1_zeiten', + customerId: 'c1', + projectId: 'p1', + featureKey: 'zeiten', + state: 'active', + enabled: true, + unlockedBy: 'abo', + unlockedByPurchase: false, + purchaseStatus: '', + siteKey: SITE_KEY, + ticketId: 't_alt', + activatedAt: ZUVOR, + einbau: 'angestossen', + aboEndeAt: ABO_ENDE, + ...over, + }) +/** Abgeschaltet nach dem Abo-Ende (Frist verstrichen), Eingaben noch da. */ +const seedeAbgeschaltet = (over = {}) => + seedeZeiten({ state: 'disabled', enabled: false, disabledAt: '2026-10-02T06:00:00.000Z', disabledReason: 'abo_ende', aboEndeMailAt: '2026-10-02T06:05:00.000Z', ...over }) + +function tage(woche = {}) { + const t = {} + for (let d = 1; d <= 7; d += 1) { + const w = woche[d] + t[d] = w ? { modus: 'zeiten', fenster: [w] } : { modus: 'zu', fenster: [] } + } + return t +} +const EIN_PLAN = () => ({ + land: 'RP', + plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }) }], + feiertage: {}, + ausnahmen: [], + hinweise: [], + kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, +}) +const mitZeiten = () => speichereZeiten({ feature: zeitenDoc(), eingabe: EIN_PLAN(), version: 0, now: new Date('2026-10-05T08:00:00.000Z') }) + +/** Behalten-Weg bis zur Stripe-Sitzung: die Route antwortet 200 und das Dokument traegt die Sitzung. */ +async function starteBehalten(projectId = 'p1') { + const res = await behalten(koerper(projectId)) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const sessionId = zeitenDoc(projectId).behaltenSessionId + assert.match(sessionId, /^cs_test_/) + return sessionId +} +/** Bezahlte Sitzung im Ereignis, mit den Metadaten, die createFeatureCheckout gesetzt hat; die Rechnung liegt im Stripe-Nachbau. */ +function bezahltAus(sessionId) { + const invoice = `in_${sessionId}` + st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` }) + return { ...st.checkoutSessions.get(sessionId), payment_status: 'paid', status: 'complete', invoice } +} +/** Bezahlte Sitzung, die nicht aus diesem Portal-Lauf stammt (z. B. der zweite Tab): Metadaten wie beim Behalten. */ +function fremdBezahlt(sessionId, { projectId = 'p1', customerId = 'c1', featureKey = 'zeiten' } = {}) { + const invoice = `in_${sessionId}` + st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` }) + return { + id: sessionId, + object: 'checkout.session', + mode: 'payment', + payment_status: 'paid', + status: 'complete', + invoice, + metadata: { customerId, projectId, featureKey, zweck: 'behalten', consentAt: '2026-10-05T08:00:00.000Z' }, + } +} + +let sink +let rebuilds +let sperrenBeimVersand +const originalRebuild = zeitenDeps.rebuild +beforeEach(async () => { + for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects']) { + for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) + } + resetZeitenCacheForTests() + st.checkoutSessions.clear() + st.invoices.clear() + st.calls.length = 0 + seedFixtures(aw) + projekt('p1') + projekt('p3', { customerId: 'c3' }) + aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['*'] + for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'freischaltbar']) delete Z[name] + sink = mailSink() + // Jede Mail merkt, ob die Sperren des Projekts beim Versand frei waren (K15 und T19 gehen erst nach der Sperre raus) + sperrenBeimVersand = [] + const roh = sink.sendMail.bind(sink) + sink.sendMail = async (mail) => { + sperrenBeimVersand.push({ subject: mail.subject, admin: await sperreFrei('feature-admin:p1'), order: await sperreFrei('feature-order:c1:p1') }) + return roh(mail) + } + setMailTransportForTests(sink) + rebuilds = [] + zeitenDeps.rebuild = async (p) => { + rebuilds.push({ + projektId: p.$id, + tickets: aw.docs('workorders').size, + state: zeitenDoc(p.$id)?.state, + unlockedBy: zeitenDoc(p.$id)?.unlockedBy, + adminFrei: await sperreFrei(`feature-admin:${p.$id}`), + }) + return { queued: true } + } + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + mock.method(console, 'log', () => {}) +}) +afterEach(() => { + mock.restoreAll() + zeitenDeps.rebuild = originalRebuild +}) +after(() => { + config.zeiten.mode = MODUS.mode + config.zeiten.pilotEmails = MODUS.pilotEmails +}) + +// ------------------------------------------------------------------------------------------------- Behalten starten + +test('Behalten nur nach Abo-Ende, sonst 409', async () => { + const leeren = async () => { + for (const col of ['portalFeatures', 'oeffnungszeiten', 'featureConsents']) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) + } + // [Name, Dokument oder null, Eingaben gespeichert?] + const faelle = [ + ['kein Dokument', null, false], + ['aus', () => seedeZeiten({ state: 'off', enabled: false, aboEndeAt: null, activatedAt: null, unlockedBy: '' }), false], + ['bestellt', () => seedeZeiten({ state: 'ordered', enabled: false, aboEndeAt: null, activatedAt: null, unlockedBy: '' }), false], + ['aktiv im Abo, ohne Abo-Ende', () => seedeZeiten({ aboEndeAt: null }), true], + ['in Einrichtung im Abo, ohne Abo-Ende', () => seedeZeiten({ state: 'setup', enabled: false, activatedAt: null, aboEndeAt: null }), true], + ['vom Team abgeschaltet', () => seedeAbgeschaltet({ disabledReason: 'team' }), true], + ['abgeschaltet vor der Einrichtung', () => seedeAbgeschaltet({ disabledReason: 'abo_ende_vor_einrichtung' }), true], + ['abgeschaltet nach Abo-Ende, Eingaben gelöscht', () => seedeAbgeschaltet(), false], + ['abgeschaltet nach Abo-Ende, nur Team-Einstellungen (keine Eingaben des Kunden)', () => seedeAbgeschaltet(), 'team'], + ] + for (const [name, vorbereiten, daten] of faelle) { + await leeren() + vorbereiten?.() + if (daten === true) await mitZeiten() + if (daten === 'team') await speichereTeam({ feature: zeitenDoc(), eingabe: { navSelektor: '' }, teamVersion: 0, now: new Date('2026-10-05T08:00:00.000Z') }) + const vorher = zeitenDoc() ? { ...zeitenDoc() } : null + const res = await behalten(koerper()) + assert.equal(res.status, 409, `${name}: ${JSON.stringify(res.body)}`) + assert.equal(res.body.error, NICHT_MOEGLICH, name) + keineSpuren() + if (vorher) assert.deepEqual({ ...zeitenDoc() }, vorher, `${name}: Dokument unverändert`) + else assert.equal(zeitenDoc(), undefined, `${name}: kein Dokument angelegt`) + } + + // dagegen möglich: in Einrichtung oder aktiv mit Abo-Ende, und abgeschaltet nach Abo-Ende mit noch vorhandenen Eingaben + for (const [name, vorbereiten] of [ + ['aktiv mit Abo-Ende', () => seedeZeiten()], + ['in Einrichtung mit Abo-Ende', () => seedeZeiten({ state: 'setup', enabled: false, activatedAt: null })], + ['abgeschaltet nach Abo-Ende, Eingaben da', () => seedeAbgeschaltet()], + ]) { + await leeren() + vorbereiten() + await mitZeiten() + const res = await behalten(koerper()) + assert.equal(res.status, 200, `${name}: ${JSON.stringify(res.body)}`) + assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_test_/, name) + } +}) + +test('Vorprüfungen wie bei der Bestellung: Kundenansicht 403, gleiche Zustimmung (beide Haken), Projekt, Schalter, Unterlagen; alles ohne Spur', async () => { + seedeZeiten() + // Kundenansicht (Mitarbeiter bestellen nicht fuer den Kunden) + const ansicht = await behalten(koerper(), kundenansichtCookie()) + assert.equal(ansicht.status, 403) + assert.equal(ansicht.body.error, 'In der Kundenansicht kann nicht bestellt werden.') + // Zustimmung: ohne, nur der Haken des Gratisweges, falsche Fassung + const ohne = await behalten({ projectId: 'p1' }) + assert.deepEqual([ohne.status, ohne.body.code], [400, 'zustimmung_fehlt']) + const nurEiner = await behalten({ projectId: 'p1', zustimmung: { version: Z.consent.version, haken: ['leistung'] } }) + assert.deepEqual([nurEiner.status, nurEiner.body.code], [400, 'zustimmung_fehlt']) + const alt = await behalten(koerper('p1', { zustimmung: { version: '2020-01-01', haken: bezahlteHaken() } })) + assert.deepEqual([alt.status, alt.body.code], [409, 'fassung_veraltet']) + assert.equal(alt.body.consent.version, Z.consent.version) + // Projekt: keines, fremdes + assert.equal((await behalten({ zustimmung: koerper().zustimmung })).status, 400) + assert.equal((await behalten(koerper('p3'))).status, 403) + // Schalter und Unterlagen + config.zeiten.mode = 'off' + const aus = await behalten(koerper()) + assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_verfuegbar']) + config.zeiten.mode = 'pilot' + Z.unterlagenBereit = async () => false + const unterlagen = await behalten(koerper()) + assert.deepEqual([unterlagen.status, unterlagen.body.code], [503, 'unterlagen_fehlen']) + delete Z.unterlagenBereit + keineSpuren() + assert.equal(zeitenDoc().behaltenSessionId ?? '', '') +}) + +test('während der Zahlung bleibt der Zustand (Leisten bleiben live); Sitzung, Nachweis und Metadaten', async () => { + seedeZeiten({ aboEndeMailAt: '2026-10-01T06:05:00.000Z' }) + const vorher = { ...zeitenDoc() } + const res = await behalten(koerper()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body), ['url']) + const sessionId = zeitenDoc().behaltenSessionId + assert.equal(res.body.url, `https://checkout.stripe.test/c/pay/${sessionId}`) + + // einzige Aenderung am Dokument: behaltenSessionId (Zustand, Freischaltung, Frist, Rechnung bleiben) + const nachher = { ...zeitenDoc() } + const { behaltenSessionId, $updatedAt, ...rest } = nachher + const { behaltenSessionId: _vorher, $updatedAt: _u, ...restVorher } = vorher + assert.equal(behaltenSessionId, sessionId) + assert.deepEqual(rest, restVorher) + assert.deepEqual([nachher.state, nachher.enabled, nachher.unlockedBy, nachher.unlockedByPurchase, zeit(nachher.aboEndeAt)], ['active', true, 'abo', false, zeit(ABO_ENDE)]) + const live = await sf.findActiveBySiteKey(SITE_KEY, 'zeiten', { states: ['setup', 'active'] }) + assert.equal(live?.$id, 'f_p1_zeiten', 'die Leisten antworten weiter') + + // Stripe: einmalig 30 EUR, Metadaten und Rechnungs-Metadaten mit zweck, Rueckkehr zum Editor + assert.equal(sessionAufrufe().length, 1) + const aufruf = sessionAufrufe()[0].body + assert.equal(preisDerSitzung(sessionAufrufe()[0]).unit_amount, 3000) + assert.equal(aufruf.mode, 'payment') + assert.equal(aufruf['metadata[zweck]'], 'behalten') + assert.equal(aufruf['metadata[featureKey]'], 'zeiten') + assert.equal(aufruf['metadata[projectId]'], 'p1') + assert.equal(aufruf['metadata[customerId]'], 'c1') + assert.match(aufruf['metadata[consentAt]'], /^\d{4}-\d\d-\d\dT[\d:.]+Z$/) + assert.equal(aufruf['invoice_creation[invoice_data][metadata][zweck]'], 'behalten') + assert.equal(aufruf['invoice_creation[invoice_data][metadata][featureKey]'], 'zeiten') + assert.equal(aufruf.success_url, `${config.publicBaseUrl}/dashboard.html#zeiten?behalten=1&projekt=p1`) + assert.equal(aufruf.cancel_url, `${config.publicBaseUrl}/dashboard.html#zeiten?projekt=p1`) + + // Nachweis der Zustimmung (die der Bestellung: beide Haken) traegt die Sitzung und den Zeitpunkt des Klicks + assert.equal(nachweise().length, 1) + const nachweis = await zustimmungFuerSitzung(sessionId) + assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], ['c1', 'p1', 'zeiten']) + assert.equal(zeit(nachweis.acceptedAt), zeit(st.checkoutSessions.get(sessionId).metadata.consentAt)) + assert.deepEqual(JSON.parse(nachweis.texte).filter((t) => t.haken).map((t) => t.id), bezahlteHaken()) + + // sonst nichts: keine Mail, kein Ticket, kein Neubau + assert.deepEqual({ ...spuren(), sitzungen: 0, nachweise: 0 }, { sitzungen: 0, nachweise: 0, mails: 0, tickets: 0, neubauten: 0 }) +}) + +test('während der Zahlung bleibt auch ein abgeschaltetes Dokument abgeschaltet', async () => { + seedeAbgeschaltet() + await mitZeiten() + const sessionId = await starteBehalten() + const doc = zeitenDoc() + assert.deepEqual( + [doc.state, doc.enabled, doc.disabledReason, zeit(doc.disabledAt), doc.unlockedBy, zeit(doc.aboEndeAt), doc.behaltenSessionId], + ['disabled', false, 'abo_ende', zeit('2026-10-02T06:00:00.000Z'), 'abo', zeit(ABO_ENDE), sessionId] + ) +}) + +test('zweiter Klick: dieselbe offene Zahlungsseite; abgelaufene Sitzung ⇒ neue; bezahlt oder in Prüfung ⇒ 409', async () => { + seedeZeiten() + const erste = await starteBehalten() + const zweiter = await behalten(koerper()) + assert.equal(zweiter.status, 200) + assert.equal(zweiter.body.url, `https://checkout.stripe.test/c/pay/${erste}`) + assert.equal(sessionAufrufe().length, 1, 'keine zweite Stripe-Sitzung') + assert.equal(nachweise().length, 1, 'kein zweiter Nachweis') + assert.equal(zeitenDoc().behaltenSessionId, erste) + + // abgelaufen: eine neue Sitzung ersetzt die alte, mit eigenem Nachweis + st.checkoutSessions.get(erste).status = 'expired' + const dritter = await behalten(koerper()) + assert.equal(dritter.status, 200) + const neue = zeitenDoc().behaltenSessionId + assert.notEqual(neue, erste) + assert.equal(dritter.body.url, `https://checkout.stripe.test/c/pay/${neue}`) + assert.equal(sessionAufrufe().length, 2) + assert.equal((await zustimmungFuerSitzung(neue)).stripeSessionId, neue) + + // abgeschlossen (Zahlung eingegangen, Webhook noch nicht verbucht): kein neuer Versuch + st.checkoutSessions.get(neue).status = 'complete' + const vierter = await behalten(koerper()) + assert.equal(vierter.status, 409) + assert.match(vierter.body.error, /Zahlung/) + assert.equal(zeitenDoc().behaltenSessionId, neue) + assert.equal(sessionAufrufe().length, 2) +}) + +test('Nachweis scheitert ⇒ 500 ohne Rohtext, keine URL, behaltenSessionId bleibt leer', async () => { + seedeZeiten() + aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500, message: 'Interner Appwrite-Text mit Kunde c1' }) + const res = await behalten(koerper()) + assert.equal(res.status, 500) + assert.equal(JSON.stringify(res.body).includes('Appwrite'), false) + assert.equal(res.body.url, undefined) + assert.equal(zeitenDoc().behaltenSessionId ?? '', '') + assert.equal(nachweise().length, 0) +}) + +test('Abruf der offenen Sitzung scheitert ⇒ 503, es entsteht keine zweite Zahlung; unbekannte Sitzung (404) ⇒ neue Sitzung', async () => { + seedeZeiten() + const erste = await starteBehalten() + // 400 statt 5xx: das SDK wiederholt 5xx von selbst, hier zaehlt, dass der Fehler nie zu einer zweiten Sitzung fuehrt + st.failNext({ method: 'GET', path: `/v1/checkout/sessions/${erste}`, status: 400, message: 'Stripe lehnt ab' }) + const res = await behalten(koerper()) + assert.equal(res.status, 503) + assert.equal(res.body.error, 'Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.') + assert.equal(sessionAufrufe().length, 1) + assert.equal(zeitenDoc().behaltenSessionId, erste) + + // Stripe kennt die Sitzung nicht mehr: wie abgelaufen + st.checkoutSessions.delete(erste) + const neu = await behalten(koerper()) + assert.equal(neu.status, 200, JSON.stringify(neu.body)) + assert.equal(sessionAufrufe().length, 2) + assert.notEqual(zeitenDoc().behaltenSessionId, erste) +}) + +test('Sperre feature-order: zwei gleichzeitige Klicks legen genau eine Sitzung an', async () => { + seedeZeiten() + const [a, b] = await Promise.all([behalten(koerper()), behalten(koerper())]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(a.body.url, b.body.url) + assert.equal(sessionAufrufe().length, 1) + assert.equal(nachweise().length, 1) +}) + +// -------------------------------------------------------------------------------------------------- Zahlung (Webhook) + +test('Webhook behalten: kauf, Frist weg, kein neues Ticket, K15 behalten', async () => { + seedeZeiten({ aboEndeMailAt: '2026-10-01T06:05:00.000Z' }) + const sessionId = await starteBehalten() + const res = await webhook('checkout.session.completed', bezahltAus(sessionId)) + assert.equal(res.status, 200, JSON.stringify(res.body)) + + const doc = zeitenDoc() + assert.deepEqual( + [doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus, doc.aboEndeAt ?? null, doc.behaltenSessionId, doc.stripeSessionId], + ['active', true, 'kauf', true, 'paid', null, '', sessionId] + ) + assert.equal(sf.unlockedByVon(doc), 'kauf') + assert.equal(doc.stripeInvoiceId, `in_${sessionId}`) + assert.equal(doc.invoiceUrl, `https://invoice.stripe.test/in_${sessionId}`) + assert.equal(doc.ticketId, 't_alt', 'das bisherige Ticket bleibt') + assert.equal(doc.activatedAt, '2026-09-20T10:00:00.000+00:00') + assert.equal(doc.einbau, 'angestossen') + assert.match(doc.adminNote, /System: .*behalten.*Sitzung /) + assert.equal(doc.zahlungMeldung ?? '', '') + + // kein neues Einbau-Ticket, kein Neubau, kein T16, kein T19 + assert.equal(tickets().length, 0) + assert.deepEqual(rebuilds, []) + assert.equal(mailsMit(SUBJECT.T16).length, 0) + assert.equal(mailsMit(SUBJECT.T19).length, 0) + + // K15 mit dem Behalten-Wortlaut, nach der Sperre + assert.equal(mailsMit(SUBJECT.K15).length, 1) + const k15 = mailsMit(SUBJECT.K15)[0] + assert.deepEqual(k15.to.map((a) => a.address), ['k@example.invalid']) + assert.match(k15.text, /Du behältst Öffnungszeiten live für Dein Projekt, auch ohne Wartungs-Abo\./) + assert.match(k15.text, /Deine Einstellungen und Daten bleiben unverändert, und es wird nichts neu eingebaut\./) + assert.doesNotMatch(k15.text, /Wir richten/) + assert.doesNotMatch(k15.text, /eingebaut ist, schalten wir/) + assert.ok(k15.text.includes(`https://invoice.stripe.test/in_${sessionId}`), 'Rechnungsknopf') + assert.deepEqual(sperrenBeimVersand, [{ subject: SUBJECT.K15, admin: true, order: true }]) + + // Ansicht: freigeschaltet durch Kauf, keine Frist mehr + const sicht = await sf.featureView(doc, 'zeiten') + assert.deepEqual([sicht.state, sicht.unlockedBy, sicht.aboEndeAt], ['active', 'kauf', null]) + + // Widerrufsfrist: der Anker ist der Klick dieser Sitzung (Nachweis mit der Sitzungs-ID) + const bezahlt = await sf.paidFeatureOrders('c1') + assert.deepEqual(bezahlt.map((b) => [b.featureKey, b.state, zeit(b.vertragAt)]), [['zeiten', 'active', zeit(st.checkoutSessions.get(sessionId).metadata.consentAt)]]) +}) + +test('in Einrichtung mit Abo-Ende: bleibt in Einrichtung (setup), kauf, ohne Neubau und ohne Ticket', async () => { + seedeZeiten({ state: 'setup', enabled: false, activatedAt: null, einbau: 'manuell' }) + const sessionId = await starteBehalten() + assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200) + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.aboEndeAt ?? null, doc.behaltenSessionId, doc.einbau, doc.ticketId], ['setup', false, 'kauf', null, '', 'manuell', 't_alt']) + assert.deepEqual(rebuilds, []) + assert.equal(tickets().length, 0) + assert.equal(mailsMit(SUBJECT.K15).length, 1) +}) + +test('Stripe wiederholt den Webhook (auch als async_payment_succeeded): keine zweite Buchung, keine zweite K15, kein T19', async () => { + seedeZeiten() + const sessionId = await starteBehalten() + assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200) + const gebucht = { ...zeitenDoc() } + assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200) + assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahltAus(sessionId))).status, 200) + assert.deepEqual({ ...zeitenDoc() }, gebucht) + assert.equal(mailsMit(SUBJECT.K15).length, 1) + assert.equal(mailsMit(SUBJECT.T19).length, 0) + assert.equal(mailsMit(SUBJECT.T16).length, 0) + assert.equal(tickets().length, 0) +}) + +test('SEPA: completed ohne Geld bucht nichts, async_payment_succeeded bucht', async () => { + seedeZeiten() + const sessionId = await starteBehalten() + const ausstehend = { ...bezahltAus(sessionId), payment_status: 'unpaid' } + assert.equal((await webhook('checkout.session.completed', ausstehend)).status, 200) + assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId, mailsMit(SUBJECT.K15).length], ['abo', sessionId, 0]) + assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahltAus(sessionId))).status, 200) + assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId, mailsMit(SUBJECT.K15).length], ['kauf', '', 1]) +}) + +test('Rechnung (noch) nicht lesbar: gebucht, K15 ohne Rechnungslink', async () => { + seedeZeiten() + const sessionId = await starteBehalten() + const sitzung = { ...bezahltAus(sessionId), invoice: 'in_gibt_es_nicht' } + assert.equal((await webhook('checkout.session.completed', sitzung)).status, 200) + const doc = zeitenDoc() + assert.deepEqual([doc.unlockedBy, doc.stripeSessionId, doc.invoiceUrl], ['kauf', sessionId, '']) + assert.equal(mailsMit(SUBJECT.K15).length, 1) + assert.doesNotMatch(mailsMit(SUBJECT.K15)[0].text, /Rechnung ansehen/) +}) + +test('K15 scheitert: gebucht bleibt gebucht, Webhook 200 (kein T19)', async () => { + seedeZeiten() + const sessionId = await starteBehalten() + sink.sendMail = async () => { + throw new Error('SMTP down') + } + assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200) + assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId], ['kauf', '']) +}) + +test('Behalten aus disabled: zurück auf active und Neubau (nach der Sperre), kein neues Ticket', async () => { + seedeAbgeschaltet() + await mitZeiten() + const sessionId = await starteBehalten() + assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200) + + const doc = zeitenDoc() + assert.deepEqual( + [doc.state, doc.enabled, doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId], + ['active', true, null, null, null, null, ''] + ) + assert.deepEqual([doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus, doc.stripeSessionId], ['kauf', true, 'paid', sessionId]) + assert.equal(doc.activatedAt, '2026-09-20T10:00:00.000+00:00', 'der frühere Zeitpunkt der Freischaltung bleibt') + assert.equal(doc.ticketId, 't_alt') + assert.equal(tickets().length, 0) + + // Neubau: einmal, als der Zustand schon active/kauf war, ausserhalb der Sperre; das Ergebnis steht in einbau + assert.deepEqual(rebuilds, [{ projektId: 'p1', tickets: 0, state: 'active', unlockedBy: 'kauf', adminFrei: true }]) + assert.equal(doc.einbau, 'angestossen') + assert.equal(mailsMit(SUBJECT.K15).length, 1) + assert.match(mailsMit(SUBJECT.K15)[0].text, /Du behältst/) + + // die Leisten antworten wieder, die Eingaben sind unberuehrt + assert.equal((await sf.findActiveBySiteKey(SITE_KEY, 'zeiten'))?.$id, 'f_p1_zeiten') + const view = await sf.featureView(doc, 'zeiten') + assert.deepEqual([view.state, view.disabledReason, view.loeschungAm, view.unlockedBy], ['active', null, null, 'kauf']) +}) + +test('Behalten aus disabled ohne frühere Freischaltung: zurück auf setup (kein active) und Neubau', async () => { + seedeAbgeschaltet({ activatedAt: null }) + await mitZeiten() + const sessionId = await starteBehalten() + assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200) + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.disabledReason ?? null, doc.einbau], ['setup', false, 'kauf', null, 'angestossen']) + assert.equal(rebuilds.length, 1) + assert.equal(tickets().length, 0) +}) + +test('Behalten aus disabled, Neubau nicht möglich oder wirft: einbau manuell, gebucht bleibt gebucht', async () => { + for (const [name, vorbereiten] of [ + ['Runner wirft', () => { zeitenDeps.rebuild = async () => { throw new Error('Runner nicht erreichbar') } }], + ['kein Repo', () => aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' })], + ]) { + for (const col of ['portalFeatures', 'oeffnungszeiten', 'featureConsents']) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) + sink.sent.length = 0 + vorbereiten() + seedeAbgeschaltet() + await mitZeiten() + const sessionId = await starteBehalten() + assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200, name) + assert.deepEqual([zeitenDoc().state, zeitenDoc().unlockedBy, zeitenDoc().einbau], ['active', 'kauf', 'manuell'], name) + assert.equal(mailsMit(SUBJECT.K15).length, 1, name) + } +}) + +// -------------------------------------------------------------------------------------------- unpassende Zahlung (T19) + +test('Sitzung passt nicht zu behaltenSessionId ⇒ T19 Erstattung fällig, nichts gebucht, nichts eingerichtet', async () => { + seedeZeiten({ behaltenSessionId: 'cs_andere' }) + const vorher = { ...zeitenDoc() } + const res = await webhook('checkout.session.completed', fremdBezahlt('cs_fremd')) + assert.equal(res.status, 200, JSON.stringify(res.body)) + + // Zustand, Freischaltung, Frist und offene Zahlung bleiben; nur Vermerk und Marker kommen dazu + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.unlockedBy, doc.unlockedByPurchase, zeit(doc.aboEndeAt), doc.behaltenSessionId, doc.purchaseStatus], ['active', 'abo', false, zeit(ABO_ENDE), 'cs_andere', '']) + assert.equal(doc.stripeSessionId ?? '', vorher.stripeSessionId ?? '') + assert.equal(doc.zahlungMeldung, 'gemeldet') + assert.match(doc.adminNote, /cs_fremd/) + + // T19: eine Mail ans Team mit der Sitzung, nach dem Abo-Ende, ohne "Ticket offen", ohne Rechnung des alten Dokuments; keine K15, T16 + assert.equal(mailsMit(SUBJECT.T19).length, 1) + const t19 = mailsMit(SUBJECT.T19)[0] + assert.match(t19.text, /cs_fremd/) + assert.match(t19.text, /Zahlung nach dem Abo-Ende/) + assert.doesNotMatch(t19.text, /Ticket noch offen/) + assert.ok(t19.text.includes('https://invoice.stripe.test/in_cs_fremd'), 'Rechnung dieser Sitzung') + assert.match(t19.text, /30 €/) + assert.deepEqual(sperrenBeimVersand, [{ subject: SUBJECT.T19, admin: true, order: true }], 'T19 erst nach der Sperre') + assert.equal(mailsMit(SUBJECT.K15).length, 0) + assert.equal(mailsMit(SUBJECT.T16).length, 0) + assert.equal(tickets().length, 0) + assert.deepEqual(rebuilds, []) + + // Stripe wiederholt: genau ein T19 + assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_fremd'))).status, 200) + assert.equal((await webhook('checkout.session.async_payment_succeeded', fremdBezahlt('cs_fremd'))).status, 200) + assert.equal(mailsMit(SUBJECT.T19).length, 1) +}) + +test('schon behalten (zweiter Tab zahlt ebenfalls) ⇒ die zweite Zahlung ist ein T19, das Dokument bleibt wie gebucht', async () => { + seedeZeiten() + const erste = await starteBehalten() + assert.equal((await webhook('checkout.session.completed', bezahltAus(erste))).status, 200) + const gebucht = { ...zeitenDoc() } + assert.equal(mailsMit(SUBJECT.K15).length, 1) + + assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_zweite'))).status, 200) + assert.equal(mailsMit(SUBJECT.T19).length, 1) + assert.match(mailsMit(SUBJECT.T19)[0].text, /cs_zweite/) + assert.ok(mailsMit(SUBJECT.T19)[0].text.includes('in_cs_zweite'), 'Rechnung der zweiten, nicht die der ersten Zahlung') + assert.ok(!mailsMit(SUBJECT.T19)[0].text.includes(`in_${erste}`)) + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.unlockedBy, doc.stripeSessionId, doc.invoiceUrl], ['active', 'kauf', erste, gebucht.invoiceUrl]) + assert.equal(mailsMit(SUBJECT.K15).length, 1, 'keine zweite K15') +}) + +test('Daten gelöscht, bevor die Zahlung ankommt ⇒ T19, das Dokument bleibt abgeschaltet, kein Neubau, keine K15', async () => { + seedeAbgeschaltet() + await mitZeiten() + const sessionId = await starteBehalten() + await loescheZeiten('f_p1_zeiten') + assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200) + + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.enabled, doc.disabledReason, doc.unlockedBy, doc.unlockedByPurchase, doc.behaltenSessionId], ['disabled', false, 'abo_ende', 'abo', false, sessionId]) + assert.equal(mailsMit(SUBJECT.T19).length, 1) + assert.match(mailsMit(SUBJECT.T19)[0].text, new RegExp(sessionId)) + assert.equal(mailsMit(SUBJECT.K15).length, 0) + assert.deepEqual(rebuilds, []) + assert.equal(tickets().length, 0) +}) + +test('vom Team abgeschaltet, während die Zahlung lief ⇒ T19 (nicht stillschweigend wieder eingeschaltet)', async () => { + seedeZeiten() + const sessionId = await starteBehalten() + aw.seed('portalFeatures', { ...zeitenDoc(), state: 'disabled', enabled: false, disabledAt: '2026-10-03T06:00:00.000Z', disabledReason: 'team' }) + assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200) + assert.deepEqual([zeitenDoc().state, zeitenDoc().unlockedBy, zeitenDoc().disabledReason], ['disabled', 'abo', 'team']) + assert.equal(mailsMit(SUBJECT.T19).length, 1) + assert.equal(mailsMit(SUBJECT.K15).length, 0) + assert.deepEqual(rebuilds, []) +}) + +test('ein früheres „gemeldet“ verschluckt das T19 einer neuen unpassenden Zahlung nicht', async () => { + seedeZeiten({ behaltenSessionId: 'cs_andere', zahlungMeldung: 'gemeldet' }) + assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_neu'))).status, 200) + assert.equal(mailsMit(SUBJECT.T19).length, 1) + assert.match(mailsMit(SUBJECT.T19)[0].text, /cs_neu/) + assert.equal(zeitenDoc().zahlungMeldung, 'gemeldet', 'danach wieder gemeldet') + // eine weitere, andere Zahlung wird wieder gemeldet; die Wiederholung derselben nicht + assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_noch_eine'))).status, 200) + assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_neu'))).status, 200) + assert.equal(mailsMit(SUBJECT.T19).length, 2) +}) + +test('T19 nicht gesendet ⇒ Webhook 500 und Wiederholung; danach genau ein T19', async () => { + seedeZeiten({ behaltenSessionId: 'cs_andere' }) + const roh = sink.sendMail + sink.sendMail = async () => { + sink.sendMail = roh + throw new Error('SMTP down') + } + const erster = await webhook('checkout.session.completed', fremdBezahlt('cs_fremd')) + assert.equal(erster.status, 500) + assert.equal(mailsMit(SUBJECT.T19).length, 0) + assert.notEqual(zeitenDoc().zahlungMeldung, 'gemeldet') + const zweiter = await webhook('checkout.session.completed', fremdBezahlt('cs_fremd')) + assert.equal(zweiter.status, 200) + assert.equal(mailsMit(SUBJECT.T19).length, 1) + assert.equal(zeitenDoc().zahlungMeldung, 'gemeldet') +}) + +test('kein Dokument zur Zahlung (oder andere Funktion) ⇒ T16 „ungeklärt“ ans Team, nichts angelegt', async () => { + assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_ohne_dok'))).status, 200) + assert.equal(mailsMit(SUBJECT.T16).length, 1) + assert.match(mailsMit(SUBJECT.T16)[0].text, /cs_ohne_dok/) + assert.equal(mailsMit(SUBJECT.T19).length, 0) + assert.equal(aw.docs('portalFeatures').size, 0) + // Metadaten eines anderen Kunden (Dokument gehört nicht dazu) ⇒ dasselbe + seedeZeiten({ behaltenSessionId: 'cs_x' }) + assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_x', { customerId: 'c3' }))).status, 200) + assert.equal(mailsMit(SUBJECT.T16).length, 2) + assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId], ['abo', 'cs_x']) +}) + +test('bucheBehalten liefert den Fall: behalten bzw. unpassend', async () => { + seedeZeiten() + const sessionId = await starteBehalten() + assert.deepEqual(await bucheBehalten(bezahltAus(sessionId)), { fall: 'behalten' }) + assert.deepEqual(await bucheBehalten(bezahltAus(sessionId)), { fall: 'behalten' }, 'wiederholt: weiterhin behalten, ohne zweite Buchung') + assert.deepEqual(await bucheBehalten(fremdBezahlt('cs_zweite')), { fall: 'unpassend' }) + assert.equal(mailsMit(SUBJECT.K15).length, 1) + assert.equal(mailsMit(SUBJECT.T19).length, 1) +}) + +// -------------------------------------------------------------------------------------- Zahlung abgebrochen, abgelaufen + +test('Sitzung abgelaufen oder SEPA fehlgeschlagen ⇒ behaltenSessionId wird frei, Zustand bleibt, man kann neu starten', async () => { + for (const type of ['checkout.session.expired', 'checkout.session.async_payment_failed']) { + for (const id of [...aw.docs('portalFeatures').keys()]) await deleteDocument('portalFeatures', id) + seedeZeiten() + const sessionId = await starteBehalten() + // eine andere Sitzung desselben Projekts raeumt nichts ab + assert.equal((await webhook(type, fremdBezahlt('cs_andere'))).status, 200, type) + assert.equal(zeitenDoc().behaltenSessionId, sessionId, type) + // die eigene schon + const eigene = { ...st.checkoutSessions.get(sessionId), status: type === 'checkout.session.expired' ? 'expired' : 'complete', payment_status: 'unpaid' } + assert.equal((await webhook(type, eigene)).status, 200, type) + assert.deepEqual([zeitenDoc().behaltenSessionId, zeitenDoc().state, zeitenDoc().unlockedBy, zeit(zeitenDoc().aboEndeAt)], ['', 'active', 'abo', zeit(ABO_ENDE)], type) + assert.equal(mailsMit(SUBJECT.T16).length, 0, type) + const neu = await behalten(koerper()) + assert.equal(neu.status, 200, `${type}: ${JSON.stringify(neu.body)}`) + assert.notEqual(zeitenDoc().behaltenSessionId, sessionId, type) + } +}) + +// ------------------------------------------------------------------------------------------------ Kauf unverändert + +test('normaler Kauf-Webhook unverändert: keine Metadaten zweck, bestellt=1, setup/kauf, Neubau, Ticket, K15 „neu“', async () => { + const res = await checkout(koerper()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const aufruf = sessionAufrufe().at(-1).body + assert.equal(Object.keys(aufruf).some((k) => k.includes('zweck')), false, 'weder in metadata noch in invoice_data') + assert.equal(aufruf.success_url, `${config.publicBaseUrl}/dashboard.html#zeiten?bestellt=1&projekt=p1`) + assert.equal(aufruf.cancel_url, `${config.publicBaseUrl}/dashboard.html#zeiten?projekt=p1`) + const sessionId = [...st.checkoutSessions.values()].at(-1).id + assert.equal(zeitenDoc().state, 'ordered') + assert.equal(zeitenDoc().behaltenSessionId ?? '', '') + + const invoice = `in_${sessionId}` + st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` }) + const paid = { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', invoice, metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'zeiten' } } + assert.equal((await webhook('checkout.session.completed', paid)).status, 200) + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus, doc.stripeSessionId, doc.einbau], ['setup', 'kauf', true, 'paid', sessionId, 'angestossen']) + assert.equal(rebuilds.length, 1) + assert.equal(tickets().length, 1) + assert.equal(mailsMit(SUBJECT.K15).length, 1) + assert.match(mailsMit(SUBJECT.K15)[0].text, /Wir richten Öffnungszeiten live jetzt für Dein Projekt ein\./) + assert.doesNotMatch(mailsMit(SUBJECT.K15)[0].text, /Du behältst/) + assert.equal(mailsMit(SUBJECT.T16).length, 1) + assert.equal(mailsMit(SUBJECT.T19).length, 0) +}) + +test('createFeatureCheckout: zweck nur behalten (sonst 400 vor jedem Stripe-Aufruf); News und Termine ohne zweck', async () => { + const { createFeatureCheckout } = await import('../server/services/stripeBilling.js') + const kunde = { $id: 'c1', email: 'k@example.invalid', name: 'Kunde Eins' } + await assert.rejects(createFeatureCheckout(kunde, { projectId: 'p1', key: 'zeiten', zweck: 'was_anderes' }), (err) => err.status === 400) + assert.equal(sessionAufrufe().length, 0) + await createFeatureCheckout(kunde, { projectId: 'p1', key: 'news', consentAt: '2026-10-05T08:00:00.000Z' }) + const news = sessionAufrufe().at(-1).body + assert.equal(Object.keys(news).some((k) => k.includes('zweck')), false) + assert.equal(news.success_url, `${config.publicBaseUrl}/dashboard.html#${config.features.news.returnHash}?bestellt=1`) +})