import { config } from '../../config.js' import { aboLage } from '../aboStatus.js' import { istAboModell } from '../aboModell.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 ABO_LAEUFT = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.' const ABO_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.' 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. * Nie fuer ein Dokument des Abo-Modells (istAboModell). */ async function behaltenMoeglich(doc) { if (!doc || istAboModell(doc)) return false // Abo-Modell (P6): kein Behalten gegen Zahlung, die Funktion endet mit dem Abo und kehrt mit einem neuen zurueck 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 } /** * Läuft das Wartungs-Abo wieder, ist die Funktion darin enthalten und der Abo-Abgleich nimmt das Abo-Ende zurück (zeiten/aboAbgleich.js): * in der Zeit dazwischen soll niemand 30 € für etwas bezahlen, das ohnehin enthalten ist (Ruling R25). Frisch gelesen (aboLage direkt, nie * ein Zwischenspeicher); ein Lesefehler ist 503 (fail-closed: unklar, ob das Abo läuft), im Log stehen nur Art und Status. */ async function pruefeAboLaeuftNicht(customerId) { let lage try { lage = await aboLage(customerId) } catch (err) { console.error(`[zeiten] Behalten: Wartungs-Abo nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) throw Object.assign(new Error(ABO_NICHT_PRUEFBAR), { status: 503 }) } if (lage.laeuft === true) throw konflikt(ABO_LAEUFT) } /** 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, Wartungs-Abo laeuft wieder, Zahlung laeuft), 503 (Abo oder 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) // läuft das Abo wieder (der Abgleich hat das Abo-Ende noch nicht zurückgenommen), gibt es weder eine neue noch die alte Zahlungsseite await pruefeAboLaeuftNicht(customerId) // 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 }) }