diff --git a/server/services/aboStatus.js b/server/services/aboStatus.js index 189d71b..162eb58 100644 --- a/server/services/aboStatus.js +++ b/server/services/aboStatus.js @@ -1,57 +1,104 @@ -// KERNVERSION (Block B) – Block E baut aus import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js' +import { listContractEvents } from './contractEvents.js' -// Abo-Baustein fuer Funktionen, die ein laufendes Wartungs-Abo voraussetzen (Terminplaner; die Analyse zieht in -// Block E nach). 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). +// 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. -// Wartungs-Abo: Altbestand ohne productKey gilt als Wartung (wie routes/abo.js productKeyOf), ein anderer -// Schluessel (hosting, mailStorage, unbekannt) nicht. Status wie im Portal: active, trialing, past_due. -function laeuftAus(sub) { - return Boolean(sub) && (sub.productKey || 'wartung') === 'wartung' && ACTIVE_STATUSES.has(sub.status) +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' } -// Die Gruende gelten in dieser Reihenfolge fuer alle Bloecke: kein laufendes Wartungs-Abo => kein_abo; Kuendigung -// vorgemerkt (cancelAtPeriodEnd oder cancelAt, auch bei trialing und past_due) => gekuendigt; Status nicht active -// (trialing, past_due) => nicht_aktiv. -function bestellbarAus(sub) { - if (!laeuftAus(sub)) return { ok: false, grund: 'kein_abo' } - if (sub.cancelAtPeriodEnd || sub.cancelAt) return { ok: false, grund: 'gekuendigt' } - if (sub.status !== 'active') return { ok: false, grund: 'nicht_aktiv' } +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 } } -/** Hat der Kunde ein laufendes Wartungs-Abo? Wirft bei Lesefehler. */ -export async function laeuft(customerId) { - return laeuftAus(await getCurrentSubscription(customerId)) +// 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 getCurrentSubscription(customerId)) + return bestellbarAus(await lage(customerId, { altbestand: true })) } /** - * Zwischenspeicher fuer einen Lauf (eine Anfrage, ein Listenlauf): { laeuft, bestellbar } mit denselben Antworten - * wie oben, aber das Abo jedes Kunden wird je aboMemo()-Aufruf nur einmal gelesen (auch bei gleichzeitigen Fragen, - * und fuer laeuft und bestellbar gemeinsam). Ein Lesefehler wird nicht gemerkt: der naechste Aufruf liest neu. + * 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, - getCurrentSubscription(customerId).catch((err) => { - gelesen.delete(customerId) - throw err - }) - ) - } + if (!gelesen.has(customerId)) gelesen.set(customerId, lage(customerId, { altbestand: true })) return gelesen.get(customerId) } return { - laeuft: async (customerId) => laeuftAus(await lade(customerId)), + laeuft: async (customerId) => (await lade(customerId)).laeuft, bestellbar: async (customerId) => bestellbarAus(await lade(customerId)), + lage: async (customerId) => lageAus(await lade(customerId)), } } diff --git a/server/services/analyse.js b/server/services/analyse.js index 75fa5d6..63969d9 100644 --- a/server/services/analyse.js +++ b/server/services/analyse.js @@ -8,7 +8,7 @@ import { withLocks } from './keyedLock.js' import { sendCustomerMail } from './mailer.js' import { triggerRebuild } from './previewRebuild.js' import { ensureFeatureDoc, getFeatureDoc, newSiteKey, stateFields, assertOwnProject } from './siteFeatures.js' -import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js' +import { laeuft } from './aboStatus.js' import { createFeatureTicket } from './ticketAdmin.js' import { berlinDateTime } from './berlinTime.js' import { legalPaths, legalPdfAttachments } from './analyseLegal.js' @@ -39,11 +39,8 @@ export const ELIGIBILITY_MESSAGES = { } const ELIGIBILITY_STATUS = { nicht_dein_projekt: 403, kein_abo: 403, nicht_oeffentlich: 409, keine_subdomain: 409 } -/** Nur Wartungs-Abo (kein Hosting) mit laufendem Status. */ -async function hasWartungsAbo(customerId) { - const sub = await getCurrentSubscription(customerId) - return Boolean(sub && sub.productKey === 'wartung' && ACTIVE_STATUSES.has(sub.status)) -} +/** Nur Wartungs-Abo (kein Hosting) mit laufendem Status; strenge Regel des Abo-Bausteins: ein Spiegel ohne productKey zaehlt nicht. */ +const hasWartungsAbo = (customerId) => laeuft(customerId, { altbestand: false }) async function loadOwnProject(customerId, projectId) { try { diff --git a/test/aboStatus.test.js b/test/aboStatus.test.js index 70d157b..b1e2887 100644 --- a/test/aboStatus.test.js +++ b/test/aboStatus.test.js @@ -5,16 +5,16 @@ import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' -// Abo-Baustein (server/services/aboStatus.js, Kernversion aus Block B): laeuft / bestellbar / aboMemo. +// Abo-Baustein (server/services/aboStatus.js, B5 Kernversion, E1 ausgebaut): laeuft / bestellbar / aboLage / aboMemo. // Fail-closed: ein Lesefehler wirft, er wird nie zu false bzw. ok:false. Die Umgebung steht vor dem ersten // Import von server/config.js. -const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } }) +const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents') } }) const st = await startFakeStripe() after(() => Promise.all([aw.close(), st.close()])) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) -const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { seedFixtures, subscriptionDoc, wartungDoc, hosting2Doc } = await import('./support/fixtures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') -const { laeuft, bestellbar, aboMemo } = await import('../server/services/aboStatus.js') +const { laeuft, bestellbar, aboLage, aboMemo } = await import('../server/services/aboStatus.js') const DAY_MS = 86400000 const inTagen = (tage) => new Date(Date.now() + tage * DAY_MS).toISOString() @@ -22,9 +22,46 @@ const inTagen = (tage) => new Date(Date.now() + tage * DAY_MS).toISOString() // seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): fuer Wartungsfaelle ueberschreibt der Test es mit einem Wartungs-Abo. const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) -// Jeder Test beginnt mit den Standard-Abos (c1 Hosting, c2 Hosting, c3 keins); geloescht wird ueber die API +let ereignisNr = 0 +// Vertragsereignis des Abos sub_h von c1; $createdAt steigt mit jedem Aufruf, damit "das juengste" eindeutig ist +const ereignis = (type, status, patch = {}) => { + ereignisNr += 1 + aw.seed('contractEvents', { + $id: `ev_${ereignisNr}`, + $createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, ereignisNr)).toISOString(), + customerId: 'c1', + subscriptionId: 'sub_h', + type, + productKey: 'wartung', + details: '{}', + status, + ...patch, + }) +} +const wechsel = (status, patch) => ereignis('wechsel', status, patch) + +/** Anzahl der Anfragen an den Appwrite-Nachbau, die fn ausloest (optional nur die, deren URL zu muster passt). */ +async function zaehleAnfragen(fn, muster = null) { + const echt = globalThis.fetch + let n = 0 + globalThis.fetch = (url, optionen) => { + if (!muster || muster.test(String(url))) n += 1 + return echt(url, optionen) + } + try { + await fn() + } finally { + globalThis.fetch = echt + } + return n +} +const ereignisAnfragen = (fn) => zaehleAnfragen(fn, /contractEvents/) + +// Jeder Test beginnt mit den Standard-Abos (c1 Hosting, c2 Hosting, c3 keins) und ohne Ereignisse; geloescht wird ueber die API beforeEach(async () => { - for (const id of aw.docs('subscriptions').keys()) await deleteDocument('subscriptions', id) + for (const collection of ['subscriptions', 'contractEvents']) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } seedFixtures(aw) }) @@ -135,7 +172,7 @@ test('aboMemo: ein anderer Kunde wird neu gelesen, ein anderer aboMemo() ebenfal // anderer Kunde im selben Memo: eigene Lesung (trifft den eingeplanten Fehler) aw.failNext({ collection: 'subscriptions', status: 500 }) await assert.rejects(() => memo.laeuft('c3'), /eingeplanter Fehler 500/) - assert.equal(await memo.laeuft('c3'), false, 'c3 hat kein Abo') + assert.equal(await aboMemo().laeuft('c3'), false, 'c3 hat kein Abo (die Ablehnung bleibt in diesem Memo stehen, E1)') // anderer aboMemo() fuer denselben Kunden: neu lesen, der alte Speicher gilt nicht aw.failNext({ collection: 'subscriptions', status: 500 }) @@ -143,17 +180,6 @@ test('aboMemo: ein anderer Kunde wird neu gelesen, ein anderer aboMemo() ebenfal assert.equal(await memo.laeuft('c1'), true, 'der erste Memo behaelt seinen Eintrag') }) -test('aboMemo: ein Lesefehler bleibt nicht im Speicher haengen, der naechste Aufruf liest neu', async () => { - wartung({ status: 'active' }) - const memo = aboMemo() - aw.failNext({ collection: 'subscriptions', status: 500 }) - await assert.rejects(() => memo.laeuft('c1'), /eingeplanter Fehler 500/) - aw.failNext({ collection: 'subscriptions', status: 500 }) - await assert.rejects(() => memo.bestellbar('c1'), /eingeplanter Fehler 500/, 'wirft auch bestellbar, nie ok:false') - assert.equal(await memo.laeuft('c1'), true) - assert.deepEqual(await memo.bestellbar('c1'), { ok: true }) -}) - test('aboMemo: gleichzeitige Aufrufe desselben Kunden lesen nur einmal', async () => { wartung({ status: 'active' }) aw.resetListCalls() @@ -162,3 +188,258 @@ test('aboMemo: gleichzeitige Aufrufe desselben Kunden lesen nur einmal', async ( assert.deepEqual(antworten, [true, { ok: true }, true]) assert.equal(aw.listCalls('subscriptions').length, 1) }) + +// ------------------------------------------------------------------------------------------ E1: ausgebaut --- +test('laeuft: Status-Tabelle (active, trialing, past_due laufen; canceled, unpaid, incomplete_expired nicht)', async () => { + for (const status of ['active', 'trialing', 'past_due']) { + wartung({ status }) + assert.equal(await laeuft('c1'), true, status) + } + for (const status of ['canceled', 'unpaid', 'incomplete_expired']) { + wartung({ status }) + assert.equal(await laeuft('c1'), false, status) + } +}) + +test('laeuft: Hosting aktiv und kein Dokument => false; vorgemerkte Kuendigung zaehlt noch als laufend', async () => { + assert.equal(await laeuft('c1'), false, 'Hosting aktiv (Standard von c1)') + assert.equal(await laeuft('c3'), false, 'kein Dokument') + wartung({ status: 'active', cancelAtPeriodEnd: true, cancelAt: inTagen(12) }) + assert.equal(await laeuft('c1'), true) +}) + +test('laeuft: zwei Dokumente (altes canceled, neues active) => true, in beiden Anlegereihenfolgen', async () => { + aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...wartungDoc, status: 'canceled', $createdAt: inTagen(-100) })) + wartung({ status: 'active', $createdAt: inTagen(-10) }) + assert.equal(await laeuft('c1'), true, 'das aktive ist das neuere') + + aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...wartungDoc, status: 'canceled', $createdAt: inTagen(-1) })) + assert.equal(await laeuft('c1'), true, 'das beendete ist das neuere, das laufende gilt trotzdem') +}) + +test('Altbestand-Unterschied: Dokument ohne productKey => laeuft(id) true, laeuft(id, { altbestand: false }) false', async () => { + const { productKey: _entfernt, ...ohneSchluessel } = wartungDoc + aw.seed('subscriptions', subscriptionDoc('sub_h', ohneSchluessel)) + assert.equal(await laeuft('c1'), true) + assert.equal(await laeuft('c1', { altbestand: true }), true) + assert.equal(await laeuft('c1', { altbestand: false }), false) + + wartung({ productKey: null }) + assert.equal(await laeuft('c1'), true, 'productKey null') + assert.equal(await laeuft('c1', { altbestand: false }), false, 'productKey null, strenge Regel') + + // mit Schluessel 'wartung' urteilen beide Regeln gleich + wartung({ status: 'active' }) + assert.equal(await laeuft('c1'), true) + assert.equal(await laeuft('c1', { altbestand: false }), true) + wartung({ status: 'canceled' }) + assert.equal(await laeuft('c1', { altbestand: false }), false) +}) + +test('Lesefehler: laeuft, bestellbar und aboLage lehnen ab (GET 500 auf subscriptions), nie false oder { ok: false }', async () => { + wartung({ status: 'active' }) + for (const frage of [() => laeuft('c1'), () => laeuft('c1', { altbestand: false }), () => bestellbar('c1'), () => aboLage('c1')]) { + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) + await assert.rejects(frage, /eingeplanter Fehler 500/) + } + // der Fehler war einmalig + assert.equal(await laeuft('c1'), true) +}) + +test('Lesefehler bei den Ereignissen: nur die grosszuegige Regel liest sie und lehnt dann ab, die strenge Regel nicht', async () => { + wartung({ status: 'active' }) + aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) + assert.equal(await laeuft('c1', { altbestand: false }), true, 'strenge Regel fragt keine Ereignisse') + await assert.rejects(() => laeuft('c1'), /eingeplanter Fehler 500/, 'laeuft liest sie und scheitert') + aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) + await assert.rejects(() => bestellbar('c1'), /eingeplanter Fehler 500/) + aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) + await assert.rejects(() => aboLage('c1'), /eingeplanter Fehler 500/) + assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null }) +}) + +test('bestellbar: Tabelle der Gruende', async () => { + const faelle = [ + ['active ohne Kuendigung', { status: 'active' }, { ok: true }], + ['active mit cancelAtPeriodEnd', { status: 'active', cancelAtPeriodEnd: true }, { ok: false, grund: 'gekuendigt' }], + ['active mit cancelAt in der Zukunft', { status: 'active', cancelAt: inTagen(30) }, { ok: false, grund: 'gekuendigt' }], + ['trialing', { status: 'trialing' }, { ok: false, grund: 'nicht_aktiv' }], + ['past_due', { status: 'past_due' }, { ok: false, grund: 'nicht_aktiv' }], + ['trialing mit Kuendigung', { status: 'trialing', cancelAtPeriodEnd: true }, { ok: false, grund: 'gekuendigt' }], + ['past_due mit Kuendigung', { status: 'past_due', cancelAt: inTagen(5) }, { ok: false, grund: 'gekuendigt' }], + ['canceled', { status: 'canceled' }, { ok: false, grund: 'kein_abo' }], + ] + for (const [name, patch, erwartet] of faelle) { + wartung(patch) + assert.deepEqual(await bestellbar('c1'), erwartet, name) + } + wartung({ productKey: 'hosting' }) + assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'kein_abo' }, 'Hosting') + assert.deepEqual(await bestellbar('c3'), { ok: false, grund: 'kein_abo' }, 'kein Abo') +}) + +test('aboLage: art laeuft (endedAt null, auch bei vorgemerkter Kuendigung mit canceledAt)', async () => { + wartung({ status: 'active' }) + assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null }) + wartung({ status: 'past_due', cancelAtPeriodEnd: true, canceledAt: inTagen(-2) }) + assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null }) +}) + +test('aboLage: art hosting bei laufendem Hosting-Abo, ohne Ereignis-Abfrage', async () => { + // Standard von c1 und c2: Hosting aktiv + const n = await ereignisAnfragen(async () => { + assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'hosting', endedAt: null }) + assert.deepEqual(await aboLage('c2'), { laeuft: false, art: 'hosting', endedAt: null }) + }) + assert.equal(n, 0, 'keine Ereignisse gelesen') + assert.equal(await ereignisAnfragen(() => laeuft('c1')), 0) + assert.equal(await ereignisAnfragen(() => bestellbar('c1')), 0) +}) + +test('aboLage: art beendet mit endedAt (oder canceledAt, oder null), ohne Ereignis-Abfrage', async () => { + const ende = inTagen(-3) + const gekuendigtAm = inTagen(-20) + const n = await ereignisAnfragen(async () => { + wartung({ status: 'canceled', endedAt: ende, canceledAt: gekuendigtAm }) + assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'beendet', endedAt: ende }, 'endedAt vor canceledAt') + wartung({ status: 'canceled', endedAt: null, canceledAt: gekuendigtAm }) + assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'beendet', endedAt: gekuendigtAm }, 'nur canceledAt') + wartung({ status: 'unpaid', endedAt: null, canceledAt: null }) + assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'beendet', endedAt: null }, 'beides leer') + assert.deepEqual(await aboLage('c3'), { laeuft: false, art: 'beendet', endedAt: null }, 'kein Abo') + wartung({ productKey: 'hosting', status: 'canceled', endedAt: ende }) + assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'beendet', endedAt: ende }, 'beendetes Hosting-Abo') + }) + assert.equal(n, 0, 'keine Ereignisse gelesen') +}) + +test('aboLage: hat ein Kunde Hosting aktiv und Wartung beendet, gilt das laufende Hosting-Abo', async () => { + aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...wartungDoc, status: 'canceled', endedAt: inTagen(-30) })) + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hosting2Doc, customerId: 'c1' })) + assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'hosting', endedAt: null }) +}) + +test('aboLage: Wechsel-Widerruf => laeuft false, art wechsel_widerrufen; ein neueres erledigtes wechsel-Ereignis hebt es auf', async () => { + wartung({ status: 'active' }) + wechsel('widerrufen') + assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'wechsel_widerrufen', endedAt: null }) + + wechsel('erledigt') + assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null }, 'das juengste wechsel-Ereignis zaehlt') +}) + +test('aboLage: nur das juengste wechsel-Ereignis DIESES Abos zaehlt (andere Typen, andere Abos, offene Wechsel)', async () => { + wartung({ status: 'active' }) + wechsel('erledigt') + wechsel('widerrufen') + assert.equal((await aboLage('c1')).art, 'wechsel_widerrufen', 'juengstes widerrufen, aelteres erledigt') + + ereignis('abschluss', 'widerrufen') + ereignis('speicher', 'widerrufen') + wechsel('widerrufen', { subscriptionId: 'sub_anderes' }) + wechsel('erledigt', { subscriptionId: 'sub_anderes' }) + assert.equal((await aboLage('c1')).art, 'wechsel_widerrufen', 'juengere Ereignisse anderer Typen und anderer Abos aendern nichts') + + wechsel('offen') + assert.deepEqual(await aboLage('c1'), { laeuft: true, art: 'laeuft', endedAt: null }, 'ein offener Wechsel ist kein Widerruf') +}) + +test('aboLage: Ereignisse anderer Kunden zaehlen nicht', async () => { + wartung({ status: 'active' }) + wechsel('widerrufen', { customerId: 'c2' }) + assert.equal((await aboLage('c1')).art, 'laeuft') +}) + +test('Wechsel-Regel in allen Praedikaten: laeuft false, bestellbar kein_abo, aboLage wechsel_widerrufen; die strenge Regel bleibt true und fragt keine Ereignisse', async () => { + wartung({ status: 'active' }) + wechsel('widerrufen') + assert.equal(await laeuft('c1'), false) + assert.equal(await laeuft('c1', { altbestand: true }), false) + assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'kein_abo' }) + assert.deepEqual(await aboLage('c1'), { laeuft: false, art: 'wechsel_widerrufen', endedAt: null }) + const memo = aboMemo() + assert.equal(await memo.laeuft('c1'), false) + assert.deepEqual(await memo.bestellbar('c1'), { ok: false, grund: 'kein_abo' }) + assert.deepEqual(await memo.lage('c1'), { laeuft: false, art: 'wechsel_widerrufen', endedAt: null }) + + let strenge + const n = await ereignisAnfragen(async () => { + strenge = await laeuft('c1', { altbestand: false }) + }) + assert.equal(strenge, true, 'Analyse bleibt bei ihrer heutigen Regel') + assert.equal(n, 0, 'strenge Regel fragt keine Ereignisse') +}) + +test('Wechsel-Regel: Kuendigung und Status spielen keine Rolle, solange der Spiegel Wartung sagt', async () => { + for (const status of ['active', 'trialing', 'past_due']) { + wartung({ status, cancelAtPeriodEnd: true }) + wechsel('widerrufen') + assert.equal(await laeuft('c1'), false, status) + assert.deepEqual(await bestellbar('c1'), { ok: false, grund: 'kein_abo' }, status) + } +}) + +test('Ereignisse werden nur gelesen, wenn der Spiegel "Wartung laeuft" sagt - und dann einmal je Frage', async () => { + wartung({ status: 'active' }) + assert.equal(await ereignisAnfragen(() => laeuft('c1')), 1) + assert.equal(await ereignisAnfragen(() => bestellbar('c1')), 1) + assert.equal(await ereignisAnfragen(() => aboLage('c1')), 1) + assert.equal(await ereignisAnfragen(() => laeuft('c1', { altbestand: false })), 0) +}) + +test('aboMemo: drei Aufrufe fuer denselben Kunden => eine Abfrage auf subscriptions (und eine auf contractEvents)', async () => { + wartung({ status: 'active' }) + aw.resetListCalls() + const memo = aboMemo() + assert.equal(await memo.laeuft('c1'), true) + assert.deepEqual(await memo.bestellbar('c1'), { ok: true }) + assert.deepEqual(await memo.lage('c1'), { laeuft: true, art: 'laeuft', endedAt: null }) + assert.equal(aw.listCalls('subscriptions').length, 1) + assert.equal(aw.listCalls('contractEvents').length, 1) + + // zweiter Kunde: eine weitere Abfrage + assert.equal(await memo.laeuft('c3'), false) + assert.deepEqual(await memo.lage('c3'), { laeuft: false, art: 'beendet', endedAt: null }) + assert.equal(aw.listCalls('subscriptions').length, 2) + + // ein neuer aboMemo() liest wieder + assert.equal(await aboMemo().laeuft('c1'), true) + assert.equal(aw.listCalls('subscriptions').length, 3) +}) + +test('aboMemo: lage liefert je Aufruf ein neues Objekt (Aendern wirkt nicht auf den Speicher)', async () => { + wartung({ status: 'active' }) + const memo = aboMemo() + const erste = await memo.lage('c1') + erste.laeuft = false + erste.art = 'beendet' + assert.deepEqual(await memo.lage('c1'), { laeuft: true, art: 'laeuft', endedAt: null }) + assert.equal(await memo.laeuft('c1'), true) +}) + +test('aboMemo: eine Ablehnung bleibt fuer diesen Lauf stehen (alle drei Fragen, keine zusaetzliche Anfrage); ein neuer Memo liest wieder', async () => { + wartung({ status: 'active' }) + const memo = aboMemo() + aw.resetListCalls() + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) + await assert.rejects(() => memo.laeuft('c1'), /eingeplanter Fehler 500/) + await assert.rejects(() => memo.bestellbar('c1'), /eingeplanter Fehler 500/, 'bestellbar wirft, nie ok:false') + await assert.rejects(() => memo.lage('c1'), /eingeplanter Fehler 500/) + await assert.rejects(() => memo.laeuft('c1'), /eingeplanter Fehler 500/) + assert.equal(aw.listCalls('subscriptions').length, 1, 'nach der Ablehnung wird im selben Memo nicht neu gelesen') + + // andere Kunden im selben Memo sind nicht betroffen, ein neuer Memo liest den Kunden neu + assert.equal(await memo.laeuft('c3'), false) + assert.equal(await aboMemo().laeuft('c1'), true) +}) + +test('aboMemo: auch ein Lesefehler bei den Ereignissen bleibt im Memo stehen', async () => { + wartung({ status: 'active' }) + const memo = aboMemo() + aw.resetListCalls() + aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) + await assert.rejects(() => memo.lage('c1'), /eingeplanter Fehler 500/) + await assert.rejects(() => memo.bestellbar('c1'), /eingeplanter Fehler 500/) + assert.equal(aw.listCalls('subscriptions').length, 1) + assert.equal(aw.listCalls('contractEvents').length, 1) +}) diff --git a/test/analyse.test.js b/test/analyse.test.js index 521636f..f6d4578 100644 --- a/test/analyse.test.js +++ b/test/analyse.test.js @@ -784,3 +784,17 @@ test('Abgleich: stopSite wirft => Dokument bleibt unverändert aktiv', async () assert.ok(!after.offSince) assert.equal(removalTopics('F5').length, 0) }) + +test('Altbestand ohne productKey: Analyse bleibt streng (kein_abo), aboStatus.laeuft grosszuegig', async () => { + const { laeuft } = await import('../server/services/aboStatus.js') + const { productKey: _entfernt, ...ohneSchluessel } = wartungDoc + aw.seed('subscriptions', { ...ohneSchluessel, $id: 'sub_w_alt', stripeSubscriptionId: 'sub_w_alt', customerId: 'c9' }) + aw.seed('customers', { $id: 'c9', name: 'Kunde Neun', email: 'neun@example.invalid' }) + aw.seed('websiteProjects', { $id: 'p9', customerId: 'c9', projectName: 'Altbestand', subdomain: 'altbestand', isPublic: true }) + + assert.equal(await laeuft('c9'), true, 'grosszuegige Regel: ohne productKey gilt als Wartung') + assert.equal(await laeuft('c9', { altbestand: false }), false, 'strenge Regel der Analyse') + assert.deepEqual(await an.analyseEligibility('c9', 'p9'), { ok: false, reason: 'kein_abo' }) + await assert.rejects(an.activateAnalyse({ customerId: 'c9', projectId: 'p9', ...cust() }), { status: 403 }) + assert.equal(puts.length, 0) +}) diff --git a/test/featureFreischaltung.test.js b/test/featureFreischaltung.test.js index b5d5eb5..cb62441 100644 --- a/test/featureFreischaltung.test.js +++ b/test/featureFreischaltung.test.js @@ -13,6 +13,7 @@ const aw = await startAppwriteMock({ portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), termineConfig: schemaKeys('termineConfig'), + contractEvents: schemaKeys('contractEvents'), }, }) after(() => aw.close()) @@ -84,7 +85,7 @@ const STUMM = (t) => { } beforeEach(async () => { - for (const col of ['portalFeatures', 'workorders', 'termineConfig', 'subscriptions', 'websiteProjects']) { + for (const col of ['portalFeatures', 'workorders', 'termineConfig', 'subscriptions', 'websiteProjects', 'contractEvents']) { for (const id of aw.docs(col).keys()) await deleteDocument(col, id) } seedFixtures(aw) @@ -213,6 +214,33 @@ test('Termine: Abo beendet (canceled) => bleibt setup', async (t) => { assert.equal(mails.sent.length, 0) }) +test('Termine: Wechsel-Widerruf (Spiegel Wartung active, juengstes wechsel-Ereignis widerrufen) => bleibt setup, keine Mail; ein neueres erledigtes Ereignis gibt frei', async (t) => { + STUMM(t) + const wechsel = (nr, status) => + aw.seed('contractEvents', { + $id: `ev_w${nr}`, + $createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, nr)).toISOString(), + customerId: 'c1', + subscriptionId: 'sub_h', + type: 'wechsel', + productKey: 'wartung', + details: '{}', + status, + }) + const id = einrichtung('termine', { einstellung: true }) + wechsel(1, 'widerrufen') + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(zustand(id), 'setup') + assert.equal(feld(id, 'enabled'), false) + assert.equal(wartetEintraege(id).length, 1) + assert.equal(mails.sent.length, 0) + + wechsel(2, 'erledigt') + assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [id]) + assert.equal(zustand(id), 'active') + assert.equal(k16('Dein Terminplaner ist bereit').length, 1) +}) + test('Termine: keine Einstellung => bleibt setup (mit Notiz), mit gespeicherter Einstellung aktiviert der naechste Lauf', async (t) => { STUMM(t) const id = einrichtung('termine') diff --git a/test/funktionenCheckout.test.js b/test/funktionenCheckout.test.js index 56c74c7..3bf1125 100644 --- a/test/funktionenCheckout.test.js +++ b/test/funktionenCheckout.test.js @@ -18,6 +18,7 @@ const aw = await startAppwriteMock({ subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents'), termineConfig: schemaKeys('termineConfig'), + contractEvents: schemaKeys('contractEvents'), }, }) const st = await startFakeStripe() @@ -44,7 +45,7 @@ const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h' // Jeder Test beginnt sauber: Standard-Abos (c1 Wartung), Pilot fuer alle, keine Haken-Ueberschreibungen, keine Dokumente beforeEach(async () => { - for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'termineConfig']) { + for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'termineConfig', 'contractEvents']) { for (const id of aw.docs(col).keys()) await deleteDocument(col, id) } seedFixtures(aw) @@ -404,6 +405,29 @@ test('Abo-Gruende: kein Abo (c3, c2 mit Hosting), Kuendigung, offene Zahlung', a assert.notEqual(ok.body?.grund, 'nicht_aktiv') }) +test('Wechsel-Regel (E1): Spiegel Wartung active, aber juengstes wechsel-Ereignis widerrufen => 409 kein_abo ohne Spur; ein neueres erledigtes hebt es auf', async () => { + T.bestellpruefung = async () => [] + const wechsel = (nr, status) => + aw.seed('contractEvents', { + $id: `ev_w${nr}`, + $createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, nr)).toISOString(), + customerId: 'c1', + subscriptionId: 'sub_h', + type: 'wechsel', + productKey: 'wartung', + details: '{}', + status, + }) + wechsel(1, 'widerrufen') + aboAbgelehnt(await checkout('termine', bodyT()), 'kein_abo') + assert.equal(aw.docs('portalFeatures').size, 0, 'kein Dokument entstanden') + + wechsel(2, 'erledigt') + const ok = await checkout('termine', bodyT()) + assert.notEqual(ok.body?.code, 'kein_abo') + assert.notEqual(ok.body?.grund, 'nicht_aktiv') +}) + test('Abo-Lesefehler: 500 ohne Rohtext, fail-closed ohne Spur', async () => { T.bestellpruefung = async () => [] aw.failNext({ collection: 'subscriptions', status: 500, message: 'GEHEIMER-ROHTEXT interner Fehler' })