Terminplaner: Erstattungsmeldung unabhaengig vom Abo nachholen (R107)

Der taegliche Abo-Abgleich holt T19 fuer jedes bezahlte, abgeschaltete Dokument mit
Grund abo_ende_vor_einrichtung und zahlungMeldung ungleich gemeldet nach, auch wenn das
Wartungs-Abo wieder laeuft oder nicht lesbar ist (der Nachlauf liest das Abo nie).
Reihenfolge wie bisher: T19, dann K22 (nur wenn aboEndeMailAt leer), dann Marker.
meldeAboEnde sendet bei gesetztem K22-Marker und offener Erstattung nur T19.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 17:31:54 +00:00
1 parent 13523ca5e2
commit 1c74c89feb
2 files changed
+342 -11

No files matched your search

+285 -3
View File
@@ -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_<nr> 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:<projectId> (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:<projectId> 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)) }))