import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js' import { listContractEvents } from './contractEvents.js' // Abo-Baustein fuer Funktionen, die ein laufendes Wartungs-Abo voraussetzen (Terminplaner, Abo-Abgleich) und fuer die // Analyse. Fail-closed: ein Lesefehler wirft, er wird nie zu false bzw. ok:false. Wer die Fehler abfaengt, entscheidet // selbst, was dann gilt (Bestellung ablehnen, Kunde im Listenlauf ueberspringen, nie abschalten). // // Alle Fragen (laeuft, bestellbar, aboLage und die Memo-Varianten) bauen auf EINEM privaten Lader auf, lage(): so // urteilen Bestell-Gate, Freischaltung, Hinweis "kein Abo" und Abgleich nie verschieden. // // Zwei Regeln fuer "Wartungs-Abo": // altbestand: true (Vorgabe, Terminplaner) grosszuegig: Produktschluessel `productKey || 'wartung'` wie routes/abo.js // productKeyOf (Altbestand ohne productKey gilt als Wartung) UND die Wechsel-Regel unten. // altbestand: false (Analyse) die Regel von analyse.js, unveraendert: `productKey === 'wartung'` (ein Spiegel ohne // productKey zaehlt nicht), keine Ereignisabfrage. // stripeBilling.js schreibt productKey immer; nur Altbestand koennte es nicht haben. // // Wechsel-Regel: Sagt der Spiegel "Wartung laeuft" und hat das juengste wechsel-Ereignis dieses Abos den Status // widerrufen, laeuft das Wartungs-Abo nicht (art 'wechsel_widerrufen'). Der Widerruf eines Wechsels (Hosting -> // Wartung) setzt den Spiegel nicht zurueck, das tut das Team von Hand (Kommentar zu withdrawOrder in routes/abo.js). // Ohne die Regel schaltete der Abgleich einen per Sofortpfad beendeten Terminplaner am naechsten Tag wieder ein, und ein // Kunde mit widerrufenem Wechsel duerfte bestellen und freischalten, der Abgleich schaltete am Folgetag wieder ab. const laeuftImSpiegel = (abo, altbestand) => Boolean(abo) && (altbestand ? abo.productKey || 'wartung' : abo.productKey) === 'wartung' && ACTIVE_STATUSES.has(abo.status) // Das juengste wechsel-Ereignis dieses Abos (listContractEvents liefert die neuesten zuerst) ist widerrufen. async function wechselWiderrufen(customerId, abo) { const juengstes = (await listContractEvents(customerId)).find((e) => e.type === 'wechsel' && e.subscriptionId === abo.$id) return juengstes?.status === 'widerrufen' } const endeVon = (abo) => abo?.endedAt || abo?.canceledAt || null /** * Der gelesene Stand: { laeuft, art, endedAt, abo }. art 'laeuft' | 'wechsel_widerrufen' | 'hosting' | 'beendet'. * Ereignisse werden nur gelesen, wenn der Spiegel "Wartung laeuft" sagt und nur bei altbestand: true. */ async function lage(customerId, { altbestand }) { const abo = await getCurrentSubscription(customerId) if (laeuftImSpiegel(abo, altbestand)) { if (altbestand && (await wechselWiderrufen(customerId, abo))) { return { laeuft: false, art: 'wechsel_widerrufen', endedAt: endeVon(abo), abo } } return { laeuft: true, art: 'laeuft', endedAt: null, abo } } const art = abo?.productKey === 'hosting' && ACTIVE_STATUSES.has(abo.status) ? 'hosting' : 'beendet' return { laeuft: false, art, endedAt: endeVon(abo), abo } } // Die Gruende gelten in dieser Reihenfolge fuer alle Bloecke: kein laufendes Wartungs-Abo (inkl. Wechsel-Regel) => // kein_abo; Kuendigung vorgemerkt (cancelAtPeriodEnd oder cancelAt, auch bei trialing und past_due) => gekuendigt; // Status nicht active (trialing, past_due) => nicht_aktiv. function bestellbarAus({ laeuft: laeuftJetzt, abo }) { if (!laeuftJetzt) return { ok: false, grund: 'kein_abo' } if (abo.cancelAtPeriodEnd || abo.cancelAt) return { ok: false, grund: 'gekuendigt' } if (abo.status !== 'active') return { ok: false, grund: 'nicht_aktiv' } return { ok: true } } // Nach aussen ohne das gelesene Abo-Dokument, bei jedem Aufruf ein neues Objekt. const lageAus = ({ laeuft: laeuftJetzt, art, endedAt }) => ({ laeuft: laeuftJetzt, art, endedAt }) /** * Hat der Kunde ein laufendes Wartungs-Abo (vorgemerkte Kuendigung zaehlt noch als laufend)? Wirft bei jedem Lesefehler. * altbestand (Vorgabe true): grosszuegige Regel mit Wechsel-Regel; false: die Regel der Analyse (siehe Kopf). */ export async function laeuft(customerId, { altbestand = true } = {}) { return (await lage(customerId, { altbestand })).laeuft } /** Darf der Kunde jetzt bestellen? { ok: true } oder { ok: false, grund: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }. Wirft bei Lesefehler. */ export async function bestellbar(customerId) { return bestellbarAus(await lage(customerId, { altbestand: true })) } /** * Lage des Abos: { laeuft, art: 'laeuft' | 'wechsel_widerrufen' | 'hosting' | 'beendet', endedAt: string | null }. * endedAt (endedAt || canceledAt des Abos, sonst null) nur, wenn das Wartungs-Abo nicht laeuft. Grosszuegige Regel * inkl. Wechsel-Regel. Wirft bei jedem Lesefehler. */ export async function aboLage(customerId) { return lageAus(await lage(customerId, { altbestand: true })) } /** * Zwischenspeicher fuer einen Lauf (eine Anfrage, ein Listenlauf): { laeuft, bestellbar, lage } mit denselben Antworten * wie oben (grosszuegige Regel), aber der Stand jedes Kunden wird je aboMemo()-Aufruf nur einmal gelesen, auch bei * gleichzeitigen Fragen und fuer alle drei Fragen gemeinsam. Eine Ablehnung bleibt fuer diesen Lauf stehen: alle * weiteren Fragen zu diesem Kunden werfen denselben Fehler, ohne neu zu lesen. Nie fuer Pruefungen unter einer Sperre * (dort frisch lesen: aboLage). */ export function aboMemo() { const gelesen = new Map() const lade = (customerId) => { if (!gelesen.has(customerId)) gelesen.set(customerId, lage(customerId, { altbestand: true })) return gelesen.get(customerId) } return { laeuft: async (customerId) => (await lade(customerId)).laeuft, bestellbar: async (customerId) => bestellbarAus(await lade(customerId)), lage: async (customerId) => lageAus(await lade(customerId)), } }