Wechsel: Lastschrift-Fehler nur für den echten Wechsel, Abgleich ohne Webhook, Gutschrift netto, Skript prüft Stripe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc20ce0673
commit
662f8d8144
7 files changed
+528
-76
No files matched your search
+264
-12
@@ -23,9 +23,9 @@ const { startTestApp, get, post, postWebhook } = await import('./support/testApp
|
||||
const { hostingDoc, hosting2Doc, consent, customerCookie, viewAsCookie, seedFixtures, stripeSubscription, subscriptionDoc } =
|
||||
await import('./support/fixtures.js')
|
||||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||||
const { previewSwitch, switchToWartung, markSwitchFailed, previewProblem, paymentMethodTypeOf, SWITCH_FAILED_REASONS } =
|
||||
const { previewSwitch, switchToWartung, markSwitchFailed, previewProblem, paymentMethodTypeOf, reconcilePendingSwitch, SWITCH_FAILED_REASONS } =
|
||||
await import('../server/services/planSwitch.js')
|
||||
const { listContractEvents, recordContractEvent } = await import('../server/services/contractEvents.js')
|
||||
const { listContractEvents, recordContractEvent, updateContractEvent } = await import('../server/services/contractEvents.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { addMonths } = await import('../server/services/stripeBilling.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
@@ -265,7 +265,12 @@ test('Wechsel: Stripe-Aufruf mit Anker jetzt, Metadaten, Ereignis', async () =>
|
||||
assert.equal(ev.details.effectiveAt, switchedAt)
|
||||
// Mindestlaufzeit des Hosting-Abos vor dem Wechsel (ohne termStartAt: Abo-Start) und alte Metadaten
|
||||
assert.equal(ev.details.previousTermStartAt, hostingDoc.startedAt)
|
||||
assert.deepEqual(ev.details.previousMetadata, { productKey: 'hosting', termStartAt: null, switchedAt: null, switchedFrom: null })
|
||||
// Fix 1 (7): auch die Zustimmungsdaten des Hosting-Abschlusses, damit eine gescheiterte Lastschrift sie wiederherstellt
|
||||
assert.deepEqual(ev.details.previousMetadata, {
|
||||
productKey: 'hosting', termStartAt: null, switchedAt: null, switchedFrom: null,
|
||||
termsVersion: config.abo.termsVersion, termsAcceptedAt: consent.consentAt, earlyStartConsentAt: consent.consentAt,
|
||||
consentIp: '127.0.0.1', consentUserAgent: 'test',
|
||||
})
|
||||
|
||||
// Spiegel: Wartungs-Abo, Mindestlaufzeit ab dem Wechseltag
|
||||
const doc = aw.docs('subscriptions').get('sub_h')
|
||||
@@ -562,14 +567,15 @@ test('Route: ohne earlyStart 400', async () => {
|
||||
test('Routen: fehlende Angaben 400, alte Fassung 409, ohne Session 401, „Als Kunde ansehen“ 403, ohne Stripe-Aufruf', async () => {
|
||||
await resetHosting()
|
||||
const cookie = customerCookie()
|
||||
const body = { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix() }
|
||||
const body = { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }
|
||||
const before = st.calls.length
|
||||
for (const missing of ['acceptTerms', 'earlyStart', 'termsVersion', 'prorationDate']) {
|
||||
for (const missing of ['acceptTerms', 'earlyStart', 'termsVersion', 'prorationDate', 'amountDueCents']) {
|
||||
const { [missing]: _drop, ...rest } = body
|
||||
assert.equal((await post('/api/abo/switch', rest, { cookie })).status, 400, missing)
|
||||
}
|
||||
assert.equal((await post('/api/abo/switch', { ...body, earlyStart: 'ja' }, { cookie })).status, 400)
|
||||
assert.equal((await post('/api/abo/switch', { ...body, prorationDate: String(body.prorationDate) }, { cookie })).status, 400)
|
||||
assert.equal((await post('/api/abo/switch', { ...body, amountDueCents: '4100' }, { cookie })).status, 400)
|
||||
const stale = await post('/api/abo/switch', { ...body, termsVersion: '2026-09-25-v3' }, { cookie })
|
||||
assert.equal(stale.status, 409)
|
||||
assert.match(stale.body.error, /Bedingungen/)
|
||||
@@ -599,10 +605,12 @@ test('Route Vorschau: Beträge; Wartungs-Abo 409', async () => {
|
||||
|
||||
test('Route Wechsel: Antwort mit Abo-Ansicht und paymentUrl, Zustimmung aus der Anfrage', async () => {
|
||||
await resetHosting()
|
||||
// Fix 1 (8): der Server rechnet die Vorschau nach; hier dieselben 41,00 € wie in der Bestelluebersicht
|
||||
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
|
||||
st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ status: 'open', hosted_invoice_url: 'https://pay.example/route' }) }
|
||||
const res = await post(
|
||||
'/api/abo/switch',
|
||||
{ acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix() },
|
||||
{ acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 },
|
||||
{ cookie: customerCookie(), headers: { 'user-agent': 'Testbrowser/1.0' } }
|
||||
)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
@@ -621,14 +629,14 @@ test('Route Wechsel: Antwort mit Abo-Ansicht und paymentUrl, Zustimmung aus der
|
||||
// Karte, sofort bezahlt: Antwort zeigt das Wartungs-Abo mit Widerrufsfrist fuer den Wechsel
|
||||
await resetHosting()
|
||||
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
|
||||
const done = await post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix() }, { cookie: customerCookie() })
|
||||
const done = await post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }, { cookie: customerCookie() })
|
||||
assert.equal(done.status, 200, JSON.stringify(done.body))
|
||||
assert.equal(done.body.paymentUrl, null)
|
||||
assert.equal(done.body.subscription.productKey, 'wartung')
|
||||
assert.equal(done.body.subscription.switchPending, null)
|
||||
assert.deepEqual(done.body.subscription.withdrawals.map((w) => w.type), ['wechsel'])
|
||||
// Wechsel geht nur einmal: jetzt Wartung -> 409
|
||||
const again = await post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix() }, { cookie: customerCookie() })
|
||||
const again = await post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }, { cookie: customerCookie() })
|
||||
assert.equal(again.status, 409)
|
||||
})
|
||||
|
||||
@@ -673,6 +681,7 @@ test('Webhook pending_update_applied: Ereignis erledigt, Spiegel Wartung, T12; W
|
||||
|
||||
test('Webhook pending_update_expired: Ereignis gescheitert, K14 an den Kunden, T12 gescheitert; Hosting bleibt', async () => {
|
||||
await pendingCardSwitch()
|
||||
st.expirePendingUpdate('sub_h')
|
||||
const event = webhookEvent('customer.subscription.pending_update_expired', { id: 'sub_h', object: 'subscription' })
|
||||
const res = await postWebhook(event)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
@@ -754,8 +763,8 @@ test('Webhook invoice.paid vor pending_update_applied (Karte): erledigt und T12
|
||||
|
||||
test('Webhook invoice.paid, solange Stripe die Änderung noch zurückhält: Ereignis bleibt offen', async () => {
|
||||
await pendingCardSwitch('in_noch_offen')
|
||||
// Stripe hat die ausstehende Aenderung noch nicht uebernommen (pending_update am Abo)
|
||||
st.subscriptions.get('sub_h').pending_update = pendingUpdate()
|
||||
// Stripe hat die ausstehende Aenderung noch nicht uebernommen: der Nachbau zeigt pending_update am Abo (Fix 1, 6)
|
||||
assert.ok(st.subscriptions.get('sub_h').pending_update)
|
||||
st.invoices.set('in_noch_offen', paidInvoice('in_noch_offen'))
|
||||
try {
|
||||
const paid = webhookEvent('invoice.paid', { id: 'in_noch_offen', object: 'invoice', billing_reason: 'subscription_update', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } })
|
||||
@@ -780,7 +789,7 @@ test('Webhook invoice.payment_failed: SEPA-Wechselrechnung → T12 mit Handgriff
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
|
||||
assert.ok(t12, 'T12 mit Handgriffen')
|
||||
for (const text of ['SEPA-Lastschrift für die Wechselrechnung ist gescheitert', 'stornieren', 'ohne anteilige Verrechnung', 'Der Abbuchungstag bleibt der Wechseltag', 'termStartAt, switchedAt und switchedFrom', 'node scripts/wechsel-gescheitert.mjs sub_h --ausfuehren', '41,00 €']) {
|
||||
for (const text of ['SEPA-Lastschrift für die Wechselrechnung ist gescheitert', 'stornieren', 'ohne anteilige Verrechnung', 'Der Abbuchungstag bleibt der Wechseltag', 'termStartAt, switchedAt, switchedFrom und die Zustimmungsdaten', 'node scripts/wechsel-gescheitert.mjs sub_h --ausfuehren', '41,00 €']) {
|
||||
assert.ok(t12.text.includes(text), `${text}\n---\n${t12.text}`)
|
||||
}
|
||||
assert.ok(!t12.text.includes(WIRKSAM))
|
||||
@@ -838,10 +847,27 @@ test('markSwitchFailed: gescheitert, K14 (SEPA: Abbuchungstag bleibt), T12; zwei
|
||||
await assert.rejects(markSwitchFailed('gibt-es-nicht', 'x'), (e) => e.status === 404)
|
||||
})
|
||||
|
||||
test('Skript wechsel-gescheitert: Aufruf, prüfen ohne Änderung, ausführen', async () => {
|
||||
/** Handgriffe des Teams in Stripe nach einer gescheiterten Lastschrift (T12): Rechnung stornieren, Preis und Metadaten zurueck. */
|
||||
function revertAtStripe(event, { voidInvoice = true, price = true, metadata = true } = {}) {
|
||||
const sub = st.subscriptions.get(event.subscriptionId)
|
||||
if (voidInvoice) st.invoices.set(event.details.invoiceId, { id: event.details.invoiceId, object: 'invoice', status: 'void', lines: { data: [] } })
|
||||
if (price) sub.items.data = sub.items.data.map((item) => (item.price.id === 'price_wartung' ? { ...item, price: st.prices.get('price_hosting') } : item))
|
||||
if (metadata) {
|
||||
for (const [key, value] of Object.entries(event.details.previousMetadata)) {
|
||||
if (value === null) delete sub.metadata[key]
|
||||
else sub.metadata[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('Skript wechsel-gescheitert: Aufruf, prüfen zeigt den Stripe-Stand, ausführen erst nach den Handgriffen', async () => {
|
||||
assert.deepEqual(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--pruefen']), { subscriptionId: 'sub_h', mode: 'pruefen' })
|
||||
assert.deepEqual(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren']), { subscriptionId: 'sub_h', mode: 'ausfuehren', grund: SWITCH_FAILED_REASONS.lastschrift })
|
||||
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren', 'Konto', 'erloschen']).grund, 'Konto erloschen')
|
||||
// Fix 1 (5): kein Schalter im Grund (Tippfehler wie "--pruefen" landet sonst in K14), Grund hoechstens 300 Zeichen
|
||||
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren', '--pruefen']), null)
|
||||
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren', 'Grund', '--force']), null)
|
||||
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren', 'x'.repeat(400)]).grund.length, 300)
|
||||
for (const args of [['sub_h'], ['--pruefen'], ['sub_h', '--anders'], ['cus_1', '--pruefen'], ['sub_h', '--pruefen', 'zu viel'], []]) {
|
||||
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', ...args]), null, args.join(' '))
|
||||
}
|
||||
@@ -849,6 +875,7 @@ test('Skript wechsel-gescheitert: Aufruf, prüfen ohne Änderung, ausführen', a
|
||||
await resetHosting({ paymentMethod: SEPA })
|
||||
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ status: 'open' }) }
|
||||
await switchToWartung('c1', consent, nowUnix())
|
||||
st.invoices.set('in_wechsel', { id: 'in_wechsel', object: 'invoice', status: 'open', lines: { data: [] } })
|
||||
const [open] = await listContractEvents('c1')
|
||||
mails.sent.length = 0
|
||||
const out = []
|
||||
@@ -858,10 +885,31 @@ test('Skript wechsel-gescheitert: Aufruf, prüfen ohne Änderung, ausführen', a
|
||||
assert.ok(printed.includes(open.$id), printed)
|
||||
assert.ok(printed.includes('productKey = hosting'))
|
||||
assert.ok(printed.includes('termStartAt = (löschen)'))
|
||||
assert.ok(printed.includes('consentUserAgent = test'))
|
||||
// Stand bei Stripe: Rechnung offen, Preis Wartung, Metadaten des Wechsels
|
||||
assert.match(printed, /Rechnung in_wechsel: open/)
|
||||
assert.match(printed, /Abo-Position: wartung/)
|
||||
assert.match(printed, /productKey: jetzt wartung/)
|
||||
assert.ok(printed.includes('--ausfuehren'))
|
||||
assert.equal((await listContractEvents('c1'))[0].status, 'offen', 'nur geprueft')
|
||||
assert.deepEqual(mails.sent, [])
|
||||
|
||||
// Ohne die Handgriffe in Stripe: kein K14, Liste der fehlenden Schritte
|
||||
out.length = 0
|
||||
const early = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'ausfuehren', grund: SWITCH_FAILED_REASONS.lastschrift }, (line) => out.push(line))
|
||||
assert.equal(early.ok, false)
|
||||
assert.deepEqual(early.missing.length, 3, out.join('\n'))
|
||||
assert.match(out.join('\n'), /stornieren/)
|
||||
assert.equal((await listContractEvents('c1'))[0].status, 'offen')
|
||||
assert.deepEqual(mails.sent, [])
|
||||
// Nur teilweise erledigt (Metadaten fehlen noch)
|
||||
revertAtStripe(open, { metadata: false })
|
||||
const partial = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'ausfuehren', grund: SWITCH_FAILED_REASONS.lastschrift }, () => {})
|
||||
assert.equal(partial.ok, false)
|
||||
assert.equal(partial.missing.length, 1)
|
||||
assert.deepEqual(mails.sent, [])
|
||||
|
||||
revertAtStripe(open)
|
||||
const done = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'ausfuehren', grund: SWITCH_FAILED_REASONS.lastschrift }, () => {})
|
||||
assert.equal(done.ok, true)
|
||||
const [ev] = await listContractEvents('c1')
|
||||
@@ -875,3 +923,207 @@ test('Skript wechsel-gescheitert: Aufruf, prüfen ohne Änderung, ausführen', a
|
||||
assert.match(out.join('\n'), /Kein offener Wechsel/)
|
||||
assert.equal((await runFailedSwitchScript({ subscriptionId: 'sub_unbekannt', mode: 'pruefen' }, () => {})).ok, false)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix round 1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const paymentFailed = (invoiceId, subscription = 'sub_h') =>
|
||||
webhookEvent('invoice.payment_failed', {
|
||||
id: invoiceId, object: 'invoice', billing_reason: 'subscription_update', amount_due: 4100, customer_email: 'k@example.invalid',
|
||||
parent: { type: 'subscription_details', subscription_details: { subscription } },
|
||||
})
|
||||
const t12Count = () => mailsWithSubject(t12Subject('k@example.invalid')).length
|
||||
|
||||
// Fix 1 (1): T12 "Lastschrift gescheitert" nur fuer den echten Wechsel: offenes Ereignis, SEPA, wirksam, dieselbe Rechnung
|
||||
test('Lastschrift gescheitert: nur für die Wechselrechnung eines offenen SEPA-Wechsels, einmal je Wechsel', async () => {
|
||||
await resetHosting({ paymentMethod: SEPA })
|
||||
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_sepa_x', status: 'open' }) }
|
||||
await switchToWartung('c1', consent, nowUnix())
|
||||
mails.sent.length = 0
|
||||
// Andere Rechnung desselben Abos: kein T12
|
||||
assert.equal((await postWebhook(paymentFailed('in_andere'))).status, 200)
|
||||
assert.equal(t12Count(), 0)
|
||||
// Die Wechselrechnung: T12, Ereignis merkt sich den Fehlschlag
|
||||
assert.equal((await postWebhook(paymentFailed('in_sepa_x'))).status, 200)
|
||||
assert.equal(t12Count(), 1)
|
||||
const [ev] = await listContractEvents('c1')
|
||||
assert.equal(ev.status, 'offen')
|
||||
assert.ok(ev.details.lastschriftFailedAt)
|
||||
// Wiederholung durch Stripe (oder erneuter Einzugsversuch): kein zweites T12
|
||||
assert.equal((await postWebhook(paymentFailed('in_sepa_x'))).status, 200)
|
||||
assert.equal(t12Count(), 1)
|
||||
|
||||
// Wechsel schon erledigt (bezahlt): kein T12, auch nicht ueber den Metadaten-Rueckfall
|
||||
await resetHosting({ paymentMethod: SEPA })
|
||||
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_sepa_y', status: 'paid' }) }
|
||||
await switchToWartung('c1', consent, nowUnix())
|
||||
assert.equal((await listContractEvents('c1'))[0].status, 'erledigt')
|
||||
mails.sent.length = 0
|
||||
await postWebhook(paymentFailed('in_sepa_y'))
|
||||
assert.equal(t12Count(), 0)
|
||||
|
||||
// Kartenkunde, Wechsel wirksam, Rechnung noch offen (kein pending_update): kein T12
|
||||
await resetHosting({ paymentMethod: CARD })
|
||||
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_karte_x', status: 'open' }) }
|
||||
await switchToWartung('c1', consent, nowUnix())
|
||||
const [cardEvent] = await listContractEvents('c1')
|
||||
assert.equal(cardEvent.status, 'offen')
|
||||
assert.equal(cardEvent.details.paymentMethod, 'card')
|
||||
mails.sent.length = 0
|
||||
await postWebhook(paymentFailed('in_karte_x'))
|
||||
assert.equal(t12Count(), 0)
|
||||
})
|
||||
|
||||
test('Lastschrift gescheitert ohne gespeichertes Ereignis: Rückfall über die Metadaten, mit Hinweis auf den fehlenden Nachweis', async () => {
|
||||
await resetHosting({ paymentMethod: SEPA })
|
||||
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_ohne', status: 'open' }) }
|
||||
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
|
||||
await withCapturedLogs(() => switchToWartung('c1', consent, nowUnix()))
|
||||
assert.deepEqual(await listContractEvents('c1'), [])
|
||||
mails.sent.length = 0
|
||||
await postWebhook(paymentFailed('in_ohne'))
|
||||
const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
|
||||
assert.ok(t12.text.includes('SEPA-Lastschrift für die Wechselrechnung ist gescheitert'))
|
||||
assert.ok(t12.text.includes('Nachweis nicht gespeichert'))
|
||||
})
|
||||
|
||||
// Fix 1 (2): kommt pending_update_expired (oder _applied) nie an, gleicht das Lesen des Abos den Wechsel ab
|
||||
async function staleCardSwitch() {
|
||||
await pendingCardSwitch()
|
||||
const [ev] = await listContractEvents('c1')
|
||||
assert.ok(ev.details.pendingExpiresAt, 'Ablauf der ausstehenden Aenderung gespeichert')
|
||||
await updateContractEvent(ev.$id, { details: { pendingExpiresAt: new Date(Date.now() - 60000).toISOString() } })
|
||||
return ev
|
||||
}
|
||||
|
||||
test('Abgleich ohne Webhook: abgelaufene Zahlung → gescheitert und K14 beim Lesen von GET /api/abo', async () => {
|
||||
await staleCardSwitch()
|
||||
st.expirePendingUpdate('sub_h')
|
||||
const view = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.equal(view.subscription.switchPending, null)
|
||||
assert.equal(view.subscription.canSwitch, true)
|
||||
const [ev] = await listContractEvents('c1')
|
||||
assert.equal(ev.status, 'gescheitert')
|
||||
assert.equal(ev.details.grund, SWITCH_FAILED_REASONS.abgelaufen)
|
||||
assert.equal(mailsWithSubject(K14_SUBJECT).length, 1)
|
||||
// Ein spaeter noch eintreffender Webhook aendert nichts mehr
|
||||
await postWebhook(webhookEvent('customer.subscription.pending_update_expired', { id: 'sub_h', object: 'subscription' }))
|
||||
assert.equal(mailsWithSubject(K14_SUBJECT).length, 1)
|
||||
})
|
||||
|
||||
test('Abgleich ohne Webhook: bezahlt, Stripe hat übernommen → erledigt, T12, Spiegel Wartung', async () => {
|
||||
const stale = await staleCardSwitch()
|
||||
st.applyPendingUpdate('sub_h')
|
||||
// Rueckfall ohne gespeicherten Ablauf: 23 Stunden nach dem Wechsel
|
||||
await updateContractEvent(stale.$id, { details: { pendingExpiresAt: null, switchedAt: new Date(Date.now() - 24 * 3600000).toISOString() } })
|
||||
const view = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.equal(view.subscription.productKey, 'wartung')
|
||||
assert.equal(view.subscription.switchPending, null)
|
||||
assert.equal((await listContractEvents('c1'))[0].status, 'erledigt')
|
||||
assert.equal(t12Count(), 1)
|
||||
assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'wartung')
|
||||
})
|
||||
|
||||
test('Abgleich: vor Ablauf kein Stripe-Aufruf; Stripe-Fehler bleibt im Log, die Seite lädt', async () => {
|
||||
await pendingCardSwitch()
|
||||
const before = st.calls.length
|
||||
const view = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.ok(view.subscription.switchPending)
|
||||
assert.deepEqual(st.calls.slice(before).filter((c) => c.path === '/v1/subscriptions/sub_h'), [], 'kein Abruf vor Ablauf')
|
||||
|
||||
const [ev] = await listContractEvents('c1')
|
||||
await updateContractEvent(ev.$id, { details: { pendingExpiresAt: new Date(Date.now() - 60000).toISOString() } })
|
||||
st.failNext({ method: 'GET', path: '/v1/subscriptions/sub_h', status: 400 })
|
||||
let stillOpen
|
||||
const logs = await withCapturedLogs(async () => {
|
||||
stillOpen = await get('/api/abo', { cookie: customerCookie() })
|
||||
})
|
||||
assert.ok(stillOpen.subscription.switchPending)
|
||||
assert.equal((await listContractEvents('c1'))[0].status, 'offen')
|
||||
assert.ok(logs.error.some((e) => e.includes('Abgleich')), logs.error.join('\n'))
|
||||
})
|
||||
|
||||
test('Abgleich in Vorschau und Wechsel: ein abgelaufener Wechsel sperrt den neuen nicht', async () => {
|
||||
await staleCardSwitch()
|
||||
st.expirePendingUpdate('sub_h')
|
||||
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
|
||||
const p = await previewSwitch('c1')
|
||||
assert.equal(p.amountDueCents, 4100)
|
||||
assert.equal((await listContractEvents('c1'))[0].status, 'gescheitert')
|
||||
})
|
||||
|
||||
// Fix 1 (3): Gutschrift netto nach Rabatt, damit die Uebersicht aufgeht
|
||||
test('Vorschau mit Rabatt: Gutschrift abzüglich des Rabatts auf der Gutschriftzeile', async () => {
|
||||
await resetHosting()
|
||||
const credit = { ...line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), discount_amounts: [{ amount: -450, discount: 'di_1' }] }
|
||||
const debit = { ...line('price_wartung', 5000), discount_amounts: [{ amount: 2500, discount: 'di_1' }] }
|
||||
st.invoicePreview = { object: 'invoice', lines: { object: 'list', data: [credit, debit], has_more: false }, amount_due: 2050 }
|
||||
const p = await previewSwitch('c1')
|
||||
assert.equal(p.creditCents, 450)
|
||||
assert.equal(p.amountDueCents, 2050)
|
||||
})
|
||||
|
||||
// Fix 1 (4): zwei Webhooks zugleich (invoice.paid und pending_update_applied, Wiederholungen) -> T12/K14 genau einmal
|
||||
test('Gleichzeitige Webhooks: T12 bzw. K14 genau einmal', async () => {
|
||||
await pendingCardSwitch('in_gleichzeitig')
|
||||
st.applyPendingUpdate('sub_h')
|
||||
st.invoices.set('in_gleichzeitig', paidInvoice('in_gleichzeitig'))
|
||||
try {
|
||||
const applied = webhookEvent('customer.subscription.pending_update_applied', { id: 'sub_h', object: 'subscription' })
|
||||
const paid = webhookEvent('invoice.paid', { id: 'in_gleichzeitig', object: 'invoice', billing_reason: 'subscription_update', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } })
|
||||
const results = await Promise.all([postWebhook(applied), postWebhook(applied), postWebhook(paid)])
|
||||
assert.deepEqual(results.map((r) => r.status), [200, 200, 200])
|
||||
assert.equal(t12Count(), 1)
|
||||
assert.equal((await listContractEvents('c1'))[0].status, 'erledigt')
|
||||
} finally {
|
||||
await deleteDocument(config.collections.hourCredits, 'in_gleichzeitig').catch(() => {})
|
||||
}
|
||||
|
||||
await pendingCardSwitch()
|
||||
st.expirePendingUpdate('sub_h')
|
||||
const expired = webhookEvent('customer.subscription.pending_update_expired', { id: 'sub_h', object: 'subscription' })
|
||||
await Promise.all([postWebhook(expired), postWebhook(expired), postWebhook(expired)])
|
||||
assert.equal(mailsWithSubject(K14_SUBJECT).length, 1)
|
||||
})
|
||||
|
||||
// Fix 1 (8): der Wechsel prueft den angezeigten Betrag gegen eine neue Vorschau
|
||||
test('Wechsel mit geändertem Betrag: 409 ohne Stripe-Änderung', async () => {
|
||||
await resetHosting()
|
||||
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
|
||||
const before = st.calls.length
|
||||
await assert.rejects(
|
||||
switchToWartung('c1', consent, nowUnix(), { expectedAmountCents: 4000 }),
|
||||
(e) => e.status === 409 && e.message === 'Der Betrag hat sich geändert – bitte Seite neu laden.'
|
||||
)
|
||||
assert.deepEqual(updateCalls('sub_h', before), [])
|
||||
const res = await post(
|
||||
'/api/abo/switch',
|
||||
{ acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4000 },
|
||||
{ cookie: customerCookie() }
|
||||
)
|
||||
assert.equal(res.status, 409)
|
||||
assert.match(res.body.error, /Betrag hat sich geändert/)
|
||||
// Passender Betrag: Wechsel laeuft, die Vorschau nutzt dasselbe proration_date
|
||||
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
|
||||
const prorationDate = nowUnix()
|
||||
const from = st.calls.length
|
||||
await switchToWartung('c1', consent, prorationDate, { expectedAmountCents: 4100 })
|
||||
const [previewCall] = previewCalls(from)
|
||||
assert.equal(previewCall.body['subscription_details[proration_date]'], String(prorationDate))
|
||||
assert.equal(updateCalls('sub_h', from).length, 1)
|
||||
})
|
||||
|
||||
// Fix 1 (4): wer mit einem veralteten Stand des Ereignisses kommt (Webhook war schneller), aendert nichts mehr
|
||||
test('Abgleich mit veraltetem Ereignis nach dem Webhook: kein zweites T12', async () => {
|
||||
const stale = await staleCardSwitch()
|
||||
const snapshot = { ...(await listContractEvents('c1'))[0] }
|
||||
assert.equal(snapshot.status, 'offen')
|
||||
st.applyPendingUpdate('sub_h')
|
||||
await postWebhook(webhookEvent('customer.subscription.pending_update_applied', { id: 'sub_h', object: 'subscription' }))
|
||||
assert.equal(t12Count(), 1)
|
||||
assert.equal(await reconcilePendingSwitch(snapshot), null)
|
||||
assert.equal(t12Count(), 1)
|
||||
assert.equal((await listContractEvents('c1'))[0].$id, stale.$id)
|
||||
assert.equal((await listContractEvents('c1'))[0].doneBy, 'stripe')
|
||||
})
|
||||
Reference in new issue
Block a user