import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { mailSink } from './support/mailSink.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' // Abo-Abgleich des Terminplaners (server/services/termine/aboAbgleich.js, Terminplaner TP2 E4/E5/E6; E10: Zusammenspiel mit dem Zahlungs-Webhook, letzter Abschnitt): aktive Terminplaner ohne laufendes // Wartungs-Abo abschalten (Schritt 1), Einrichtungen, die das Abo-Ende erreicht, abschalten und erstatten (Schritt 1b, E5), die // Mail K22 nachholen (Schritt 2) und bei zurueckgekehrtem Abo wieder einschalten, mit K23 (Schritt 3, E6). Aufbau wie termineJobs.test.js: Nachbau von Appwrite mit Schema und Standard-Limit 25, Mails in // der Senke, feste Zeit JETZT. Stripe wird nie erreicht (Port 1). const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings'), }, defaultLimit: 25, }) after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixtures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { withLocks } = await import('../server/services/keyedLock.js') const { disable, activateClosedTickets, handleFeatureCheckoutEvent } = await import('../server/services/siteFeatures.js') const { gleicheAboAb, meldeAboEnde, endeTermineFuerKunde } = await import('../server/services/termine/aboAbgleich.js') after(() => setMailTransportForTests(null)) const TAG = 24 * 60 * 60 * 1000 const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin const KUNDE = 'c_kunde01' const F1 = 'f_p_projekt01_termine' const BASIS_VORHER = config.publicBaseUrl const plus = (von, ms) => new Date(von.getTime() + ms) const ms = (wert) => new Date(wert).getTime() let sink let log beforeEach(async () => { for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'termineBookings', 'customers', 'websiteProjects', 'workorders']) { for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) } aw.resetListCalls() config.publicBaseUrl = 'https://project.webklar.com' sink = mailSink() setMailTransportForTests(sink) log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) }) afterEach(() => { // Logs enthalten nie Adresse oder Namen des Kunden (und keine Gaeste: der Abgleich kennt keine) const text = [...log.error.mock.calls, ...log.warn.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' ')).join('\n') mock.timers.reset() mock.restoreAll() config.publicBaseUrl = BASIS_VORHER assert.doesNotMatch(text, /silke@example\.com|Silke|Müller/i, 'keine Adresse und kein Name des Kunden im Log') }) // ---------------------------------------------------------------- Hilfen const projektId = (nr) => `p_projekt0${nr}` const funktionsId = (nr) => `f_p_projekt0${nr}_termine` /** Aktiver, bezahlter Terminplaner von KUNDE fuer Projekt nr (samt Projekt "Praxis Muster" bzw. eigenem Namen). */ function legeFunktion(nr = 1, over = {}, { projekt = 'Praxis Muster', kunde = KUNDE } = {}) { aw.seed('websiteProjects', { $id: projektId(nr), customerId: kunde, projectName: projekt }) aw.seed('portalFeatures', { $id: funktionsId(nr), customerId: kunde, projectId: projektId(nr), featureKey: 'termine', state: 'active', enabled: true, unlockedByPurchase: true, purchaseStatus: 'paid', siteKey: `abcdefghijklmn0${nr}`, ...over, }) return funktionsId(nr) } const funktion = (id = F1) => aw.docs('portalFeatures').get(id) const T19_BETREFF = '[Abo] Erstattung fällig: Terminplaner-Einrichtung' const K22_BETREFF = /^Dein Terminplaner wurde beendet/ /** Endzustand der Meldung zu einer bezahlten, abgeschalteten Einrichtung (E5): T19 gemeldet, K22 gesendet (Marker). Der Abgleich laesst sie in Ruhe. */ const ERSTATTET = { zahlungMeldung: 'gemeldet', aboEndeMailAt: '2026-10-04T08:00:00.000Z' } /** Bezahlte Einrichtung im Aufbau (setup): Sitzung cs_test_1, Rechnungslink, offenes Ticket WO-4711 (Dokument t_ in workorders). */ function legeAufbau(nr = 1, over = {}) { aw.seed('workorders', { $id: `t_${nr}`, woid: 'WO-4711', status: 'Open', topic: 'Terminplaner einbauen – Praxis Muster' }) return legeFunktion(nr, { state: 'setup', enabled: false, stripeSessionId: 'cs_test_1', stripeInvoiceId: 'in_1', invoiceUrl: 'https://invoice.stripe.test/in_1', ticketId: `t_${nr}`, ...over, }) } /** Bestellung ohne Zahlung (ordered): die Stripe-Sitzung ist offen, unlockedByPurchase fehlt. */ const legeBestellung = (nr = 1, over = {}) => legeFunktion(nr, { state: 'ordered', enabled: false, unlockedByPurchase: false, purchaseStatus: 'ordered', stripeSessionId: 'cs_test_1', ...over }) /** Wartungs-Abo von KUNDE (ersetzt nichts: ein einziges Abo-Dokument je Test). */ const wartung = (status, over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status, ...over })) const hosting = (status = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, customerId: KUNDE, status, ...over })) /** Der Abgleich laeuft "zur Zeit now": die Wanduhr steht fest auf now, damit disable() (new Date()) denselben Zeitpunkt schreibt. */ async function laufe(now = JETZT) { mock.timers.enable({ apis: ['Date'], now: now.getTime() }) try { return await gleicheAboAb(now) } finally { mock.timers.reset() } } const ERGEBNIS_LEER = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 } const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over }) const mails = () => sink.sent const t19 = () => mails().filter((m) => m.subject === T19_BETREFF) const k22 = () => mails().filter((m) => K22_BETREFF.test(m.subject)) const abfragenAufAbos = () => aw.listCalls('subscriptions').length /** * Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, laeuft zuerst dazwischen() zu Ende, dann * erst bekommt der Aufrufer die (inzwischen veraltete) Antwort: ein anderer Vorgang schiebt sich dazwischen. */ async function mitEinwurf(passt, dazwischen, fn) { const echt = globalThis.fetch let offen = true globalThis.fetch = async (url, optionen) => { const antwort = await echt(url, optionen) if (offen && passt(decodeURIComponent(String(url)), optionen?.method ?? 'GET')) { offen = false globalThis.fetch = echt await dazwischen() } return antwort } try { return await fn() } finally { globalThis.fetch = echt } } const liste = (collection) => (url, methode) => methode === 'GET' && new RegExp(`/collections/${collection}/documents(\\?|$)`).test(url) // ---------------------------------------------------------------- Schritt 1 und 2: Abo beendet test('Abo beendet: Terminplaner abgeschaltet, K22 an den Kunden, am Folgetag nichts mehr (die Frist wird nie verschoben)', async () => { legeFunktion(1) wartung('canceled', { endedAt: JETZT.toISOString(), canceledAt: JETZT.toISOString() }) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.enabled, false) assert.equal(doc.disabledReason, 'abo_ende') assert.equal(ms(doc.disabledAt), JETZT.getTime()) assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo beendet') assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker erst nach dem Versand, mit now') assert.equal(mails().length, 1) const mail = mails()[0] assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) assert.equal(mail.subject, 'Dein Terminplaner wurde beendet – Praxis Muster') assert.match(mail.text, /Silke Müller/) assert.match(mail.text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'Block abo_ende, Enddatum = Berliner Tag von disabledAt') assert.match(mail.text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = disabledAt + 30 Tage') assert.match(mail.text, /Ab 04\.11\.2026 löschen wir/) assert.match(mail.text, /149 €/, 'Preis aus dem Register (priceLabel)') assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#termine/) assert.doesNotMatch(mail.text, /Hosting-Abo/, 'nicht der Hosting-Block') assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail') // Folgetag: nichts mehr, disabledAt und Marker bleiben, keine zweite Mail const vorher = { ...funktion() } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 1) }) test('Kein Abo-Dokument: abgeschaltet, K22 mit dem Abo-Ende-Block', async () => { legeFunktion(1) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(funktion().disabledReason, 'abo_ende') assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/) }) test('Nur ein Hosting-Abo aktiv: abgeschaltet, K22 mit dem Block hosting ("Hosting-Abo")', async () => { legeFunktion(1) hosting('active') assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') const text = mails()[0].text assert.match(text, /weil Dein Abo ein Hosting-Abo ist/) assert.doesNotMatch(text, /Wartungs-Abo am/, 'nicht der Block abo_ende') assert.match(text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'der Kasten "Was jetzt gilt" gehoert auch zum Hosting-Fall') }) test('Widerrufener Wechsel ins Wartungs-Abo (Spiegel sagt "Wartung"): abgeschaltet, K22 mit dem Block hosting', async () => { legeFunktion(1) wartung('active') aw.seed('contractEvents', { $id: 'ev_1', customerId: KUNDE, subscriptionId: 'sub_w', type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen' }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') assert.match(mails()[0].text, /weil Dein Abo ein Hosting-Abo ist/) }) // ---------------------------------------------------------------- Laeuft weiter / endet: die Status-Tabelle const LAEUFT_WEITER = [ ['past_due', {}], ['trialing', {}], ['active', { cancelAtPeriodEnd: true, cancelAt: new Date(Date.now() + 10 * TAG).toISOString() }], ] for (const [status, zusatz] of LAEUFT_WEITER) { test(`Abo ${status}${zusatz.cancelAtPeriodEnd ? ' mit vorgemerkter Kuendigung in 10 Tagen' : ''}: laeuft weiter, Terminplaner bleibt aktiv, keine Mail`, async () => { legeFunktion(1) wartung(status, zusatz) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().enabled, true) assert.equal(funktion().disabledAt, undefined) assert.equal(mails().length, 0) }) } for (const status of ['unpaid', 'incomplete_expired', 'canceled']) { test(`Abo ${status}: Terminplaner abgeschaltet`, async () => { legeFunktion(1) wartung(status) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(funktion().disabledReason, 'abo_ende') }) } // ---------------------------------------------------------------- Lesefehler: nie abschalten test('Lesefehler beim Abo: der Terminplaner bleibt aktiv, fehler 1, keine Mail; der naechste Lauf schaltet ab', async () => { legeFunktion(1) wartung('canceled') aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }), 'nicht lesbar heisst nicht bewertet: geprueft 0') assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') }) test('Lesefehler erst bei der frischen Pruefung unter der Sperre: bleibt aktiv; der folgende Lauf schaltet ab', async () => { legeFunktion(1) wartung('canceled') aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().disabledAt, undefined) assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') }) test('Fehler beim Lesen der Ereignisse (Wechsel-Regel): bleibt aktiv, fehler 1', async () => { legeFunktion(1) wartung('active') aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) }) test('Fehler beim Lesen der Dokumentliste: kein Wurf, Ergebnis mit fehler, nichts geaendert', async () => { legeFunktion(1) wartung('canceled') aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 }) assert.deepEqual(await gleicheAboAb(JETZT), ergebnis({ fehler: 1 })) assert.equal(funktion().state, 'active') assert.equal(abfragenAufAbos(), 0, 'ohne Liste wird kein Abo gelesen') assert.equal(mails().length, 0) }) test('Ein fehlgeschlagenes Abschalten stoppt den Lauf nicht: das Dokument bleibt aktiv, die anderen Kunden kommen dran', async () => { aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' }) legeFunktion(1) legeFunktion(2, {}, { projekt: 'Zweites Projekt', kunde: 'c_kunde02' }) aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c_kunde02', status: 'canceled' })) wartung('canceled') aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500 }) const erg = await laufe() assert.deepEqual(erg, ergebnis({ geprueft: 2, abgeschaltet: 1, mails: 1, fehler: 1 })) assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state].sort(), ['active', 'disabled']) assert.equal(mails().length, 1) // der naechste Lauf holt das Fehlgeschlagene nach assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 2, abgeschaltet: 1, mails: 1 })) assert.equal(funktion(funktionsId(1)).state, 'disabled') assert.equal(funktion(funktionsId(2)).state, 'disabled') }) // ---------------------------------------------------------------- Pilot/Kulanz, Entwuerfe, andere Funktionen test('Pilot und Kulanz (unlockedByPurchase false oder fehlend): unveraendert aktiv, nicht geprueft, als ausgenommen gezaehlt, ohne Abo-Abfrage und Mail', async () => { aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' }) legeFunktion(1, { unlockedByPurchase: false }) const ohneFeld = legeFunktion(2, { unlockedByPurchase: undefined }, { kunde: 'c_kunde02' }) // das Feld fehlt (Altbestand, Staff-Freischaltung) assert.notEqual(funktion(ohneFeld).unlockedByPurchase, true) assert.deepEqual(await laufe(), ergebnis({ ausgenommen: 2 })) assert.equal(funktion(funktionsId(1)).state, 'active') assert.equal(funktion(ohneFeld).state, 'active') assert.equal(abfragenAufAbos(), 0, 'fuer ausgenommene Dokumente wird das Abo nicht gelesen') assert.equal(mails().length, 0) }) test('Entwuerfe (off) werden nicht gelesen: eine Abfrage mit featureKey termine und notEqual state off; andere Funktionen bleiben unberuehrt', async () => { legeFunktion(1, { state: 'off', enabled: false }) aw.seed('portalFeatures', { $id: 'f_p_projekt09_news', customerId: KUNDE, projectId: 'p_projekt09', featureKey: 'news', state: 'active', enabled: true, unlockedByPurchase: true, purchaseStatus: 'paid' }) assert.deepEqual(await laufe(), ergebnis()) assert.equal(funktion().state, 'off') assert.equal(funktion('f_p_projekt09_news').state, 'active', 'News fasst der Abgleich nie an') const listen = aw.listCalls('portalFeatures') assert.equal(listen.length, 1, 'eine einzige Listenabfrage') const abfragen = listen[0].queries assert.ok(abfragen.some((q) => q.method === 'equal' && q.attribute === 'featureKey' && q.values[0] === 'termine')) assert.ok(abfragen.some((q) => q.method === 'notEqual' && q.attribute === 'state' && q.values[0] === 'off'), JSON.stringify(abfragen)) assert.equal(abfragenAufAbos(), 0) }) test('Laeuft das Abo: ordered, setup und ein abgeschaltetes abo_ende_vor_einrichtung zaehlen als geprueft und bleiben unberuehrt (kein T19, kein K22)', async () => { wartung('active') legeBestellung(1) legeAufbau(2) legeFunktion(3, { ...ERSTATTET, state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() }) const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) })) assert.deepEqual(await laufe(), ergebnis({ geprueft: 3 })) assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher) assert.equal(mails().length, 0) }) test('Alt-disabled ohne Grund, team und kunde_geloescht: nie angefasst, nie K22', async () => { const tag = plus(JETZT, -3 * TAG).toISOString() legeFunktion(1, { state: 'disabled', enabled: false, disabledAt: tag }) // Altbestand ohne Grund legeFunktion(2, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'team' }) legeFunktion(3, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'kunde_geloescht' }) const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) })) assert.deepEqual(await laufe(), ergebnis()) assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher) assert.equal(mails().length, 0) assert.equal(abfragenAufAbos(), 0) }) test('Dokument ohne Kunden-ID wird nie als "kein Abo" gedeutet: bleibt aktiv, fehler 1', async () => { legeFunktion(1, { customerId: '' }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) assert.equal(funktion().state, 'active') assert.equal(abfragenAufAbos(), 0) }) // ---------------------------------------------------------------- Rennen test('Rennen: zwischen Vorauswahl und Sperre kommt ein aktives Abo - die frische Pruefung laesst den Terminplaner aktiv', async () => { legeFunktion(1) wartung('canceled') const erg = await mitEinwurf( liste('subscriptions'), async () => wartung('active'), // das Abo kommt zurueck, nachdem die Vorauswahl "beendet" gelesen hat () => laufe(), ) assert.deepEqual(erg, ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) assert.equal(abfragenAufAbos(), 2, 'Vorauswahl und frische Pruefung') }) test('Rennen: zwischen Liste und Sperre schaltet das Team ab - Grund team und disabledAt bleiben, kein K22', async () => { legeFunktion(1) wartung('canceled') await mitEinwurf( liste('portalFeatures'), () => disable(F1, { reason: 'team', adminNote: 'Abgeschaltet von Kenso' }), () => gleicheAboAb(JETZT), ).then((erg) => assert.deepEqual(erg, ergebnis({ geprueft: 1 }))) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'team') assert.equal(doc.adminNote, 'Abgeschaltet von Kenso') assert.ok(Math.abs(ms(doc.disabledAt) - Date.now()) < 60_000, 'disabledAt vom Team, nicht vom Abgleich') assert.equal(doc.aboEndeMailAt, undefined) assert.equal(mails().length, 0) const vorher = { ...doc } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis(), 'auch am Folgetag nichts') assert.deepEqual(funktion(), vorher) }) test('Rennen: zwischen Liste und Sperre wird das Dokument zum Kulanz-Dokument (nicht vom Kauf freigeschaltet) - bleibt aktiv, nichts passiert', async () => { legeFunktion(1) wartung('canceled') const erg = await mitEinwurf( liste('portalFeatures'), async () => aw.seed('portalFeatures', { ...funktion(), unlockedByPurchase: false }), () => laufe(), ) assert.deepEqual(erg, ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().disabledAt, undefined) assert.equal(mails().length, 0) }) test('Sperre: haelt jemand feature-admin:, bleibt der Abgleich offen, bis sie frei ist', async () => { legeFunktion(1) wartung('canceled') let fertig = false let lauf await withLocks([`feature-admin:${'p_projekt01'}`], async () => { lauf = gleicheAboAb(JETZT).then((erg) => { fertig = true return erg }) await new Promise((resolve) => setTimeout(resolve, 250)) assert.equal(fertig, false, 'der Abgleich wartet auf die Sperre') assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) }) assert.deepEqual(await lauf, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') }) test('Sperre: ein anderes Projekt blockiert nicht', async () => { legeFunktion(1) wartung('canceled') const erg = await withLocks(['feature-admin:p_fremd'], () => gleicheAboAb(JETZT)) assert.deepEqual(erg, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) }) // ---------------------------------------------------------------- Memo test('Memo: ein Kunde mit drei Projekten - die Vorauswahl liest das Abo einmal, drei Dokumente abgeschaltet, je Projekt eine K22', async () => { legeFunktion(1, {}, { projekt: 'Projekt Alpha' }) legeFunktion(2, {}, { projekt: 'Projekt Beta' }) legeFunktion(3, {}, { projekt: 'Projekt Gamma' }) wartung('canceled') assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 3, mails: 3 })) for (const nr of [1, 2, 3]) { assert.equal(funktion(funktionsId(nr)).state, 'disabled') assert.equal(funktion(funktionsId(nr)).disabledReason, 'abo_ende') } assert.deepEqual(mails().map((m) => m.subject).sort(), [ 'Dein Terminplaner wurde beendet – Projekt Alpha', 'Dein Terminplaner wurde beendet – Projekt Beta', 'Dein Terminplaner wurde beendet – Projekt Gamma', ]) // eine Abfrage fuer die Vorauswahl (nicht eine je Projekt) plus je abzuschaltendem Dokument die frische Pruefung unter seiner Sperre assert.equal(abfragenAufAbos(), 1 + 3) }) test('Memo: mehrere Kunden - je Kunde eine Vorauswahl, die Ausnahmen kosten keine Abfrage', async () => { aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' }) legeFunktion(1) legeFunktion(2, {}, { kunde: 'c_kunde02' }) legeFunktion(3, { unlockedByPurchase: false }, { kunde: 'c_kunde03' }) wartung('active') aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c_kunde02', status: 'active' })) assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, ausgenommen: 1 })) assert.equal(abfragenAufAbos(), 2) }) // ---------------------------------------------------------------- K22-Nachlauf test('K22-Nachlauf: der Transport wirft - disabled, Marker leer, fehler 1; der naechste Tag sendet K22 mit unveraenderten Daten', async () => { legeFunktion(1) wartung('canceled') setMailTransportForTests({ sendMail: async () => { throw new Error('550 5.1.1 Recipient address rejected') }, }) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'abo_ende') assert.ok(!doc.aboEndeMailAt, 'Marker erst nach Erfolg') assert.ok(log.error.mock.calls.length >= 1, 'der Fehler steht im Log (ohne Adresse, das prueft afterEach)') setMailTransportForTests(sink) const disabledAt = doc.disabledAt const naechster = plus(JETZT, TAG) assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(mails().length, 1) assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'Enddatum aus disabledAt, nicht vom Folgetag') assert.match(mails()[0].text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum bleibt: die Frist wuchs nicht') assert.equal(funktion().disabledAt, disabledAt) assert.equal(ms(funktion().aboEndeMailAt), naechster.getTime()) assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }), 'am dritten Tag nichts mehr') assert.equal(mails().length, 1) }) test('K22-Nachlauf: Marker-Schreiben scheitert nach dem Versand - fehler 1, die Mail ist raus, der naechste Tag meldet erneut und setzt den Marker', async () => { legeFunktion(1) wartung('canceled') aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, after: 1 }) // 1. PATCH = abschalten, 2. = Marker assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1, fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.ok(!funktion().aboEndeMailAt) assert.equal(mails().length, 1) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }), 'lieber doppelt als gar nicht') assert.equal(ms(funktion().aboEndeMailAt), plus(JETZT, TAG).getTime()) assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 })) }) for (const [fall, vorbereitung] of [ ['Kunde geloescht', () => deleteDocument('customers', KUNDE)], ['Kunde ohne E-Mail', () => aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })], ]) { test(`K22-Nachlauf: ${fall} - kein Wurf, keine Mail, Marker gesetzt, am Folgetag nichts`, async () => { legeFunktion(1) wartung('canceled') await vorbereitung() assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(ms(funktion().aboEndeMailAt), JETZT.getTime(), 'kein Wiederholen') assert.equal(mails().length, 0) assert.ok(log.warn.mock.calls.length >= 1, 'ein Hinweis im Log, ohne Adresse (das prueft afterEach)') assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 0) }) } test('K22-Nachlauf: das Abo laeuft wieder (ohne Einstellung) - kein K22, Marker bleibt leer, das Dokument bleibt abgeschaltet (Schritt 3 braucht die Einstellung)', async () => { legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString() }) wartung('active') assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'disabled') assert.ok(!funktion().aboEndeMailAt) assert.equal(mails().length, 0) }) test('K22-Nachlauf: mit Marker (aboEndeMailAt) nie eine zweite Mail', async () => { legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString(), aboEndeMailAt: plus(JETZT, -2 * TAG).toISOString() }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 0) }) test('K22-Nachlauf: Fehler beim frischen Lesen des Abos - keine Mail, fehler 1, der naechste Tag holt sie nach', async () => { legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -TAG).toISOString() }) aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, frische Pruefung scheitert assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 04\.10\.2026 beendet wurde/, 'Enddatum = Tag von disabledAt (gestern)') assert.match(mails()[0].text, /Bis 03\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = disabledAt (04.10.) + 30 Tage') }) // ---------------------------------------------------------------- meldeAboEnde (E5/E9 rufen nur ihn) test('meldeAboEnde: sendet K22 und setzt den Marker (true), mit Marker nichts mehr (true, keine Mail), Fehler im Versand false', async () => { const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) const lage = { laeuft: false, art: 'beendet', endedAt: null } const spaeter = plus(JETZT, 2 * TAG) setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } }) assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter }), false) assert.ok(!funktion(id).aboEndeMailAt) setMailTransportForTests(sink) let gesendet = 0 assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true) assert.equal(gesendet, 1) assert.equal(mails().length, 1) assert.equal(ms(funktion(id).aboEndeMailAt), spaeter.getTime()) assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true) assert.equal(gesendet, 1) assert.equal(mails().length, 1, 'der Marker verhindert die zweite Mail') }) test('meldeAboEnde: unbekannter Projektname faellt auf "Dein Projekt" zurueck, ein Lesefehler beim Projekt wiederholt am naechsten Tag', async () => { const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) await deleteDocument('websiteProjects', 'p_projekt01') const lage = { laeuft: false, art: 'beendet', endedAt: null } assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true) assert.equal(mails()[0].subject, 'Dein Terminplaner wurde beendet – Dein Projekt') const id2 = legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) aw.failNext({ collection: 'websiteProjects', method: 'GET', status: 500 }) assert.equal(await meldeAboEnde(funktion(id2), { lage, now: JETZT }), false) assert.ok(!funktion(id2).aboEndeMailAt) assert.equal(mails().length, 1) }) // ---------------------------------------------------------------- C3: das Datum in K22 ist das Abo-Ende, nicht der Tag des Abschaltens const AM_ABO_ENDE = new Date('2026-10-02T09:30:00.000Z') // drei Tage vor dem Lauf (JETZT = 05.10.2026) test('C3: K22 nennt das Abo-Ende (endedAt des Abos), wenn es frueher war als das Abschalten; das Loeschdatum bleibt disabledAt + 30 Tage', async () => { legeFunktion(1) wartung('canceled', { endedAt: AM_ABO_ENDE.toISOString(), canceledAt: '2026-09-20T08:00:00.000Z' }) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(ms(funktion().disabledAt), JETZT.getTime()) const mail = mails()[0] assert.match(mail.text, /weil Dein Wartungs-Abo am 02\.10\.2026 beendet wurde/, 'Enddatum = Abo-Ende, nicht der 05.10.') assert.match(mail.text, /Beendet am: 02\.10\.2026/, 'auch die Zeile "Beendet am"') assert.match(mail.text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = Abschalten (05.10.) + 30 Tage') }) test('C3: ohne endedAt gilt canceledAt, ohne beides der Tag des Abschaltens (disabledAt)', async () => { legeFunktion(1) wartung('canceled', { endedAt: null, canceledAt: AM_ABO_ENDE.toISOString() }) await laufe(JETZT) assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 02\.10\.2026 beendet wurde/, 'canceledAt als Abo-Ende') const id = legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { projekt: 'Zweites' }) assert.equal(await meldeAboEnde(funktion(id), { lage: { laeuft: false, art: 'beendet', endedAt: null }, now: JETZT }), true) assert.match(mails().at(-1).text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'ohne Abo-Ende: disabledAt') const id3 = legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { projekt: 'Drittes' }) assert.equal(await meldeAboEnde(funktion(id3), { lage: { laeuft: false, art: 'beendet', endedAt: 'kein Datum' }, now: JETZT }), true) assert.match(mails().at(-1).text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'unlesbares endedAt: disabledAt') }) test('C3: bei einem Hosting-Abo nennt K22 den Tag des Abschaltens, nicht das Kuendigungsdatum des Hosting-Abos', async () => { const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) assert.equal(await meldeAboEnde(funktion(id), { lage: { laeuft: false, art: 'hosting', endedAt: AM_ABO_ENDE.toISOString() }, now: JETZT }), true) const text = mails().at(-1).text assert.match(text, /05\.10\.2026/, 'Tag des Abschaltens') assert.doesNotMatch(text, /02\.10\.2026/, 'nicht das Datum des Hosting-Abos') }) test('C3: K22 vor_einrichtung nennt ebenfalls das Abo-Ende', async () => { legeAufbau(1) wartung('canceled', { endedAt: AM_ABO_ENDE.toISOString() }) await laufe(JETZT) assert.equal(funktion().disabledReason, 'abo_ende_vor_einrichtung') assert.match(k22()[0].text, /Dein Wartungs-Abo wurde am 02\.10\.2026 beendet, noch vor der Einrichtung/) }) // ---------------------------------------------------------------- Schritt 1b: Abo endet vor der Einrichtung (E5) test('Bezahlt im Aufbau (setup, Ticket) ohne Abo: abgeschaltet, ein T19 an das Team, K22 vor_einrichtung, Ticket bleibt offen, am Folgetag nichts', async () => { legeAufbau(1) const ticketVorher = { ...aw.docs('workorders').get('t_1') } assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.enabled, false) assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.equal(ms(doc.disabledAt), JETZT.getTime()) assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung') assert.equal(doc.purchaseStatus, 'paid', 'die Zahlung bleibt gebucht') assert.equal(doc.zahlungMeldung, 'gemeldet', 'T19 ist raus') assert.equal(doc.stripeSessionId, 'cs_test_1') assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker erst nach T19 und K22') // T19: genau einmal, an das Team, mit Kunde, Projekt, Sitzung, Rechnungslink und Ticketnummer assert.equal(t19().length, 1) const mailT19 = t19()[0] assert.deepEqual(mailT19.to.map((a) => a.address).sort(), [...config.chatNotify.emails].sort()) assert.match(mailT19.text, /Silke Müller/) assert.match(mailT19.text, /Praxis Muster/) assert.match(mailT19.text, /cs_test_1/) assert.ok(mailT19.html.includes('https://invoice.stripe.test/in_1'), 'Rechnungslink') assert.match(mailT19.text, /WO-4711/) assert.match(mailT19.text, /Ticket noch offen/) assert.doesNotMatch(mailT19.text, /Zahlung nach dem Abo-Ende/, 'die Zahlung kam vor dem Abo-Ende') // K22 an den Kunden: Block vor_einrichtung mit Preis, ohne den Kasten "Was jetzt gilt" assert.equal(k22().length, 1) const mailK22 = k22()[0] assert.deepEqual(mailK22.to.map((a) => a.address), ['silke@example.com']) assert.equal(mailK22.subject, 'Dein Terminplaner wurde beendet – Praxis Muster') assert.match(mailK22.text, /Dein Wartungs-Abo wurde am 05\.10\.2026 beendet, noch vor der Einrichtung Deines Terminplaners/) assert.match(mailK22.text, /erstatten Dir die Einrichtung \(149 €\) vollständig/) assert.doesNotMatch(mailK22.text, /Was jetzt gilt|löschen wir|Hosting-Abo|weil Dein Wartungs-Abo/, 'kein Loeschkasten, nicht die anderen Gruende') assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'zuerst T19, dann K22') // das Ticket wird nicht angefasst assert.deepEqual(aw.docs('workorders').get('t_1'), ticketVorher) assert.equal(aw.docs('workorders').get('t_1').status, 'Open') // zweiter Lauf: weder T19 noch K22, nichts geaendert const vorher = { ...funktion() } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(t19().length, 1) assert.equal(k22().length, 1) }) test('T19 schlaegt fehl: abgeschaltet, kein Marker, kein K22, fehler; der naechste Lauf sendet T19 und danach K22, spaetere Stripe-Ereignisse kein zweites T19', async () => { legeAufbau(1) setMailTransportForTests({ sendMail: async (mail) => { if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen') return sink.sendMail(mail) }, }) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) let doc = funktion() assert.equal(doc.state, 'disabled', 'abgeschaltet wird trotzdem') assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.ok(!doc.zahlungMeldung, 'Marker gemeldet erst nach gesendetem T19') assert.ok(!doc.aboEndeMailAt, 'Marker aboEndeMailAt erst nach T19 und K22') assert.equal(doc.purchaseStatus, 'paid') assert.equal(mails().length, 0, 'auch K22 ging nicht (T19 zuerst)') const disabledAt = doc.disabledAt setMailTransportForTests(sink) const naechster = plus(JETZT, TAG) assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(t19().length, 1) assert.equal(k22().length, 1) assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'T19 vor K22') assert.match(k22()[0].text, /am 05\.10\.2026 beendet/, 'Enddatum aus disabledAt, nicht vom Folgetag') doc = funktion() assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(doc.purchaseStatus, 'paid') assert.equal(ms(doc.aboEndeMailAt), naechster.getTime()) assert.equal(doc.disabledAt, disabledAt, 'die Frist wuchs nicht') assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }), 'danach nie wieder') assert.equal(mails().length, 2) // ein spaeteres Stripe-Ereignis zur selben Sitzung bucht nichts und meldet nichts mehr await handleFeatureCheckoutEvent({ type: 'checkout.session.async_payment_succeeded', data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: KUNDE, projectId: 'p_projekt01', featureKey: 'termine' } } }, }) assert.equal(t19().length, 1, 'kein zweites T19') assert.equal(mails().length, 2) assert.equal(funktion().state, 'disabled') }) test('T19 schlaegt fehl und K22 wuerde gehen: K22 wird nicht vorgezogen (Reihenfolge T19, K22, Marker), fehler zaehlt je Lauf', async () => { legeAufbau(1) setMailTransportForTests({ sendMail: async (mail) => { if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen') return sink.sendMail(mail) }, }) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, fehler: 1 }), 'auch der zweite Tag scheitert an T19') assert.equal(k22().length, 0) assert.ok(!funktion().aboEndeMailAt) assert.ok(!funktion().zahlungMeldung) }) test('Unbezahltes ordered ohne Abo: abgeschaltet, kein T19, kein K22, kein Marker, Sitzung unveraendert, auch am Folgetag nichts', async () => { legeBestellung(1) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 })) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.enabled, false) assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung') assert.ok(!doc.aboEndeMailAt, 'kein Marker') assert.ok(!doc.zahlungMeldung) assert.equal(doc.stripeSessionId, 'cs_test_1', 'die offene Stripe-Sitzung wird nicht angefasst') assert.equal(doc.purchaseStatus, 'ordered') assert.equal(doc.unlockedByPurchase, false) assert.equal(mails().length, 0) const vorher = { ...funktion() } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher, 'kein Schreibzugriff') assert.equal(mails().length, 0) }) test('Unbezahltes ordered, danach trifft die Zahlung ein: das Webhook-Ereignis meldet T19 genau einmal (nach dem Abo-Ende), der naechste Lauf sendet K22 vor_einrichtung', async () => { legeBestellung(1) await laufe(JETZT) assert.equal(mails().length, 0) await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: KUNDE, projectId: 'p_projekt01', featureKey: 'termine' } } }, }) assert.equal(t19().length, 1) assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/) assert.equal(funktion().state, 'disabled') assert.equal(funktion().unlockedByPurchase, true) assert.equal(funktion().zahlungMeldung, 'gemeldet') assert.equal(k22().length, 0, 'K22 gehoert dem Abgleich') assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(t19().length, 1, 'kein zweites T19') assert.equal(k22().length, 1) assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/) assert.ok(funktion().aboEndeMailAt) assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 2) }) test('setup ohne ticketId ("Ticket fehlt"): T19 ohne den Block ticket_offen, K22 vor_einrichtung', async () => { legeAufbau(1, { ticketId: '' }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(t19().length, 1) assert.doesNotMatch(t19()[0].text, /Ticket noch offen/) assert.doesNotMatch(t19()[0].text, /WO-4711/) assert.equal(k22().length, 1) assert.equal(funktion().zahlungMeldung, 'gemeldet') }) for (const status of ['past_due', 'trialing', 'active']) { test(`Abo ${status}: ordered und setup bleiben unberuehrt, keine Mail`, async () => { legeBestellung(1) legeAufbau(2) wartung(status) const vorher = [1, 2].map((nr) => ({ ...funktion(funktionsId(nr)) })) assert.deepEqual(await laufe(), ergebnis({ geprueft: 2 })) assert.deepEqual([1, 2].map((nr) => funktion(funktionsId(nr))), vorher) assert.equal(mails().length, 0) }) } test('Lesefehler beim Abo: ordered und setup bleiben unberuehrt, fehler 1; der naechste Lauf schaltet ab (auch beim Fehler der frischen Pruefung)', async () => { legeBestellung(1) legeAufbau(2) aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state], ['ordered', 'setup']) assert.equal(mails().length, 0) aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, die frische Pruefung des ersten Dokuments scheitert const zweiter = await laufe(plus(JETZT, TAG)) assert.equal(zweiter.fehler, 1) assert.equal(zweiter.abgeschaltet, 1, 'das andere Dokument kommt dran') const dritter = await laufe(plus(JETZT, 2 * TAG)) assert.equal(dritter.fehler, 0) assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state], ['disabled', 'disabled']) assert.equal(t19().length, 1, 'T19 nur fuer die bezahlte Einrichtung') assert.equal(k22().length, 1) }) test('Rennen: das Abo kommt zwischen Vorauswahl und Sperre zurueck - ordered und setup bleiben, nichts wird gesendet', async () => { legeBestellung(1) legeAufbau(2) const erg = await mitEinwurf(liste('subscriptions'), async () => wartung('active'), () => laufe()) assert.deepEqual(erg, ergebnis({ geprueft: 2 })) assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state], ['ordered', 'setup']) assert.equal(mails().length, 0) }) test('Rennen: das Team aktiviert zwischen Liste und Sperre - das frisch gelesene Dokument ist active, Schritt 1b greift nicht', async () => { legeAufbau(1) const erg = await mitEinwurf( liste('portalFeatures'), async () => aw.seed('portalFeatures', { ...funktion(), state: 'active', enabled: true }), () => laufe(), ) assert.deepEqual(erg, ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().disabledReason, undefined) assert.equal(mails().length, 0) }) test('Review Focus 1, nie ohne Spur: das Ticket wird nach dem Abo-Ende geschlossen - activateClosedTickets aktiviert das abgeschaltete Dokument nicht', async () => { legeAufbau(1) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(t19().length, 1, 'die Spur: T19 mit dem offenen Ticket') aw.seed('workorders', { ...aw.docs('workorders').get('t_1'), status: 'Closed' }) assert.deepEqual(await activateClosedTickets({ now: plus(JETZT, TAG) }), []) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.equal(doc.enabled, false) assert.equal(mails().length, 2, 'keine weitere Mail') }) test('abo_ende_vor_einrichtung-Dokument mit Abo-Rueckkehr: bleibt wie es ist (die Rueckkehr gilt nur fuer abo_ende)', async () => { legeFunktion(1, { ...ERSTATTET, state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() }) wartung('active') const vorher = { ...funktion() } assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 0) }) test('Unbezahltes, abgeschaltetes abo_ende_vor_einrichtung-Dokument wird taeglich uebergangen: kein Schreibzugriff, keine Mail', async () => { legeBestellung(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -3 * TAG).toISOString() }) const vorher = { ...funktion() } assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 0) assert.equal(abfragenAufAbos(), 1, 'nur die Vorauswahl: fuer das Dokument ohne Aufgabe gibt es keine frische Pruefung unter der Sperre') }) test('Mehrere Projekte: bezahlter Aufbau, unbezahlte Bestellung und aktiver Terminplaner eines Kunden - je Dokument das Richtige', async () => { legeFunktion(1, {}, { projekt: 'Projekt Alpha' }) // aktiv legeAufbau(2) legeBestellung(3) assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 3, mails: 2 })) assert.equal(funktion(funktionsId(1)).disabledReason, 'abo_ende') assert.equal(funktion(funktionsId(2)).disabledReason, 'abo_ende_vor_einrichtung') assert.equal(funktion(funktionsId(3)).disabledReason, 'abo_ende_vor_einrichtung') assert.equal(t19().length, 1, 'T19 nur fuer die bezahlte Einrichtung') assert.equal(k22().length, 2, 'K22 fuer den aktiven (abo_ende) und den bezahlten Aufbau (vor_einrichtung)') assert.ok(!funktion(funktionsId(3)).aboEndeMailAt) }) // ---------------------------------------------------------------- Erstattungsmeldung (T19) unabhaengig vom Abo (R107, E5b) /** Bezahlte Einrichtung, die das Abo-Ende schon abgeschaltet hat, deren T19 aber noch aussteht (Marker gemeldet und aboEndeMailAt leer). */ const legeErstattungOffen = (nr = 1, over = {}) => legeAufbau(nr, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString(), ...over }) /** Transport, bei dem T19 scheitert (alles andere geht in die Senke). */ const t19Scheitert = () => setMailTransportForTests({ sendMail: async (mail) => { if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen') return sink.sendMail(mail) }, }) test('R107: T19 scheitert am Tag 1 (Abo beendet), am Tag 2 laeuft das Abo wieder - Tag 2 sendet T19 genau einmal, danach K22, das Dokument bleibt abgeschaltet, Tag 3 sendet nichts', async () => { legeAufbau(1) t19Scheitert() assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) let doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.ok(!doc.zahlungMeldung) assert.ok(!doc.aboEndeMailAt) assert.equal(mails().length, 0) const disabledAt = doc.disabledAt // Tag 2: das Wartungs-Abo laeuft wieder, der Versand geht setMailTransportForTests(sink) wartung('active') aw.resetListCalls() const tag2 = plus(JETZT, TAG) assert.deepEqual(await laufe(tag2), ergebnis({ geprueft: 1, mails: 1 }), 'mails zaehlt K22, nicht T19') assert.equal(abfragenAufAbos(), 0, 'der Nachlauf liest das Abo nicht') assert.equal(t19().length, 1, 'T19 genau einmal') assert.equal(k22().length, 1) assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'T19 vor K22') assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/) assert.match(k22()[0].text, /am 05\.10\.2026 beendet/, 'Enddatum aus disabledAt, nicht vom Folgetag') assert.match(t19()[0].text, /cs_test_1/) assert.match(t19()[0].text, /WO-4711/) doc = funktion() assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(doc.purchaseStatus, 'paid') assert.equal(ms(doc.aboEndeMailAt), tag2.getTime()) assert.equal(doc.state, 'disabled', 'keine Wiederkehr: sie gilt nur fuer abo_ende') assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.equal(doc.enabled, false) assert.equal(doc.disabledAt, disabledAt, 'die Frist wuchs nicht') assert.equal(aw.docs('workorders').get('t_1').status, 'Open', 'das Ticket bleibt unberuehrt') // Tag 3: nichts mehr const vorher = { ...doc } assert.deepEqual(await laufe(plus(tag2, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 2) }) test('R107: T19 scheitert weiter, obwohl das Abo laeuft - jeden Tag fehler 1, kein K22, kein Marker; sobald der Versand geht, T19 und K22', async () => { legeAufbau(1) t19Scheitert() assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) wartung('active') assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, fehler: 1 })) assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(mails().length, 0, 'auch K22 geht nicht vor T19') assert.ok(!funktion().zahlungMeldung) assert.ok(!funktion().aboEndeMailAt) assert.equal(funktion().state, 'disabled') setMailTransportForTests(sink) assert.deepEqual(await laufe(plus(JETZT, 3 * TAG)), ergebnis({ geprueft: 1, mails: 1 })) assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false]) assert.equal(funktion().zahlungMeldung, 'gemeldet') assert.deepEqual(await laufe(plus(JETZT, 4 * TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 2) }) test('R107: das Abo ist nicht lesbar - T19 wird trotzdem nachgeholt; ein anderes Dokument desselben Kunden bleibt fail-closed (aktiv, fehler 1)', async () => { legeErstattungOffen(1) legeFunktion(2, {}, { projekt: 'Zweites Projekt' }) // aktiv: braucht die Abo-Lage aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, mails: 1, fehler: 1 })) assert.equal(t19().length, 1) assert.equal(k22().length, 1) assert.equal(funktion(funktionsId(1)).zahlungMeldung, 'gemeldet') assert.ok(funktion(funktionsId(1)).aboEndeMailAt) assert.equal(funktion(funktionsId(2)).state, 'active', 'nie abschalten, wenn etwas nicht lesbar war') assert.equal(mails().length, 2) }) test('R107: der Nachlauf liest das Abo nie - ein einzelnes Dokument kostet keine Abo-Abfrage, bei zwei Dokumenten braucht nur das erledigte die Lage', async () => { legeErstattungOffen(1) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(abfragenAufAbos(), 0) assert.equal(t19().length, 1) legeErstattungOffen(2) wartung('active') aw.resetListCalls() assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 2, mails: 1 }), 'das erste Dokument ist erledigt und wird nur bewertet') assert.equal(abfragenAufAbos(), 1, 'nur das erledigte Dokument braucht die Lage (Rueckkehr-Pruefung), der Nachlauf selbst keine') assert.equal(t19().length, 2) }) for (const [abo, vorbereitung] of [ ['ohne Abo', () => {}], ['mit laufendem Abo', () => wartung('active')], ['mit beendetem Abo', () => wartung('canceled', { endedAt: JETZT.toISOString() })], ]) { test(`R107: unbezahltes abo_ende_vor_einrichtung-Dokument ${abo} - nie T19, nie K22, nichts geschrieben`, async () => { legeBestellung(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() }) vorbereitung() const vorher = { ...funktion() } assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 })) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 0) }) } test('R107: bereits gemeldet - kein zweites T19, auch wenn das Abo laeuft; mit gemeldetem T19 und fehlendem K22 geht nur K22 (ohne Abo), nie T19', async () => { legeErstattungOffen(1, ERSTATTET) // T19 gemeldet, K22 ging wartung('active') const vorher = { ...funktion() } assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 0) // T19 gemeldet, K22 noch offen, kein Abo: der bisherige Nachlauf (Schritt 2) sendet nur K22 await deleteDocument('subscriptions', 'sub_w') aw.seed('portalFeatures', { ...funktion(), aboEndeMailAt: null }) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(t19().length, 0, 'T19 war schon gemeldet') assert.equal(k22().length, 1) assert.equal(funktion().zahlungMeldung, 'gemeldet') assert.ok(funktion().aboEndeMailAt) }) test('R107: Mischung - ein Kunde mit offener Erstattung, aktivem Terminplaner und Bestellung; das Abo laeuft: nur die Erstattung wird gemeldet, nichts wird abgeschaltet', async () => { legeErstattungOffen(1) legeFunktion(2, {}, { projekt: 'Projekt Alpha' }) legeBestellung(3) wartung('active') assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, mails: 1 })) assert.equal(t19().length, 1) assert.equal(funktion(funktionsId(2)).state, 'active') assert.equal(funktion(funktionsId(3)).state, 'ordered') assert.equal(abfragenAufAbos(), 1, 'die Lage wird fuer die uebrigen Dokumente einmal gelesen') }) test('R107: Mischung ohne Abo - Erstattung, aktiver Terminplaner und Bestellung: je Dokument das Richtige, T19 genau einmal', async () => { legeErstattungOffen(1) legeFunktion(2, {}, { projekt: 'Projekt Alpha' }) legeBestellung(3) assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 2, mails: 2 })) assert.equal(t19().length, 1) assert.equal(k22().length, 2, 'K22 fuer die Erstattung (vor_einrichtung) und den aktiven Terminplaner (abo_ende)') assert.equal(funktion(funktionsId(1)).zahlungMeldung, 'gemeldet') assert.equal(funktion(funktionsId(2)).disabledReason, 'abo_ende') assert.equal(funktion(funktionsId(3)).disabledReason, 'abo_ende_vor_einrichtung') assert.ok(!funktion(funktionsId(3)).aboEndeMailAt) }) test('R107: das Dokument wird nur einmal je Lauf bearbeitet - schlaegt K22 nach gesendetem T19 fehl, zaehlt fehler 1 (nicht 2); der naechste Tag ohne Abo sendet K22', async () => { legeErstattungOffen(1) setMailTransportForTests({ sendMail: async (mail) => { if (K22_BETREFF.test(mail.subject)) throw new Error('Verbindung abgebrochen') return sink.sendMail(mail) }, }) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(t19().length, 1) assert.equal(funktion().zahlungMeldung, 'gemeldet') assert.ok(!funktion().aboEndeMailAt) setMailTransportForTests(sink) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(t19().length, 1, 'kein zweites T19') assert.equal(k22().length, 1) }) test('R107 Rennen: das Team aktiviert zwischen Liste und Sperre - das frisch gelesene Dokument ist active, kein T19, keine Mail', async () => { legeErstattungOffen(1) const erg = await mitEinwurf( liste('portalFeatures'), async () => aw.seed('portalFeatures', { ...funktion(), state: 'active', enabled: true }), () => laufe(), ) assert.deepEqual(erg, ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) }) test('R107 Rennen: der Webhook meldet T19 zwischen Liste und Sperre - kein zweites T19 durch den Abgleich; ohne Abo geht K22 ueber den bisherigen Nachlauf (Schritt 2), am Folgetag nichts', async () => { legeErstattungOffen(1) const erg = await mitEinwurf( liste('portalFeatures'), async () => aw.seed('portalFeatures', { ...funktion(), zahlungMeldung: 'gemeldet' }), () => laufe(), ) assert.deepEqual(erg, ergebnis({ geprueft: 1, mails: 1 })) assert.equal(t19().length, 0, 'T19 war schon gemeldet') assert.equal(k22().length, 1) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 1) }) test('R107 Sperre: der Nachlauf liest das Dokument unter feature-admin: (wartet auf die Sperre), sendet aber ausserhalb jeder Sperre', async () => { legeErstattungOffen(1) let fertig = false let lauf await withLocks(['feature-admin:p_projekt01'], async () => { lauf = gleicheAboAb(JETZT).then((erg) => { fertig = true return erg }) await new Promise((resolve) => setTimeout(resolve, 250)) assert.equal(fertig, false, 'der Abgleich wartet auf die Sperre') assert.equal(mails().length, 0) }) // Versand: waehrenddessen darf feature-admin: nicht gehalten sein const sperreBeimVersand = [] setMailTransportForTests({ sendMail: async (mail) => { const frei = await Promise.race([ withLocks(['feature-admin:p_projekt01'], async () => 'frei'), new Promise((resolve) => setTimeout(() => resolve('gesperrt'), 150)), ]) sperreBeimVersand.push([mail.subject === T19_BETREFF ? 'T19' : 'K22', frei]) return sink.sendMail(mail) }, }) assert.deepEqual(await lauf, ergebnis({ geprueft: 1, mails: 1 })) assert.deepEqual(sperreBeimVersand, [['T19', 'frei'], ['K22', 'frei']]) assert.equal(funktion().zahlungMeldung, 'gemeldet') }) test('R107: Lesefehler beim frischen Dokument - kein Wurf, fehler 1, keine Mail; der naechste Tag holt T19 und K22 nach', async () => { legeErstattungOffen(1) aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, after: 1 }) // die Liste geht durch, das frische Lesen scheitert assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(mails().length, 0) assert.ok(!funktion().zahlungMeldung) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false]) }) for (const [fall, vorbereitung] of [ ['Kunde ohne E-Mail-Adresse', () => aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })], ['Kunde geloescht', () => deleteDocument('customers', KUNDE)], ]) { test(`R107: ${fall} bei laufendem Abo - T19 geht trotzdem an das Team, kein K22, Marker gesetzt, am Folgetag nichts`, async () => { legeErstattungOffen(1) await vorbereitung() wartung('active') assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.equal(t19().length, 1, 'das Geld hat eine Spur, auch ohne Kunden') assert.equal(k22().length, 0) assert.equal(funktion().zahlungMeldung, 'gemeldet') assert.ok(funktion().aboEndeMailAt, 'kein Wiederholen') assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 1) }) } // ---------------------------------------------------------------- meldeAboEnde fuer abo_ende_vor_einrichtung (der Aufruf, den E9 nutzt) test('meldeAboEnde direkt: bezahltes Dokument mit Grund abo_ende_vor_einrichtung - ein T19, ein K22 vor_einrichtung, Marker, true; zweiter Aufruf nichts', async () => { const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() }) const lage = { laeuft: false, art: 'beendet', endedAt: null } let gesendet = 0 assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT, onGesendet: () => (gesendet += 1) }), true) assert.equal(t19().length, 1) assert.equal(k22().length, 1) assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/) assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false]) assert.equal(gesendet, 1, 'onGesendet zaehlt K22') assert.equal(ms(funktion(id).aboEndeMailAt), JETZT.getTime()) assert.equal(funktion(id).zahlungMeldung, 'gemeldet') assert.equal(funktion(id).purchaseStatus, 'paid') assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true) assert.equal(mails().length, 2, 'zweiter Aufruf: weder T19 noch K22') assert.equal(ms(funktion(id).aboEndeMailAt), JETZT.getTime(), 'Marker unveraendert') }) test('meldeAboEnde direkt: bezahlt, K22-Marker gesetzt, aber T19 noch nicht gemeldet - nur T19 (kein zweites K22, Marker unveraendert); danach nichts mehr', async () => { const marker = plus(JETZT, -TAG).toISOString() const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: marker, aboEndeMailAt: marker }) assert.equal(await meldeAboEnde(funktion(id), { now: JETZT }), true, 'ohne lage: bei abo_ende_vor_einrichtung wird sie nicht gebraucht') assert.equal(t19().length, 1) assert.equal(k22().length, 0, 'K22 ging schon') assert.equal(funktion(id).zahlungMeldung, 'gemeldet') assert.equal(funktion(id).aboEndeMailAt, marker, 'Marker unveraendert') assert.equal(await meldeAboEnde(funktion(id), { now: plus(JETZT, TAG) }), true) assert.equal(mails().length, 1) }) test('meldeAboEnde direkt: unbezahltes ordered-Dokument - weder T19 noch K22 noch Marker, true, Dokument unveraendert', async () => { const id = legeBestellung(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() }) const vorher = { ...funktion(id) } const lage = { laeuft: false, art: 'beendet', endedAt: null } assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true) assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true) assert.equal(mails().length, 0) assert.deepEqual(funktion(id), vorher) }) test('meldeAboEnde direkt: T19 scheitert - false, kein K22, kein Marker; der naechste Aufruf sendet T19 und K22 in dieser Reihenfolge', async () => { const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() }) const lage = { laeuft: false, art: 'beendet', endedAt: null } setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } }) assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), false) assert.ok(!funktion(id).zahlungMeldung) assert.ok(!funktion(id).aboEndeMailAt) setMailTransportForTests(sink) assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true) assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false]) assert.ok(funktion(id).aboEndeMailAt) }) test('meldeAboEnde direkt: T19 war schon draussen (gemeldet) - kein zweites T19, K22 und Marker folgen; K22 scheitert - Marker leer, T19 nicht wiederholt', async () => { const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString(), zahlungMeldung: 'gemeldet' }) const lage = { laeuft: false, art: 'beendet', endedAt: null } setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } }) assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), false, 'K22 scheitert') assert.ok(!funktion(id).aboEndeMailAt) setMailTransportForTests(sink) assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true) assert.equal(t19().length, 0, 'T19 war schon gemeldet') assert.equal(k22().length, 1) assert.ok(funktion(id).aboEndeMailAt) }) test('meldeAboEnde direkt: bezahlt, aber Kunde nicht mehr vorhanden - T19 geht trotzdem an das Team, kein K22, Marker gesetzt', async () => { const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() }) await deleteDocument('customers', KUNDE) const lage = { laeuft: false, art: 'beendet', endedAt: null } assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true) assert.equal(t19().length, 1, 'das Geld hat eine Spur, auch ohne Kunden') assert.equal(k22().length, 0) assert.equal(funktion(id).zahlungMeldung, 'gemeldet') assert.ok(funktion(id).aboEndeMailAt, 'kein Wiederholen') }) // ---------------------------------------------------------------- Schritt 3: Rueckkehr des Abos, Wiedereinschalten und K23 (E6) const K23_BETREFF = 'Dein Terminplaner läuft wieder – Praxis Muster' const k23 = () => mails().filter((m) => /^Dein Terminplaner läuft wieder/.test(m.subject)) const bookingId = (nr) => `b_${funktionsId(nr)}` /** Einstellung (termineConfig, Version 7) und eine kuenftige Buchung der Funktion nr: die Daten, die ein Wiedereinschalten behalten muss. */ function legeDaten(nr = 1) { aw.seed('termineConfig', { $id: funktionsId(nr), customerId: KUNDE, projectId: projektId(nr), einstellung: JSON.stringify({ betrieb: { name: 'Salon Müller' }, anrede: 'sie' }), version: 7, updatedAt: plus(JETZT, -20 * TAG).toISOString(), updatedBy: 'silke@example.com', }) aw.seed('termineBookings', { $id: bookingId(nr), featureId: funktionsId(nr), customerId: KUNDE, projectId: projektId(nr), status: 'bestaetigt', startsAt: plus(JETZT, 3 * TAG).toISOString(), endsAt: plus(JETZT, 3 * TAG + 45 * 60_000).toISOString(), gastName: 'Max Mustermann', }) } const datenStand = (nr = 1) => ({ einstellung: aw.docs('termineConfig').get(funktionsId(nr)) && { ...aw.docs('termineConfig').get(funktionsId(nr)) }, buchung: aw.docs('termineBookings').get(bookingId(nr)) && { ...aw.docs('termineBookings').get(bookingId(nr)) }, }) /** Vor 10 Tagen wegen des Abo-Endes abgeschalteter Terminplaner (K22 ging: Marker gesetzt) mit Einstellung und Buchung. */ function legeAbgeschaltet(nr = 1, over = {}, { daten = true, ...kontext } = {}) { const id = legeFunktion( nr, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -10 * TAG).toISOString(), aboEndeMailAt: plus(JETZT, -10 * TAG).toISOString(), adminNote: 'Abgeschaltet: Wartungs-Abo beendet', ...over, }, kontext, ) if (daten) legeDaten(nr) return id } let wechselNr = 0 /** Vertragsereignis "wechsel" des Wartungs-Abos sub_w; $createdAt steigt mit jedem Aufruf ("das juengste" ist eindeutig). */ function wechselEreignis(status) { wechselNr += 1 aw.seed('contractEvents', { $id: `ev_w${wechselNr}`, $createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, wechselNr)).toISOString(), customerId: KUNDE, subscriptionId: 'sub_w', type: 'wechsel', productKey: 'wartung', details: '{}', status, }) } test('Rueckkehr innerhalb von 30 Tagen: wieder aktiv, Einstellung und Buchung unveraendert, K23 genau einmal, am Folgetag nichts', async () => { legeAbgeschaltet(1) wartung('active') const daten = datenStand() assert.ok(daten.einstellung && daten.buchung) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) const doc = funktion() assert.equal(doc.state, 'active') assert.equal(doc.enabled, true) assert.equal(ms(doc.activatedAt), JETZT.getTime()) assert.equal(doc.disabledAt, null, 'die Loeschfrist endet') assert.equal(doc.disabledReason, null) assert.equal(doc.aboEndeMailAt, null) assert.equal(doc.adminNote, 'Wieder eingeschaltet: Wartungs-Abo läuft wieder') assert.equal(doc.unlockedByPurchase, true, 'bleibt ein bezahlter Terminplaner') assert.equal(doc.siteKey, 'abcdefghijklmn01', 'das eingebettete Snippet bleibt gueltig') assert.deepEqual(datenStand(), daten, 'Einstellung (samt Version) und Buchung sind unveraendert') assert.equal(aw.docs('termineConfig').get(F1).version, 7) assert.equal(k23().length, 1) assert.equal(mails().length, 1) const mail = k23()[0] assert.equal(mail.subject, K23_BETREFF) assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) assert.match(mail.text, /Silke Müller/) assert.match(mail.text, /Dein Wartungs-Abo läuft wieder/) assert.match(mail.text, /Praxis Muster/) assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#termine/) assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail') // Folgetag: nichts mehr (das Dokument ist aktiv und das Abo laeuft), keine zweite Mail, nichts geschrieben const vorher = { ...funktion() } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 1) }) test('K22 ging nie (Marker leer, das Abo kam vorher zurueck): wieder eingeschaltet, weder K22 noch K23', async () => { legeAbgeschaltet(1, { aboEndeMailAt: null }) wartung('active') assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().disabledReason, null) assert.equal(mails().length, 0, 'kein K22, kein K23') }) test('K22 scheiterte am Abschalttag und das Abo kommt am Folgetag zurueck: wieder aktiv, weder K22 noch K23 (der Kunde erfuhr nie vom Ende)', async () => { legeFunktion(1) legeDaten(1) wartung('canceled') setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } }) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.ok(!funktion().aboEndeMailAt) setMailTransportForTests(sink) wartung('active') assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, wiederEin: 1 })) assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) }) test('Kunde ohne E-Mail oder geloescht: wieder eingeschaltet, kein K23, kein Fehler (ein Hinweis im Log, ohne Adresse)', async () => { for (const vorbereitung of [() => deleteDocument('customers', KUNDE), () => aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })]) { for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) legeAbgeschaltet(1) wartung('active') await vorbereitung() assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1 })) assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) } assert.ok(log.warn.mock.calls.length >= 1) }) test('Rueckkehr nach Tag 30 (disabledAt vor 35 Tagen), Einstellung noch da: wieder eingeschaltet; die Loeschung desselben Tagesjobs findet nichts mehr', async () => { const { loescheAbgeschalteteFunktionen } = await import('../server/services/termine/jobs.js') legeAbgeschaltet(1, { disabledAt: plus(JETZT, -35 * TAG).toISOString(), aboEndeMailAt: plus(JETZT, -35 * TAG).toISOString() }) // Gegenprobe: ein Dokument mit Grund team in gleicher Lage wird von der Loeschung erfasst legeAbgeschaltet(2, { disabledReason: 'team', disabledAt: plus(JETZT, -35 * TAG).toISOString(), aboEndeMailAt: null }) wartung('active') const daten = datenStand(1) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().disabledAt, null) assert.equal(k23().length, 1) assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 }, 'nur das team-Dokument') assert.deepEqual(datenStand(1), daten, 'Einstellung und Buchung des zurueckgekehrten Terminplaners sind noch da') assert.equal(aw.docs('termineConfig').get(funktionsId(2)), undefined, 'Gegenprobe: team-Dokument geloescht') assert.equal(funktion().state, 'active') }) test('Nach der Loeschung nie automatisch: ein off-Dokument mit laufendem Abo bleibt unberuehrt und wird nicht einmal gelesen', async () => { legeFunktion(1, { state: 'off', enabled: false, unlockedByPurchase: false, purchaseStatus: 'zurueckgesetzt', adminNote: 'Daten gelöscht (Löschfrist abgelaufen)' }) wartung('active') const vorher = { ...funktion() } assert.deepEqual(await laufe(), ergebnis()) assert.deepEqual(funktion(), vorher) assert.equal(abfragenAufAbos(), 0) assert.equal(mails().length, 0) }) test('Ohne Einstellung (termineConfig fehlt oder ist unlesbar): bleibt abgeschaltet, kein K23, kein Fehler, nichts geschrieben', async () => { legeAbgeschaltet(1, {}, { daten: false }) legeAbgeschaltet(2, {}, { daten: false }) aw.seed('termineConfig', { $id: funktionsId(2), customerId: KUNDE, projectId: projektId(2), einstellung: '{kaputt', version: 3, updatedAt: JETZT.toISOString(), updatedBy: '' }) wartung('active') const vorher = [1, 2].map((nr) => ({ ...funktion(funktionsId(nr)) })) assert.deepEqual(await laufe(), ergebnis({ geprueft: 2 })) assert.deepEqual([1, 2].map((nr) => funktion(funktionsId(nr))), vorher) assert.equal(mails().length, 0) }) test('Ein Kunde, zwei abgeschaltete Terminplaner: nur der mit Einstellung kehrt zurueck, K23 nennt sein Projekt', async () => { legeAbgeschaltet(1, {}, { projekt: 'Projekt Alpha' }) legeAbgeschaltet(2, {}, { projekt: 'Projekt Beta', daten: false }) wartung('active') assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, wiederEin: 1, mails: 1 })) assert.equal(funktion(funktionsId(1)).state, 'active') assert.equal(funktion(funktionsId(2)).state, 'disabled') assert.deepEqual(mails().map((m) => m.subject), ['Dein Terminplaner läuft wieder – Projekt Alpha']) }) test('Nicht automatisch: team, kunde_geloescht, ohne Grund und abo_ende_vor_einrichtung bleiben trotz laufendem Abo und vorhandener Einstellung unberuehrt', async () => { const tag = plus(JETZT, -3 * TAG).toISOString() legeAbgeschaltet(1, { disabledReason: 'team', aboEndeMailAt: null, disabledAt: tag }) legeAbgeschaltet(2, { disabledReason: 'kunde_geloescht', aboEndeMailAt: null, disabledAt: tag }) legeAbgeschaltet(3, { disabledReason: undefined, aboEndeMailAt: null, disabledAt: tag }) // Altbestand ohne Grund legeAufbau(4, { ...ERSTATTET, state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: tag }) // bezahlt, Erstattung gemeldet, K22 ging legeDaten(4) wartung('active') const vorher = [1, 2, 3, 4].map((nr) => ({ ...funktion(funktionsId(nr)) })) const daten = [1, 2, 3, 4].map((nr) => datenStand(nr)) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }), 'nur abo_ende_vor_einrichtung zaehlt als bewertet') assert.deepEqual([1, 2, 3, 4].map((nr) => funktion(funktionsId(nr))), vorher, 'kein Feld wird angefasst') assert.deepEqual([1, 2, 3, 4].map((nr) => datenStand(nr)), daten) assert.equal(mails().length, 0, 'weder K23 noch K22 noch T19') }) // -- Rennen und Lesefehler: im Zweifel bleibt der Terminplaner abgeschaltet test('Rennen: das Abo endet zwischen Vorauswahl und Sperre wieder - die frische Pruefung laesst den Terminplaner abgeschaltet, kein K23', async () => { legeAbgeschaltet(1) wartung('active') const vorher = { ...funktion() } const erg = await mitEinwurf( liste('subscriptions'), async () => wartung('canceled'), // die Vorauswahl las "laeuft", dann endet das Abo () => laufe(), ) assert.deepEqual(erg, ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 0) assert.equal(abfragenAufAbos(), 2, 'Vorauswahl und frische Pruefung') }) test('Rennen: das Team schaltet zwischen Liste und Sperre selbst wieder ein oder mit Grund team ab - der Abgleich schreibt nichts, kein K23', async () => { for (const [fall, aenderung, erwartet] of [ ['Team aktiviert', (doc) => ({ ...doc, state: 'active', enabled: true, disabledAt: null, disabledReason: null, aboEndeMailAt: null }), { state: 'active', disabledReason: null }], ['Team schaltet neu ab (Grund team)', (doc) => ({ ...doc, disabledReason: 'team', aboEndeMailAt: null }), { state: 'disabled', disabledReason: 'team' }], ]) { for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) legeAbgeschaltet(1) wartung('active') let teamStand const erg = await mitEinwurf( liste('portalFeatures'), async () => { teamStand = aenderung({ ...funktion() }) aw.seed('portalFeatures', teamStand) }, () => laufe(), ) assert.deepEqual(erg, ergebnis({ geprueft: 1 }), fall) assert.deepEqual(funktion(), teamStand, `${fall}: der Abgleich schreibt nichts`) assert.equal(funktion().state, erwartet.state, fall) assert.equal(funktion().disabledReason, erwartet.disabledReason, fall) assert.notEqual(funktion().adminNote, 'Wieder eingeschaltet: Wartungs-Abo läuft wieder', fall) assert.equal(mails().length, 0, fall) } }) test('Lesefehler beim Abo (Vorauswahl): bleibt abgeschaltet, fehler 1, kein K23; der naechste Lauf schaltet ein', async () => { legeAbgeschaltet(1) wartung('active') aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) assert.equal(funktion().state, 'active') }) test('Lesefehler erst bei der frischen Pruefung unter der Sperre: bleibt abgeschaltet, fehler 1', async () => { legeAbgeschaltet(1) wartung('active') aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(funktion().disabledReason, 'abo_ende') assert.equal(mails().length, 0) }) test('Fehler beim Lesen der Ereignisse (Wechsel-Regel) oder der Einstellung: bleibt abgeschaltet, fehler 1, kein K23', async () => { legeAbgeschaltet(1) wartung('active') aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) assert.equal(funktion().state, 'disabled') aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 }) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }), 'ohne Fehler schaltet der naechste Lauf ein') }) test('Das Wiedereinschalten scheitert (PATCH): bleibt abgeschaltet, fehler 1 und kein K23 - die Mail folgt nur auf ein geschriebenes active', async () => { legeAbgeschaltet(1) wartung('active') const vorher = { ...funktion() } aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500 }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) assert.equal(k23().length, 1) }) test('Sperre: haelt jemand feature-admin:, wartet auch das Wiedereinschalten', async () => { legeAbgeschaltet(1) wartung('active') let fertig = false let lauf await withLocks(['feature-admin:p_projekt01'], async () => { lauf = gleicheAboAb(JETZT).then((erg) => { fertig = true return erg }) await new Promise((resolve) => setTimeout(resolve, 250)) assert.equal(fertig, false, 'der Abgleich wartet auf die Sperre') assert.equal(funktion().state, 'disabled') assert.equal(mails().length, 0) }) assert.deepEqual(await lauf, ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) assert.equal(funktion().state, 'active') }) test('K23 schlaegt fehl: der Terminplaner bleibt aktiv, fehler 1, wiederEin 1; am Folgetag wird nichts mehr angefasst', async () => { legeAbgeschaltet(1) wartung('active') setMailTransportForTests({ sendMail: async (mail) => { if (/läuft wieder/.test(mail.subject)) throw new Error('550 5.1.1 Recipient address rejected') return sink.sendMail(mail) }, }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, wiederEin: 1, fehler: 1 })) const doc = funktion() assert.equal(doc.state, 'active', 'die Reaktivierung bleibt bestehen') assert.equal(doc.disabledReason, null) assert.equal(doc.aboEndeMailAt, null) assert.equal(mails().length, 0) assert.ok(log.error.mock.calls.length >= 1, 'der Fehler steht im Log (ohne Adresse, das prueft afterEach)') setMailTransportForTests(sink) const vorher = { ...funktion() } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 0) }) // -- Wechsel widerrufen (der Spiegel sagt noch "Wartung"): kein Flattern test('Wechsel widerrufen: aktiv => abgeschaltet (K22 hosting), abgeschaltet bleibt abgeschaltet (kein K23); ein neueres, wirksames wechsel-Ereignis hebt das auf => Rueckkehr mit K23', async () => { legeFunktion(1) legeDaten(1) wartung('active') // der Spiegel steht auf Wartung, obwohl der Wechsel widerrufen wurde wechselEreignis('widerrufen') assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(funktion().disabledReason, 'abo_ende') assert.match(k22()[0].text, /weil Dein Abo ein Hosting-Abo ist/) // kein Flattern: am Folgetag und am uebernaechsten bleibt es abgeschaltet, ohne K23 und ohne zweite K22 const vorher = { ...funktion() } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(k23().length, 0) assert.equal(k22().length, 1) // ein neueres, wirksames wechsel-Ereignis (erledigt) hebt den Widerruf auf: das Wartungs-Abo laeuft wirklich wechselEreignis('erledigt') assert.deepEqual(await laufe(plus(JETZT, 3 * TAG)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().disabledReason, null) assert.equal(k23().length, 1) assert.equal(k22().length, 1) }) // ---------------------------------------------------------------- Sofortpfad: Widerruf des Wechsels (E9) // endeTermineFuerKunde(customerId, { now }) beendet die Terminplaner eines Kunden sofort, ohne das Abo zu lesen (der Spiegel ist // veraltet), und meldet danach ueber meldeAboEnde (nach der Sperre feature-admin). Der Tagesabgleich laesst das Ergebnis in Ruhe. const NOTIZ_WECHSEL = 'Abgeschaltet: Wechsel widerrufen' /** endeTermineFuerKunde "zur Zeit now": die Wanduhr steht fest, damit disable() (new Date()) denselben Zeitpunkt schreibt. */ async function beende(kunde = KUNDE, now = JETZT) { mock.timers.enable({ apis: ['Date'], now: now.getTime() }) try { return await endeTermineFuerKunde(kunde, { now }) } finally { mock.timers.reset() } } test('Sofortpfad: aktiver, bezahlter Terminplaner => abgeschaltet (abo_ende, Notiz Wechsel widerrufen), K22 Block Hosting, Marker; das Abo wird nie gelesen', async () => { legeFunktion(1) legeDaten(1) const daten = datenStand(1) assert.deepEqual(await beende(), { beendet: [F1] }) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.enabled, false) assert.equal(doc.disabledReason, 'abo_ende') assert.equal(ms(doc.disabledAt), JETZT.getTime()) assert.equal(doc.adminNote, NOTIZ_WECHSEL) assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker nach dem Versand, mit now') assert.equal(abfragenAufAbos(), 0, 'weder Abo noch Spiegel gelesen') assert.equal(aw.listCalls('contractEvents').length, 0) assert.deepEqual(datenStand(1), daten, 'Einstellung und Buchungen bleiben (30 Tage Frist)') assert.equal(k22().length, 1) const mail = k22()[0] assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) assert.match(mail.text, /weil Dein Abo ein Hosting-Abo ist/, 'Block hosting: der Wechsel wurde widerrufen, das Abo bleibt Hosting') assert.match(mail.text, /Ab 04\.11\.2026 löschen wir/, 'Loeschdatum = Sofortpfad + 30 Tage') assert.equal(t19().length, 0, 'aktiv und bezahlt hat nichts zu erstatten') // der Tagesabgleich findet nichts mehr zu tun, auch ohne Abo assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.equal(k22().length, 1) assert.equal(funktion().adminNote, NOTIZ_WECHSEL) }) test('Sofortpfad: Pilot und Kulanz (aktiv ohne Kauf), News, off, schon Abgeschaltete und andere Kunden bleiben unberuehrt', async () => { legeFunktion(1) // bezahlt: wird beendet legeFunktion(2, { unlockedByPurchase: false, purchaseStatus: '' }) // Pilot legeFunktion(3, { featureKey: 'news' }) legeFunktion(4, { state: 'off', enabled: false, unlockedByPurchase: false }) legeFunktion(5, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: plus(JETZT, -2 * TAG).toISOString() }) legeFunktion(6, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString() }) // K22 noch offen: Sache des Tagesabgleichs aw.seed('customers', { $id: 'c_anderer', name: 'Anderer Kunde', email: 'anderer@example.com' }) legeFunktion(7, {}, { kunde: 'c_anderer' }) const unberuehrt = [2, 3, 4, 5, 6, 7].map((nr) => ({ ...funktion(funktionsId(nr)) })) assert.deepEqual(await beende(), { beendet: [F1] }) assert.deepEqual([2, 3, 4, 5, 6, 7].map((nr) => funktion(funktionsId(nr))), unberuehrt) assert.equal(funktion().state, 'disabled') assert.equal(mails().length, 1) assert.deepEqual(mails()[0].to.map((a) => a.address), ['silke@example.com']) assert.deepEqual(await beende('c_ohne_funktionen'), { beendet: [] }) assert.deepEqual(await beende(''), { beendet: [] }) }) test('Sofortpfad: mehrere Projekte des Kunden, aktiv und im Aufbau, jedes unter seinem Grund', async () => { legeFunktion(1) legeAufbau(2) legeBestellung(3) assert.deepEqual((await beende()).beendet.sort(), [1, 2, 3].map(funktionsId)) assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr)).disabledReason), ['abo_ende', 'abo_ende_vor_einrichtung', 'abo_ende_vor_einrichtung']) assert.equal(t19().length, 1, 'nur die bezahlte Einrichtung wird erstattet') assert.equal(k22().length, 2, 'K22 fuer den aktiven (abo_ende) und die bezahlte Einrichtung (vor_einrichtung), nicht fuer das unbezahlte ordered') assert.equal(abfragenAufAbos(), 0) }) test('Sofortpfad, bezahlter Aufbau (setup): abgeschaltet vor der Einrichtung, genau ein T19 (Ticket offen), K22 vor_einrichtung, Marker, gemeldet; der Tagesabgleich sendet nichts mehr', async () => { legeAufbau(1) const ticketVorher = { ...aw.docs('workorders').get('t_1') } assert.deepEqual(await beende(), { beendet: [F1] }) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.equal(doc.adminNote, NOTIZ_WECHSEL) assert.equal(doc.purchaseStatus, 'paid') assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime()) assert.equal(doc.stripeSessionId, 'cs_test_1') assert.equal(t19().length, 1) assert.match(t19()[0].text, /Ticket noch offen/) assert.match(t19()[0].text, /WO-4711/) assert.doesNotMatch(t19()[0].text, /Zahlung nach dem Abo-Ende/) assert.equal(k22().length, 1) assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/) assert.doesNotMatch(k22()[0].text, /Was jetzt gilt|löschen wir/) assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'zuerst T19, dann K22') assert.deepEqual(aw.docs('workorders').get('t_1'), ticketVorher, 'das Ticket bleibt, wie es ist') // der folgende Abgleichslauf: weder T19 noch K22 (auch wenn der Spiegel noch Wartung sagt, der Wechsel aber widerrufen ist) wartung('active') wechselEreignis('widerrufen') const vorher = { ...funktion() } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(t19().length, 1) assert.equal(k22().length, 1) }) test('Sofortpfad, unbezahltes ordered: abgeschaltet, kein T19, kein K22, kein Marker; kommt die Zahlung danach, meldet das Ereignis T19 genau einmal und der Abgleich K22', async () => { legeBestellung(1) assert.deepEqual(await beende(), { beendet: [F1] }) let doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.equal(doc.adminNote, NOTIZ_WECHSEL) assert.ok(!doc.aboEndeMailAt, 'kein Marker') assert.ok(!doc.zahlungMeldung) assert.equal(doc.stripeSessionId, 'cs_test_1', 'die offene Sitzung wird nicht angefasst') assert.equal(doc.unlockedByPurchase, false) assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }), 'der Abgleich laesst es in Ruhe') assert.equal(mails().length, 0) await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: KUNDE, projectId: 'p_projekt01', featureKey: 'termine' } } }, }) assert.equal(t19().length, 1) assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/) assert.equal(k22().length, 0, 'K22 gehoert dem Abgleich') assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(t19().length, 1, 'kein zweites T19') assert.equal(k22().length, 1) assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/) assert.deepEqual(await laufe(plus(JETZT, 3 * TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 2) }) test('Sofortpfad: T19 scheitert => beendet wird gemeldet, ohne Wurf; K22 und Marker fehlen; der Tagesabgleich sendet T19 und K22 nach, in dieser Reihenfolge', async () => { legeAufbau(1) setMailTransportForTests({ sendMail: async (mail) => { if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen') return sink.sendMail(mail) }, }) assert.deepEqual(await beende(), { beendet: [F1] }) let doc = funktion() assert.equal(doc.state, 'disabled', 'abgeschaltet wird trotzdem') assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.ok(!doc.zahlungMeldung, 'T19 nicht gesendet: kein Marker gemeldet') assert.ok(!doc.aboEndeMailAt) assert.equal(mails().length, 0, 'auch K22 geht erst nach T19') assert.ok(log.error.mock.calls.length >= 1) const disabledAt = doc.disabledAt setMailTransportForTests(sink) const naechster = plus(JETZT, TAG) assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 })) assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false]) doc = funktion() assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(ms(doc.aboEndeMailAt), naechster.getTime()) assert.equal(doc.disabledAt, disabledAt, 'die Frist wuchs nicht') assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 2) }) test('Sofortpfad: K22 scheitert bei einem aktiven Terminplaner => beendet, kein Marker; der Tagesabgleich holt K22 nach', async () => { legeFunktion(1) wartung('active') // Spiegel noch Wartung, der Wechsel ist widerrufen: der Abgleich nimmt denselben Block hosting wechselEreignis('widerrufen') setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } }) assert.deepEqual(await beende(), { beendet: [F1] }) assert.equal(funktion().state, 'disabled') assert.ok(!funktion().aboEndeMailAt) const disabledAt = funktion().disabledAt setMailTransportForTests(sink) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(k22().length, 1) assert.match(k22()[0].text, /weil Dein Abo ein Hosting-Abo ist/) assert.match(k22()[0].text, /Ab 04\.11\.2026 löschen wir/, 'Loeschdatum aus dem Sofortpfad (disabledAt), nicht vom Folgetag') assert.equal(funktion().disabledAt, disabledAt, 'die Frist wuchs nicht') assert.ok(funktion().aboEndeMailAt) }) test('Sofortpfad: scheitert ein Dokument, werden die anderen trotzdem beendet; der Fehler steht im Log (ohne Adresse)', async () => { legeFunktion(1) legeFunktion(2) aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) const erg = await beende() assert.equal(erg.beendet.length, 1) const [beendet] = erg.beendet const uebrig = [F1, funktionsId(2)].find((id) => id !== beendet) assert.equal(funktion(beendet).state, 'disabled') assert.equal(funktion(uebrig).state, 'active', 'das gescheiterte bleibt aktiv (der Tagesabgleich holt es nach)') assert.equal(k22().length, 1) assert.ok(log.error.mock.calls.length >= 1) // ein zweiter Aufruf beendet den Rest assert.deepEqual(await beende(), { beendet: [uebrig] }) assert.equal(k22().length, 2) }) test('Sofortpfad: Dokumentenliste nicht lesbar => kein Wurf, nichts beendet, Fehler im Log', async () => { legeFunktion(1) aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'kaputt' }) assert.deepEqual(await beende(), { beendet: [] }) assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) assert.ok(log.error.mock.calls.length >= 1) assert.deepEqual(await beende(), { beendet: [F1] }, 'danach geht es wieder') }) test('Sofortpfad: ungueltiges now wirft nicht (die Meldung scheitert, das Dokument ist abgeschaltet)', async () => { legeFunktion(1) mock.timers.enable({ apis: ['Date'], now: JETZT.getTime() }) let erg try { erg = await endeTermineFuerKunde(KUNDE, { now: 'gestern' }) } finally { mock.timers.reset() } assert.deepEqual(erg, { beendet: [F1] }) assert.equal(funktion().state, 'disabled') assert.ok(!funktion().aboEndeMailAt) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) }) test('Sofortpfad: frisch gelesen unter der Sperre feature-admin - hat das Team zwischen Liste und Sperre abgeschaltet, bleibt dessen Grund', async () => { legeFunktion(1) const teamAus = { ...funktion(), state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: plus(JETZT, -TAG).toISOString() } const erg = await mitEinwurf(liste('portalFeatures'), () => aw.seed('portalFeatures', teamAus), () => beende()) assert.deepEqual(erg, { beendet: [] }) assert.deepEqual(funktion(), { ...funktion(), ...teamAus }) assert.equal(funktion().disabledReason, 'team') assert.equal(mails().length, 0) }) test('Sofortpfad: eine Zahlung zwischen Liste und Sperre (ordered => setup, bezahlt) wird als bezahlt behandelt: T19 und K22 vor_einrichtung', async () => { legeBestellung(1) aw.seed('workorders', { $id: 't_1', woid: 'WO-4711', status: 'Open', topic: 'Terminplaner einbauen – Praxis Muster' }) const bezahlt = { ...funktion(), state: 'setup', unlockedByPurchase: true, purchaseStatus: 'paid', ticketId: 't_1', stripeInvoiceId: 'in_1', invoiceUrl: 'https://invoice.stripe.test/in_1' } const erg = await mitEinwurf(liste('portalFeatures'), () => aw.seed('portalFeatures', bezahlt), () => beende()) assert.deepEqual(erg, { beendet: [F1] }) assert.equal(funktion().state, 'disabled') assert.equal(funktion().zahlungMeldung, 'gemeldet') assert.equal(t19().length, 1) assert.equal(k22().length, 1) assert.ok(funktion().aboEndeMailAt) }) test('Sofortpfad: wartet auf die Sperre feature-admin des Projekts; T19 und K22 gehen erst nach ihrer Freigabe (nie innerhalb)', async () => { legeAufbau(1) legeFunktion(2) const sperre = (nr) => `feature-admin:${projektId(nr)}` // 1) eine fremde Sperre haelt das Dokument auf let freigeben const gehalten = withLocks([sperre(1)], () => new Promise((r) => (freigeben = r))) await new Promise((r) => setTimeout(r, 10)) const lauf = beende() await new Promise((r) => setTimeout(r, 60)) assert.equal(funktion().state, 'setup', 'solange die Sperre gehalten wird, bleibt das Dokument, wie es ist') assert.equal(mails().length, 0) freigeben() await gehalten assert.deepEqual((await lauf).beendet.sort(), [F1, funktionsId(2)].sort()) assert.equal(funktion().state, 'disabled') // 2) beim Versand ist die Sperre frei (withLocks ist nicht wiederbetretbar: innerhalb haengte jede Mail) const frei = [] for (const nr of [1, 2]) { await deleteDocument('portalFeatures', funktionsId(nr)) } legeAufbau(1) legeFunktion(2) setMailTransportForTests({ sendMail: async (mail) => { const projekte = [1, 2].map((nr) => Promise.race([withLocks([sperre(nr)], async () => true), new Promise((r) => setTimeout(() => r(false), 150))])) frei.push([mail.subject, ...(await Promise.all(projekte))]) return sink.sendMail(mail) }, }) await beende() assert.equal(frei.length, 3, 'T19 und zwei K22') assert.ok(frei.every(([, ...rest]) => rest.every(Boolean)), JSON.stringify(frei)) }) // ---------------------------------------------------------------- Zahlung nach dem Abo-Ende: Webhook und Abgleich zusammen (E10) // // Review Focus 1: eine Zahlung, die nach einem Zustandswechsel eintrifft, hinterlaesst immer eine Spur (Ticket ODER T16/T19 an das // Team) und nie ein Doppel. Das Verhalten selbst steckt in siteFeatures.js (markPaid-Fall, meldeErstattung, onFeaturePaid, B10); // diese Tests lassen Webhook (handleFeatureCheckoutEvent) und Abgleich (gleicheAboAb) gegeneinander laufen. const SITZUNG = 'cs_test_1' const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } }) const paidSession = (id = SITZUNG, projectId = 'p_projekt01', extra = {}) => ({ id, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: KUNDE, projectId, featureKey: 'termine' }, ...extra, }) const COMPLETED = 'checkout.session.completed' const ASYNC_BEZAHLT = 'checkout.session.async_payment_succeeded' /** Das Stripe-Ereignis einer bezahlten Sitzung (noch einmal mit demselben Ereignis aufrufen = Wiederholung durch Stripe). */ const zahlungsEreignis = (typ = COMPLETED, session = paidSession()) => event(typ, session) const T16_BETREFF = 'Neue Funktion bestellt: Terminplaner' const K15_BETREFF = 'Deine Bestellung: Terminplaner' const t16 = () => mails().filter((m) => m.subject === T16_BETREFF) const k15 = () => mails().filter((m) => m.subject === K15_BETREFF) const betreffs = () => mails().map((m) => m.subject) const tickets = () => aw.docs('workorders').size /** Das Anlegen eines Tickets schreibt eine Zeile ins Log (ticketAdmin); die Tests mit Ticket halten die Ausgabe sauber. Zurueck setzt afterEach (restoreAll). */ const stilleTicketLog = () => mock.method(console, 'log', () => {}) const TEAM = () => [...config.chatNotify.emails].sort() const an = (mail) => mail.to.map((a) => a.address).sort() /** Unbezahlte Bestellung, deren Wartungs-Abo schon beendet ist: der Abgleich (ohne Abo) hat sie abgeschaltet, die Zahlung steht noch aus. */ async function bestellungNachAboEnde() { legeBestellung(1) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(funktion().disabledReason, 'abo_ende_vor_einrichtung') assert.equal(funktion().unlockedByPurchase, false) assert.equal(mails().length, 0) } test('E10: Zahlung nach dem Abschalten - genau ein T19 (zahlung_nach_ende, Sitzung), kein Ticket, kein K15/T16, bezahlt gebucht, abgeschaltet; Wiederholung und async_payment_succeeded melden nichts mehr', async () => { await bestellungNachAboEnde() const ticketsVorher = tickets() await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)) assert.equal(t19().length, 1, 'genau ein T19') const mailT19 = t19()[0] assert.deepEqual(an(mailT19), TEAM(), 'T19 geht an das Team, nicht an den Kunden') assert.match(mailT19.text, /Zahlung nach dem Abo-Ende/, 'Block zahlung_nach_ende') assert.match(mailT19.text, /cs_test_1/, 'die Sitzungs-ID steht drin') assert.match(mailT19.text, /Silke Müller/) assert.match(mailT19.text, /Praxis Muster/) assert.doesNotMatch(mailT19.text, /Ticket noch offen/, 'es gibt kein Ticket') assert.deepEqual(betreffs(), [T19_BETREFF], 'sonst keine Mail: weder K15 noch T16 noch K22') assert.equal(k15().length + t16().length, 0) assert.equal(tickets(), ticketsVorher, 'kein Eintrag in workorders') assert.equal(tickets(), 0) let doc = funktion() assert.equal(doc.unlockedByPurchase, true) assert.equal(doc.purchaseStatus, 'paid') assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(doc.state, 'disabled', 'der Zustand bleibt abgeschaltet') assert.equal(doc.enabled, false) assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.ok(!doc.ticketId, 'kein Ticket am Dokument') assert.equal(doc.stripeSessionId, SITZUNG) assert.match(doc.adminNote, /Zahlung eingegangen \(Sitzung cs_test_1\), nichts eingerichtet/, 'die Spur steht in der Notiz') // dasselbe Ereignis noch einmal (Stripe-Wiederholung) und das SEPA-Folgeereignis: stumm, nichts geschrieben const gebucht = { ...doc } await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)) await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)) await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)) assert.equal(t19().length, 1, 'nie ein zweites T19') assert.deepEqual(betreffs(), [T19_BETREFF]) assert.deepEqual(funktion(), gebucht, 'nichts neu verbucht') assert.equal(tickets(), 0) }) test('E10: T19-Versand scheitert - der Webhook wirft (500), die Zahlung ist gebucht, die Meldung offen; jede Wiederholung wirft weiter, die erste mit funktionierendem Versand sendet genau ein T19', async () => { await bestellungNachAboEnde() t19Scheitert() await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)), /Meldung zur Zahlung cs_test_1 \(abo_ende\) nicht gesendet/) let doc = funktion() assert.equal(doc.unlockedByPurchase, true, 'die Zahlung ist trotzdem gebucht') assert.equal(doc.purchaseStatus, 'paid') assert.equal(doc.zahlungMeldung, 'abo_ende', 'der Fall bleibt offen (nicht gemeldet)') assert.equal(doc.state, 'disabled') assert.equal(mails().length, 0) assert.equal(tickets(), 0) const gebucht = { ...doc } // Stripe wiederholt, der Versand geht weiter nicht: wieder 500, nichts neu verbucht await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)), /nicht gesendet/) await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)), /nicht gesendet/) assert.deepEqual(funktion(), gebucht) assert.equal(mails().length, 0) // der Versand geht wieder: genau ein T19, danach ist die Wiederholung stumm setMailTransportForTests(sink) await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)) assert.equal(t19().length, 1) assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/) assert.match(t19()[0].text, /cs_test_1/) doc = funktion() assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(doc.purchaseStatus, 'paid') assert.equal(doc.state, 'disabled') await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)) assert.deepEqual(betreffs(), [T19_BETREFF]) assert.equal(tickets(), 0) }) test('E10: Folgetag-Abgleich nach der Zahlung - K22 vor_einrichtung an den Kunden, aboEndeMailAt gesetzt, kein zweites T19 (Marker gemeldet), danach nichts mehr', async () => { await bestellungNachAboEnde() await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)) assert.equal(t19().length, 1) assert.ok(!funktion().aboEndeMailAt, 'K22 gehoert dem Abgleich') const tag2 = plus(JETZT, TAG) assert.deepEqual(await laufe(tag2), ergebnis({ geprueft: 1, mails: 1 }), 'mails zaehlt K22, nicht T19') assert.equal(k22().length, 1) const mailK22 = k22()[0] assert.deepEqual(an(mailK22), ['silke@example.com'], 'K22 geht an den Kunden') assert.equal(mailK22.subject, 'Dein Terminplaner wurde beendet – Praxis Muster') assert.match(mailK22.text, /noch vor der Einrichtung Deines Terminplaners/, 'Block vor_einrichtung') assert.match(mailK22.text, /erstatten Dir die Einrichtung \(149 €\) vollständig/) assert.match(mailK22.text, /am 05\.10\.2026 beendet/, 'Enddatum aus disabledAt') assert.doesNotMatch(mailK22.text, /Was jetzt gilt|löschen wir/, 'es gibt nichts zu loeschen') assert.equal(t19().length, 1, 'kein zweites T19') assert.deepEqual(betreffs().map((s) => s === T19_BETREFF), [true, false], 'zuerst T19 (Webhook), dann K22 (Abgleich)') const doc = funktion() assert.equal(ms(doc.aboEndeMailAt), tag2.getTime()) assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') // Tag 3 und eine spaete Wiederholung des Ereignisses: nichts mehr const vorher = { ...doc } assert.deepEqual(await laufe(plus(tag2, TAG)), ergebnis({ geprueft: 1 })) await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)) assert.equal(mails().length, 2) assert.deepEqual(funktion(), vorher) assert.equal(tickets(), 0) }) test('E10: Abgleich VOR der Wiederholung des Webhooks - Schritt 2b meldet T19 (die erste Verbuchung hat bezahlt), K22 folgt, die Wiederholung bleibt stumm', async () => { await bestellungNachAboEnde() t19Scheitert() await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)), /nicht gesendet/) assert.equal(funktion().unlockedByPurchase, true) assert.equal(funktion().zahlungMeldung, 'abo_ende') assert.equal(mails().length, 0) // der Versand geht wieder, der Tagesabgleich kommt vor Stripes Wiederholung setMailTransportForTests(sink) const tag2 = plus(JETZT, TAG) assert.deepEqual(await laufe(tag2), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(t19().length, 1, 'der Abgleich meldet T19') assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/, 'als Zahlung nach dem Abo-Ende (der offene Fall steht am Dokument)') assert.match(t19()[0].text, /cs_test_1/) assert.equal(k22().length, 1) assert.deepEqual(betreffs().map((s) => s === T19_BETREFF), [true, false], 'T19 vor K22') const doc = funktion() assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(ms(doc.aboEndeMailAt), tag2.getTime()) assert.equal(doc.state, 'disabled') // jetzt kommt die Wiederholung des Webhooks: kein Wurf, kein zweites T19 const vorher = { ...doc } await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)) await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)) assert.equal(t19().length, 1, 'nie doppelt') assert.equal(mails().length, 2) assert.deepEqual(funktion(), vorher) assert.equal(tickets(), 0) }) test('E10: Abgleich und Wiederholung des Webhooks gleichzeitig (Promise.all) - insgesamt ein T19, ein K22, kein Wurf', async () => { await bestellungNachAboEnde() t19Scheitert() await assert.rejects(handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)), /nicht gesendet/) setMailTransportForTests(sink) const tag2 = plus(JETZT, TAG) const [erg] = await Promise.all([laufe(tag2), handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))]) assert.deepEqual(erg, ergebnis({ geprueft: 1, mails: 1 }), 'der Abgleich sendet K22, T19 hoechstens einmal, gleich wer zuerst kommt') assert.equal(t19().length, 1, 'insgesamt ein T19') assert.equal(k22().length, 1) assert.equal(mails().length, 2) const doc = funktion() assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(ms(doc.aboEndeMailAt), tag2.getTime()) assert.deepEqual(await laufe(plus(tag2, TAG)), ergebnis({ geprueft: 1 })) await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)) assert.equal(mails().length, 2) }) test('E10: erste Zahlung und Tagesabgleich gleichzeitig (Promise.all) - gleich in welcher Reihenfolge: ein T19, nach dem naechsten Lauf ein K22, nie ein Doppel', async () => { await bestellungNachAboEnde() const tag2 = plus(JETZT, TAG) await Promise.all([laufe(tag2), handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED))]) assert.equal(t19().length, 1, 'genau ein T19 (Webhook oder Abgleich, nie beide)') assert.equal(funktion().purchaseStatus, 'paid') assert.equal(funktion().unlockedByPurchase, true) assert.equal(funktion().zahlungMeldung, 'gemeldet') assert.equal(tickets(), 0) await laufe(plus(tag2, TAG)) await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)) // Stripe-Wiederholung await laufe(plus(tag2, 2 * TAG)) assert.equal(t19().length, 1) assert.equal(k22().length, 1, 'K22 genau einmal') assert.equal(mails().length, 2) assert.equal(k15().length + t16().length, 0) assert.equal(funktion().state, 'disabled') }) test('E10: Zahlung auf ein vom Team abgeschaltetes Dokument (Grund team) - T16 "abgeschaltet" wie in B10b, kein T19, kein Ticket, der Abgleich fasst es nie an', async () => { legeBestellung(1) await disable(F1, { by: 'Kenso', reason: 'team' }) assert.equal(funktion().disabledReason, 'team') await handleFeatureCheckoutEvent(zahlungsEreignis(COMPLETED)) assert.equal(t16().length, 1, 'genau ein T16') assert.deepEqual(an(t16()[0]), TEAM()) assert.match(t16()[0].text, /Bezahlt, aber schon vorhanden/, 'Block bereits_aktiv') assert.match(t16()[0].text, /abgeschaltet/, 'Zustand abgeschaltet') assert.match(t16()[0].text, /cs_test_1/) assert.equal(t19().length, 0, 'kein T19: das Abo-Ende ist nicht der Grund') assert.equal(k15().length, 0) assert.deepEqual(betreffs(), [T16_BETREFF]) assert.equal(tickets(), 0) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'team') assert.equal(doc.unlockedByPurchase, true) assert.equal(doc.purchaseStatus, 'paid') assert.equal(doc.zahlungMeldung, 'gemeldet') // der Abgleich (ohne Abo) laesst ein Team-Dokument in Ruhe: keine Abo-Abfrage, keine Mail; die Wiederholung ist stumm aw.resetListCalls() assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis()) assert.equal(abfragenAufAbos(), 0) await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)) assert.deepEqual(betreffs(), [T16_BETREFF]) assert.deepEqual(funktion(), doc) }) for (const [name, legeDokument] of [ ['ordered', () => legeBestellung(1)], ['setup ohne Ticket (Ticket fehlt, Stripe wiederholt)', () => legeFunktion(1, { state: 'setup', enabled: false, stripeSessionId: SITZUNG })], ]) { test(`E10: Zahlung bei laufendem Abo (${name}) - unveraendert: Ticket, K15 an den Kunden, T16 ohne kein_abo, kein T19; die Wiederholung legt nichts doppelt an`, async () => { stilleTicketLog() wartung('active') legeDokument() assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 }), 'der Abgleich fasst eine Bestellung bei laufendem Abo nicht an') assert.equal(funktion().state, name === 'ordered' ? 'ordered' : 'setup') const ereignis = zahlungsEreignis(COMPLETED) await handleFeatureCheckoutEvent(ereignis) assert.equal(tickets(), 1, 'ein Einrichtungs-Ticket') const doc = funktion() assert.equal(doc.state, 'setup') assert.equal(doc.unlockedByPurchase, true) assert.equal(doc.purchaseStatus, 'paid') assert.ok(aw.docs('workorders').has(doc.ticketId), 'das Ticket haengt am Dokument') assert.ok(!doc.zahlungMeldung, 'kein Zahlungsfall') assert.equal(k15().length, 1) assert.deepEqual(an(k15()[0]), ['silke@example.com']) assert.equal(t16().length, 1) assert.deepEqual(an(t16()[0]), TEAM()) assert.doesNotMatch(t16()[0].text, /Kein laufendes Wartungs-Abo/, 'das Abo laeuft') assert.doesNotMatch(t16()[0].text, /Bezahlt, aber schon vorhanden/) assert.match(t16()[0].text, new RegExp(`Ticket: ${aw.docs('workorders').get(doc.ticketId).woid}\\b`), 'T16 nennt das Ticket') assert.equal(t19().length, 0) assert.equal(mails().length, 2) // Stripe wiederholt: kein zweites Ticket, keine weitere Mail; der Abgleich bleibt dabei ruhig await handleFeatureCheckoutEvent(ereignis) await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)) assert.equal(tickets(), 1) assert.equal(mails().length, 2) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 2) assert.equal(funktion().state, 'setup') }) } test('E10: Rennen - Zahlung VOR dem Abgleich: Ticket, K15 und T16 (mit kein_abo), dann der Abgleich: T19 und K22; Stripe-Retry desselben Ereignisses ist stumm', async () => { stilleTicketLog() legeBestellung(1) // das Wartungs-Abo ist beendet (kein Abo-Dokument), die Bestellung wartet noch auf die Zahlung const ereignis = zahlungsEreignis(COMPLETED) await handleFeatureCheckoutEvent(ereignis) assert.equal(funktion().state, 'setup', 'gebucht und im Aufbau') assert.equal(tickets(), 1, 'Ticket angelegt') assert.equal(k15().length, 1) assert.equal(t16().length, 1) assert.match(t16()[0].text, /Kein laufendes Wartungs-Abo/, 'T16 sagt dem Team, dass das Abo fehlt') assert.equal(t19().length, 0, 'noch kein T19: das Dokument ist nicht abgeschaltet') const ticketId = funktion().ticketId assert.ok(ticketId) // der Abgleich kommt danach: bezahlter Aufbau ohne Abo => abgeschaltet, T19 (ohne "nach dem Abo-Ende"), K22 vor_einrichtung assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(t19().length, 1, 'T19 hoechstens einmal') assert.doesNotMatch(t19()[0].text, /Zahlung nach dem Abo-Ende/, 'die Zahlung kam vor dem Abo-Ende') assert.match(t19()[0].text, /Ticket noch offen/) assert.equal(k22().length, 1) assert.equal(tickets(), 1, 'kein zweites Ticket') assert.equal(aw.docs('workorders').get(ticketId).status, 'Open', 'das Ticket bleibt offen, das Team erstattet') let doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') assert.equal(doc.zahlungMeldung, 'gemeldet') assert.equal(doc.purchaseStatus, 'paid') assert.equal(doc.ticketId, ticketId) assert.equal(mails().length, 4, 'K15, T16, T19, K22') // Stripe wiederholt dasselbe Ereignis (und das SEPA-Folgeereignis): stumm, nichts geschrieben, kein zweites Ticket const vorher = { ...doc } await handleFeatureCheckoutEvent(ereignis) await handleFeatureCheckoutEvent(zahlungsEreignis(ASYNC_BEZAHLT)) assert.equal(mails().length, 4) assert.equal(t19().length, 1) assert.equal(tickets(), 1) assert.deepEqual(funktion(), vorher) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 4) })