diff --git a/server/services/termine/aboAbgleich.js b/server/services/termine/aboAbgleich.js index 273f4e5..a42fd9d 100644 --- a/server/services/termine/aboAbgleich.js +++ b/server/services/termine/aboAbgleich.js @@ -21,6 +21,11 @@ import { datumKurz } from './zeit.js' * Schritt 2 fuer jedes abgeschaltete Dokument mit Grund 'abo_ende' oder (bezahlt) 'abo_ende_vor_einrichtung' ohne aboEndeMailAt * die Meldung nachholen (auch fuer die eben abgeschalteten); der Marker aboEndeMailAt wird erst nach erfolgreichem * Versand gesetzt + * Schritt 2b Erstattung nachholen, unabhaengig vom Abo (R107, E5b): fuer jedes BEZAHLTE (unlockedByPurchase true), abgeschaltete + * Dokument mit Grund 'abo_ende_vor_einrichtung' und zahlungMeldung !== 'gemeldet' geht T19 an das Team, ob das Wartungs- + * Abo inzwischen wieder laeuft oder nicht lesbar ist (die Erstattung bleibt nach D3 voll geschuldet, die Rueckkehr nach + * Schritt 3 gilt dafuer nicht). Der Nachlauf liest das Abo nie. Danach, in dieser Reihenfolge und nur nach erfolgreichem + * T19: K22 (Block vor_einrichtung), falls aboEndeMailAt noch leer ist, und der Marker. Ein unbezahltes Dokument bekommt nichts. * Schritt 3 Rueckkehr: ein abgeschaltetes Dokument mit Grund 'abo_ende', dessen Wartungs-Abo wieder laeuft, wird wieder eingeschaltet * (activate: Daten und Einstellungen bleiben, Grund, Marker und disabledAt enden), solange die Einstellung noch da ist, auch * nach Tag 30 (der Abgleich laeuft im Tagesjob vor der Loeschung; sonst blockierten sich Loeschung und Rueckkehr). @@ -36,6 +41,8 @@ import { datumKurz } from './zeit.js' * Abo (aboLage direkt, nie der Memo): zwischen Vorauswahl und Sperre kann das Abo zurueckkommen oder das Team abschalten. * Die Mails (T19, K22) und die Marker folgen NACH der Sperre (meldeAboEnde), denn T19 laeuft ueber meldeErstattung mit eigener * Sperre feature-meldung: und darf nie innerhalb einer feature-admin- oder feature-paid-Sperre gerufen werden. + * - Ausnahme von "nach frischem Lesen des Abos": der Nachlauf der Erstattung (Schritt 2b) liest nur das Dokument frisch (unter + * feature-admin:), nie das Abo - er schaltet nichts ab und nichts ein, er meldet nur, was geschuldet ist. * - Fail-closed: ist etwas nicht lesbar (Liste, Abo, Ereignisse, Dokument), wird nichts abgeschaltet. Kunde bzw. Dokument werden * uebersprungen, `fehler` zaehlt, der naechste Lauf versucht es wieder. Das Log nennt weder Namen noch Adressen. * - disabledAt aendert dieser Abgleich nie nach dem Abschalten: schlaegt eine Mail fehl, wiederholt der naechste Tag den Versand mit @@ -62,6 +69,13 @@ const istBewertet = (doc) => doc.state === 'setup' || (doc.state === 'disabled' && typeof doc.disabledReason === 'string' && doc.disabledReason.startsWith('abo_ende')) +/** + * Schritt 2b: bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist. + * Das Abo spielt keine Rolle: die Erstattung bleibt geschuldet, auch wenn es zurueckkommt. + */ +const istErstattungOffen = (doc) => + doc.state === 'disabled' && doc.disabledReason === GRUND_VOR && doc.unlockedByPurchase === true && doc.zahlungMeldung !== 'gemeldet' + /** Schritt 3: abgeschaltet wegen des Abo-Endes (nur 'abo_ende', nicht 'abo_ende_vor_einrichtung'). */ const istRueckkehr = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND @@ -77,20 +91,24 @@ const portalFeatures = () => config.collections.portalFeatures * siteFeatures das als T19 und der Abgleich sendet dann K22). Ein bezahltes Dokument meldet ZUERST die Erstattung an das Team * (meldeErstattung, T19, genau einmal je Dokument, auch ohne erreichbaren Kunden), dann K22 mit Block `vor_einrichtung` (ohne * `daten`: es gibt nichts zu loeschen). Scheitert T19 (weder gesendet noch schon gemeldet), folgen weder K22 noch Marker: der - * naechste Tag wiederholt beides in dieser Reihenfolge. + * naechste Tag wiederholt beides in dieser Reihenfolge. Der Marker aboEndeMailAt gilt nur fuer K22: steht er schon, aber T19 ist + * noch nicht gemeldet (R107), geht nur T19 und K22 nicht noch einmal. `lage` braucht dieser Grund nicht (nur 'abo_ende' fuer + * den Block hosting). * Der Aufrufer liest `doc` frisch und haelt KEINE feature-admin- und keine feature-paid-Sperre (meldeErstattung sperrt selbst * feature-meldung:). Ohne Kunde oder ohne E-Mail gibt es keine K22 zu senden: der Marker wird gesetzt, damit es kein * Wiederholen gibt. Wirft nie. * @param {object} doc portalFeatures-Dokument (disabled) - * @param {{ lage: { art: string }, now: Date, onGesendet?: () => void }} options onGesendet wird nach jedem versendeten K22 gerufen + * @param {{ lage?: { art: string } | null, now: Date, onGesendet?: () => void }} options onGesendet wird nach jedem versendeten K22 gerufen * @returns {Promise} true = nichts mehr offen (Marker gesetzt oder nichts zu melden), false = Versand oder Marker * gescheitert (der naechste Tag wiederholt) */ -export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) { +export async function meldeAboEnde(doc, { lage = null, now, onGesendet = null }) { const nowMs = pruefeNow(now, 'meldeAboEnde') const vorEinrichtung = doc.disabledReason === GRUND_VOR - if ((doc.disabledReason !== GRUND && !vorEinrichtung) || doc.aboEndeMailAt) return true // mit Marker ist alles gesagt + if (doc.disabledReason !== GRUND && !vorEinrichtung) return true if (vorEinrichtung && doc.unlockedByPurchase !== true) return true // nichts zu erstatten, nichts zu sagen: die Zahlung kommt eventuell noch + const erstattungOffen = vorEinrichtung && doc.zahlungMeldung !== 'gemeldet' + if (doc.aboEndeMailAt && !erstattungOffen) return true // mit Marker ist alles gesagt const setzeMarker = () => updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: new Date(nowMs).toISOString() }) let gesendet = false try { @@ -102,6 +120,7 @@ export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) { return false } } + if (doc.aboEndeMailAt) return true // K22 ging schon, hier fehlte nur T19 if (!customer?.email) { console.warn('[termine-abo] K22 entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, Marker wird gesetzt') await setzeMarker() @@ -186,6 +205,27 @@ async function bearbeiteDokument(doc, ctx) { if (!fertig) erg.fehler += 1 } +/** + * Schritt 2b, ein Dokument (R107): in der Liste bezahlt, abgeschaltet mit Grund 'abo_ende_vor_einrichtung' und T19 nicht gemeldet. + * Unter der Sperre feature-admin: frisch lesen (das Team kann inzwischen aktiviert haben, der Webhook T19 gemeldet), das + * Abo wird NICHT gelesen. Die Meldung (T19, danach K22 und Marker, meldeAboEnde) folgt nach der Sperre. Wirft nie. + * @returns {Promise} true = erledigt oder gescheitert (fehler gezaehlt, der naechste Tag wiederholt): das Dokument ist fuer + * diesen Lauf versorgt; false = nichts zu tun (die frische Fassung ist nicht mehr offen): der Abo-Pfad darf es noch bewerten + */ +async function holeErstattungNach(doc, ctx) { + const { erg, now } = ctx + try { + const frisch = await withLocks([`feature-admin:${doc.projectId}`], () => getDocument(portalFeatures(), doc.$id)) + if (!istErstattungOffen(frisch)) return false + const fertig = await meldeAboEnde(frisch, { now, onGesendet: () => (erg.mails += 1) }) + if (!fertig) erg.fehler += 1 + } catch (err) { + erg.fehler += 1 + console.error(`[termine-abo] Erstattung für Funktion ${doc.$id} nicht gemeldet, der nächste Lauf wiederholt: ${fehlerText(err)}`) + } + return true +} + /** * K23 an die E-Mail des Kundenkontos. Nur nach geschriebenem `active` gerufen, nie innerhalb einer Sperre. Scheitert der Versand, * bleibt die Reaktivierung bestehen: das Log (ohne Adresse) und `fehler` halten es fest; der Marker aboEndeMailAt ist mit dem @@ -245,17 +285,26 @@ async function bearbeiteKunde(customerId, dokumente, ctx) { const bewertet = dokumente.filter(istBewertet) if (bewertet.length === 0) return // nichts zu bewerten: das Abo wird nicht gelesen + // Schritt 2b (R107) VOR dem Abo: die Erstattung haengt nicht davon ab, ob das Abo lesbar ist oder wieder laeuft + const versorgt = new Set() + for (const doc of bewertet.filter(istErstattungOffen)) { + if (await holeErstattungNach(doc, ctx)) versorgt.add(doc) + } + erg.geprueft += versorgt.size + const uebrige = bewertet.filter((doc) => !versorgt.has(doc)) + if (uebrige.length === 0) return // alles versorgt: das Abo wird nicht gelesen + let lage try { lage = await ctx.memo.lage(customerId) } catch (err) { erg.fehler += 1 - console.error(`[termine-abo] Abo-Lage eines Kunden nicht lesbar, ${bewertet.length} Funktion(en) übersprungen: ${fehlerText(err)}`) + console.error(`[termine-abo] Abo-Lage eines Kunden nicht lesbar, ${uebrige.length} Funktion(en) übersprungen: ${fehlerText(err)}`) return } - erg.geprueft += bewertet.length + erg.geprueft += uebrige.length - for (const doc of bewertet) { + for (const doc of uebrige) { // laeuft das Abo, gibt es nur die Rueckkehr (Schritt 3); sonst abschalten, einrichten, nachholen (Schritte 1, 1b, 2) const bearbeite = lage.laeuft ? istRueckkehr(doc) && schalteWiederEin : aktionFuer(doc) && bearbeiteDokument if (!bearbeite) continue @@ -274,7 +323,7 @@ async function bearbeiteKunde(customerId, dokumente, ctx) { * @param {Date} now * @returns {Promise<{ geprueft: number, abgeschaltet: number, wiederEin: number, mails: number, ausgenommen: number, fehler: number }>} * geprueft = bewertete Dokumente (aktiv mit unlockedByPurchase true, ordered/setup, disabled mit Grund abo_ende*, deren Kunde - * lesbar war), abgeschaltet = in diesem Lauf abgeschaltet, wiederEin = in diesem Lauf wieder eingeschaltet (Schritt 3), mails = + * lesbar war; der Nachlauf der Erstattung, Schritt 2b, braucht das Abo nicht und zaehlt immer), abgeschaltet = in diesem Lauf abgeschaltet, wiederEin = in diesem Lauf wieder eingeschaltet (Schritt 3), mails = * versendete Kundenmails K22 und K23 (T19 geht ans Team und zaehlt nicht), ausgenommen = aktive Dokumente ohne Kauf (Pilot, Kulanz), * fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie ein gescheiterter K23-Versand (das Dokument ist dann * trotzdem wieder aktiv) diff --git a/test/termineAboAbgleich.test.js b/test/termineAboAbgleich.test.js index 23faa7a..c3d11a6 100644 --- a/test/termineAboAbgleich.test.js +++ b/test/termineAboAbgleich.test.js @@ -90,6 +90,9 @@ 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' }) @@ -340,7 +343,7 @@ test('Laeuft das Abo: ordered, setup und ein abgeschaltetes abo_ende_vor_einrich wartung('active') legeBestellung(1) legeAufbau(2) - legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() }) + 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) @@ -835,7 +838,7 @@ test('Review Focus 1, nie ohne Spur: das Ticket wird nach dem Abo-Ende geschloss }) test('abo_ende_vor_einrichtung-Dokument mit Abo-Rueckkehr: bleibt wie es ist (die Rueckkehr gilt nur fuer abo_ende)', async () => { - legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() }) + 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 })) @@ -865,6 +868,273 @@ test('Mehrere Projekte: bezahlter Aufbau, unbezahlte Bestellung und aktiver Term 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 () => { @@ -886,6 +1156,18 @@ test('meldeAboEnde direkt: bezahltes Dokument mit Grund abo_ende_vor_einrichtung 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) } @@ -1134,7 +1416,7 @@ test('Nicht automatisch: team, kunde_geloescht, ohne Grund und abo_ende_vor_einr 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, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: tag }) // bezahlt, Erstattung laeuft + 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)) }))