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:
1 parent
6bd6aed7d5
commit
c5009da570
6 files changed
+726
-10
No files matched your search
+205
-5
@@ -29,6 +29,8 @@ const { listContractEvents, recordContractEvent } = await import('../server/serv
|
||||
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { gleicheAboAb } = await import('../server/services/termine/aboAbgleich.js')
|
||||
const { handleFeatureCheckoutEvent } = await import('../server/services/siteFeatures.js')
|
||||
|
||||
const { analyseDeps } = await import('../server/services/analyse.js')
|
||||
const app = await startTestApp({ '/api/abo': aboRoutes })
|
||||
@@ -445,6 +447,199 @@ test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Pre
|
||||
assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/)
|
||||
})
|
||||
|
||||
// Terminplaner (Dokument wf_termine) gehoert zum Wartungs-Abo: der Widerruf des Wechsels beendet ihn sofort (E9, endeTermineFuerKunde)
|
||||
const TERMINE = 'wf_termine'
|
||||
const termine = () => aw.docs('portalFeatures').get(TERMINE)
|
||||
const k22 = () => mails.sent.filter((m) => /^Dein Terminplaner wurde beendet/.test(m.subject))
|
||||
const k23 = () => mails.sent.filter((m) => /^Dein Terminplaner läuft wieder/.test(m.subject))
|
||||
const t19 = () => mails.sent.filter((m) => m.subject.startsWith('[Abo] Erstattung fällig'))
|
||||
const aufraeumen = async () => {
|
||||
for (const id of [TERMINE, 'wf_feat']) await deleteDocument(config.collections.portalFeatures, id).catch(() => {})
|
||||
await deleteDocument('workorders', 't_wf').catch(() => {})
|
||||
}
|
||||
/** Fuehrt fn mit einem Terminplaner-Dokument (Vorgabe: aktiv, bezahlt) von c1 aus und raeumt danach auf (andere Tests sehen es nie). */
|
||||
async function mitTermine(over, fn) {
|
||||
aw.seed('portalFeatures', {
|
||||
$id: TERMINE, customerId: 'c1', projectId: 'wf_pt', featureKey: 'termine', state: 'active', enabled: true,
|
||||
unlockedByPurchase: true, purchaseStatus: 'paid', siteKey: 'wftermine01234567', ...over,
|
||||
})
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await aufraeumen()
|
||||
}
|
||||
}
|
||||
/** Bezahlter Aufbau: setup, Sitzung cs_test_1, Rechnungslink, offenes Ticket WO-4711. */
|
||||
const AUFBAU = { state: 'setup', enabled: false, stripeSessionId: 'cs_test_1', stripeInvoiceId: 'in_1', invoiceUrl: 'https://invoice.stripe.test/in_1', ticketId: 't_wf' }
|
||||
const legeTicket = () => aw.seed('workorders', { $id: 't_wf', woid: 'WO-4711', status: 'Open', topic: 'Terminplaner einbauen' })
|
||||
const T19_BETREFF = '[Abo] Erstattung fällig: Terminplaner-Einrichtung'
|
||||
/** Der Spiegel steht auf Wartung (so war es nach dem Wechsel): der Tagesabgleich darf den Widerruf nur ueber die Wechsel-Regel kennen. */
|
||||
const wartungSpiegel = () => resetSubH({ units: 0, patch: { productKey: 'wartung' } })
|
||||
|
||||
test('Widerruf Wechsel in der Frist beendet den bezahlten Terminplaner (disabled/abo_ende, K22 Hosting); die Analyse verhält sich wie zuvor', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await withActiveAnalyse(async (stops) => {
|
||||
await mitTermine({}, async () => {
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const d = termine()
|
||||
assert.deepEqual([d.state, d.enabled, d.disabledReason, d.adminNote], ['disabled', false, 'abo_ende', 'Abgeschaltet: Wechsel widerrufen'])
|
||||
assert.ok(Math.abs(Date.parse(d.disabledAt) - Date.now()) < 10000)
|
||||
assert.ok(d.aboEndeMailAt, 'Marker nach K22')
|
||||
assert.equal(k22().length, 1)
|
||||
assert.deepEqual(k22()[0].to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.match(k22()[0].text, /weil Dein Abo ein Hosting-Abo ist/)
|
||||
assert.equal(t19().length, 0)
|
||||
// Analyse wie bisher: Messdienst aus, off, K19
|
||||
assert.deepEqual(stops, ['wfsitekey0123456789'])
|
||||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'off')
|
||||
assert.ok(bySubject('Deine Website-Analyse wurde beendet'))
|
||||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||||
assert.ok(t09() && k04(), 'K04 und T09 wie immer')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel: scheitert die Abschaltung des Terminplaners, bleibt der Widerruf 200; der Tagesabgleich holt es über die Wechsel-Regel nach', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await mitTermine({}, async () => {
|
||||
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||||
assert.ok((await events('widerruf'))[0], 'Widerrufsereignis gespeichert')
|
||||
assert.equal(termine().state, 'active', 'nicht abgeschaltet')
|
||||
assert.equal(k22().length, 0)
|
||||
assert.ok(log.mock.calls.some((c) => /Sofortpfad/.test(String(c.arguments[0]))), 'der Fehler steht im Log')
|
||||
assert.ok(k04() && t09())
|
||||
// der Abgleich am Folgetag schaltet ab (Spiegel Wartung, aber der Wechsel ist widerrufen) und sendet K22
|
||||
const lauf = await gleicheAboAb(new Date())
|
||||
assert.equal(lauf.abgeschaltet, 1)
|
||||
assert.equal(termine().disabledReason, 'abo_ende')
|
||||
assert.equal(k22().length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel: Pilot- und Kulanz-Terminplaner (ohne Kauf) bleiben aktiv', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await mitTermine({ unlockedByPurchase: false, purchaseStatus: '' }, async () => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal(termine().state, 'active')
|
||||
assert.equal(k22().length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel, bezahlter Aufbau: abo_ende_vor_einrichtung, genau ein T19 (Ticket offen), K22 vor_einrichtung, Marker, gemeldet; der Abgleich sendet danach nichts mehr', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
legeTicket()
|
||||
await mitTermine(AUFBAU, async () => {
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const d = termine()
|
||||
assert.deepEqual([d.state, d.disabledReason, d.purchaseStatus, d.zahlungMeldung, d.stripeSessionId], ['disabled', 'abo_ende_vor_einrichtung', 'paid', 'gemeldet', 'cs_test_1'])
|
||||
assert.ok(d.aboEndeMailAt)
|
||||
assert.equal(t19().length, 1)
|
||||
assert.match(t19()[0].text, /Ticket noch offen/)
|
||||
assert.match(t19()[0].text, /WO-4711/)
|
||||
assert.match(t19()[0].text, /cs_test_1/)
|
||||
assert.equal(k22().length, 1)
|
||||
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
|
||||
assert.deepEqual(mails.sent.filter((m) => m.subject === T19_BETREFF || /^Dein Terminplaner wurde beendet/.test(m.subject)).map((m) => m.subject === T19_BETREFF), [true, false], 'T19 vor K22')
|
||||
assert.equal(aw.docs('workorders').get('t_wf').status, 'Open', 'das Ticket bleibt')
|
||||
|
||||
const vorher = { ...termine() }
|
||||
const anzahl = mails.sent.length
|
||||
const lauf = await gleicheAboAb(new Date(Date.now() + DAY_MS))
|
||||
assert.deepEqual(lauf, { geprueft: 1, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 })
|
||||
assert.deepEqual(termine(), vorher)
|
||||
assert.equal(mails.sent.length, anzahl, 'weder T19 noch K22 noch K23')
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel, unbezahltes ordered: abgeschaltet ohne T19, K22 und Marker; die spätere Zahlung meldet T19 genau einmal, der Abgleich K22', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await mitTermine({ state: 'ordered', enabled: false, unlockedByPurchase: false, purchaseStatus: 'ordered', stripeSessionId: 'cs_test_1' }, async () => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
let d = termine()
|
||||
assert.deepEqual([d.state, d.disabledReason, d.stripeSessionId], ['disabled', 'abo_ende_vor_einrichtung', 'cs_test_1'])
|
||||
assert.ok(!d.aboEndeMailAt && !d.zahlungMeldung, 'kein Marker')
|
||||
assert.equal(k22().length + t19().length, 0)
|
||||
|
||||
await handleFeatureCheckoutEvent({
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: 'c1', projectId: 'wf_pt', featureKey: 'termine' } } },
|
||||
})
|
||||
assert.equal(t19().length, 1)
|
||||
assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/)
|
||||
assert.equal(k22().length, 0)
|
||||
const lauf = await gleicheAboAb(new Date(Date.now() + DAY_MS))
|
||||
assert.equal(lauf.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.equal(termine().state, 'disabled')
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel, bezahlter Aufbau: scheitert T19, bleibt der Widerruf 200, K22 und Marker fehlen; der Abgleich am Folgetag sendet T19 und K22 nach', async (t) => {
|
||||
t.mock.method(console, 'error', () => {})
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
legeTicket()
|
||||
await mitTermine(AUFBAU, async () => {
|
||||
setMailTransportForTests({
|
||||
sendMail: async (mail) => {
|
||||
if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen')
|
||||
return mails.sendMail(mail)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
} finally {
|
||||
setMailTransportForTests(mails)
|
||||
}
|
||||
const d = termine()
|
||||
assert.deepEqual([d.state, d.disabledReason], ['disabled', 'abo_ende_vor_einrichtung'])
|
||||
assert.ok(!d.aboEndeMailAt && !d.zahlungMeldung)
|
||||
assert.equal(t19().length + k22().length, 0)
|
||||
assert.ok(k04() && t09(), 'K04 und T09 gehen trotzdem')
|
||||
|
||||
const lauf = await gleicheAboAb(new Date(Date.now() + DAY_MS))
|
||||
assert.equal(lauf.mails, 1)
|
||||
assert.equal(t19().length, 1)
|
||||
assert.equal(k22().length, 1)
|
||||
assert.equal(termine().zahlungMeldung, 'gemeldet')
|
||||
assert.ok(termine().aboEndeMailAt)
|
||||
})
|
||||
})
|
||||
|
||||
test('Kein Flattern: nach dem Sofortpfad (Spiegel Wartung/active, Wechsel widerrufen) bleibt der Terminplaner abgeschaltet, kein K23, kein zweites K22', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await mitTermine({}, async () => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal(termine().state, 'disabled')
|
||||
assert.equal(k22().length, 1)
|
||||
assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'wartung')
|
||||
assert.equal(aw.docs('subscriptions').get('sub_h').status, 'active')
|
||||
const vorher = { ...termine() }
|
||||
for (const tage of [1, 2, 40]) {
|
||||
const lauf = await gleicheAboAb(new Date(Date.now() + tage * DAY_MS))
|
||||
assert.equal(lauf.wiederEin + lauf.abgeschaltet + lauf.mails + lauf.fehler, 0, `Tag ${tage}`)
|
||||
}
|
||||
assert.deepEqual(termine(), vorher)
|
||||
assert.equal(k23().length, 0)
|
||||
assert.equal(k22().length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
// Analyse hat nur mit Wartungs-Abo Bestand: der Widerruf des Wechsels beendet sie (Messdienst aus, K19)
|
||||
async function withActiveAnalyse(fn) {
|
||||
const stops = []
|
||||
@@ -489,14 +684,19 @@ test('Widerruf Wechsel in der Frist beendet die Analyse des Kunden (stopSite, of
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse nicht', async () => {
|
||||
test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse und den Terminplaner nicht', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
await wechselIn(30)
|
||||
await withActiveAnalyse(async (stops) => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
|
||||
assert.deepEqual(stops, [])
|
||||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active')
|
||||
await mitTermine({}, async () => {
|
||||
const vorher = { ...termine() }
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
|
||||
assert.deepEqual(stops, [])
|
||||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active')
|
||||
assert.deepEqual(termine(), vorher, 'Terminplaner unveraendert')
|
||||
assert.equal(k22().length + t19().length, 0, 'weder K22 noch T19')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user