Abo-Ende: Sofortpfad beim Widerruf des Wechsels, Team-Aktivierung nur mit laufendem Abo

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 18:19:48 +00:00
1 parent 6bd6aed7d5
commit c5009da570
6 files changed
+726 -10

No files matched your search

+295 -1
View File
@@ -28,7 +28,7 @@ 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 } = await import('../server/services/termine/aboAbgleich.js')
const { gleicheAboAb, meldeAboEnde, endeTermineFuerKunde } = await import('../server/services/termine/aboAbgleich.js')
after(() => setMailTransportForTests(null))
const TAG = 24 * 60 * 60 * 1000
@@ -1593,3 +1593,297 @@ test('Wechsel widerrufen: aktiv => abgeschaltet (K22 hosting), abgeschaltet blei
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))
})