Öffnungszeiten: nach Abo-Ende für 30 € behalten

Neuer Modus neben Bestellen und Einschalten (Spec 3.3): POST /api/funktionen/zeiten/behalten
startet einen Checkout mit zweck 'behalten', ohne den Zustand zu ändern; der Webhook-Zweig
bucht vor markPaid/adoptPaidSession unter feature-admin zum Kauf (Frist weg, stripeSessionId =
Sitzung), schaltet eine abgeschaltete Funktion wieder ein (Neubau nach der Sperre), ohne neues
Ticket, mit K15 im Behalten-Wortlaut. Passt die Sitzung nicht, geht T19 nach der Sperre raus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 02:54:39 +00:00
1 parent 97e6971276
commit 53253f0451
6 files changed
+1116 -12

No files matched your search

+43
View File
@@ -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) => {
+31 -8
View File
@@ -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
+11 -4
View File
@@ -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
+259
View File
@@ -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:<kunde>:<projekt>` (wie Checkout und Einschalten).
* bucheBehalten Webhook: passt die Sitzung zu behaltenSessionId, wird das Dokument unter `feature-admin:<projekt>` 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:<kunde>:<projekt>`: 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<unknown>, 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:<projekt>`: 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<boolean>} 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
})
}