Zahlung nach Abo-Ende: Zusatztests Webhook und Abgleich (T19 nie doppelt)

Nur Tests in test/termineAboAbgleich.test.js, kein Produktionscode: handleFeatureCheckoutEvent
und gleicheAboAb gegeneinander (Review Focus 1, nie Geld ohne Spur, nie doppelt).
- Zahlung nach dem Abschalten: genau ein T19 (zahlung_nach_ende, Sitzung), kein Ticket,
  kein K15/T16; Wiederholung und async_payment_succeeded stumm
- T19-Versand scheitert: Webhook wirft, Zahlung gebucht, Fall offen; erste Wiederholung
  mit funktionierendem Versand sendet genau ein T19
- Folgetag-Abgleich: K22 vor_einrichtung, Marker aboEndeMailAt, kein zweites T19
- Abgleich vor der Webhook-Wiederholung und parallel (Promise.all): insgesamt ein T19
- Grund team: T16 abgeschaltet, kein T19; Abo laeuft (ordered/setup): Ticket, K15, T16
- Rennen Zahlung vor Abgleich: Ticket, T16 und ein T19; Stripe-Retry stumm

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 18:29:10 +00:00
1 parent c5009da570
commit d66dd60a76
1 file changed
+341 -1
+341 -1
View File
@@ -5,7 +5,7 @@ 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): aktive Terminplaner ohne laufendes
// 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).
@@ -1887,3 +1887,343 @@ test('Sofortpfad: wartet auf die Sperre feature-admin des Projekts; T19 und K22
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)
})