Files
Webklar-Kundenbereich/server/services/zeiten/behalten.js
T

283 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<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 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:<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, 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:<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
})
}