gleicheZeitenAboAb(now) koppelt die im Wartungs-Abo enthaltenen Zeiten (unlockedBy abo) an das Abo (Spec 5, Z11): Abo-Ende erkennen (aboEndeAt, K26), nach 30 Berliner Kalendertagen abschalten (abo_ende, K27, Ersatz-Ticket), bei Rückkehr des Abos wieder einschalten (Neubau nach dem Abschalten, K24 wieder), 30 Tage nach dem Abschalten Daten löschen und state off. Lesefehler beim Abo: Kunde überspringen, nie abschalten oder löschen. R25: activate() leert aboEndeAt und behaltenSessionId; starteBehalten lehnt bei laufendem Wartungs-Abo mit 409 ab (Lesefehler 503). R17: k24Mail mit einbau null und wieder schaltet beide Einbau-Blöcke ab, K24 bekommt dafür den Block weiter um den Kasten "Wie geht es weiter?". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
16 KiB
JavaScript
281 lines
16 KiB
JavaScript
import { config } from '../../config.js'
|
||
import { aboLage } from '../aboStatus.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.
|
||
*/
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
})
|
||
}
|