import { test, after } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' import { mailSink } from './support/mailSink.js' // Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), hourCredits: schemaKeys('hourCredits'), }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) // Testkonto (Testrabatte): Kunde c4 der Fixtures process.env.ABO_TEST_EMAILS = 'test@example.invalid' const { config } = await import('../server/config.js') const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js') 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, reconcilePendingSwitch, SWITCH_FAILED_REASONS } = await import('../server/services/planSwitch.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') const { parseArgs, run: runFailedSwitchScript } = await import('../scripts/wechsel-gescheitert.mjs') const app = await startTestApp({ '/api/abo': aboRoutes }) // F25: Mails landen in einer Senke (nodemailer jsonTransport) statt bei SMTP, damit K12, K14 und T12 pruefbar sind const mails = mailSink() setMailTransportForTests(mails) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) seedFixtures(aw) // Preise im Stripe-Nachbau mit den lookup_keys des Katalogs (ensurePrice findet sie, die Fixture // stripeSubscription() haengt dieselben IDs an die Abo-Posten) const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } }) st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents)) st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents)) st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents)) const nowUnix = () => Math.floor(Date.now() / 1000) const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) // Standard-Zahlungsmethoden, wie subscriptions.retrieve sie mit expand liefert (F24) const CARD = { id: 'pm_card_visa', object: 'payment_method', type: 'card' } const SEPA = { id: 'pm_sepa_de', object: 'payment_method', type: 'sepa_debit' } const K12_SUBJECT = 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung' const K14_SUBJECT = 'Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen' const t12Subject = (email) => `[Abo] Wechsel ins Wartungs-Abo: ${email}` // Wortlaut aus der Rechtsrunde (Task 9, Block zahlung_offen) const ZAHLUNG_OFFEN = 'Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.' const LASTSCHRIFT = 'Wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt' const WIRKSAM = 'Der Wechsel ist wirksam' const HOSTING_LAEUFT_WEITER = 'Ihr Hosting-Abo läuft mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter.' async function clearEvents(customerId) { for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) } /** * Hosting-Abo in Spiegel und Stripe-Nachbau auf den Ausgangsstand setzen (Standard: sub_h von c1, Karte), * Ereignisse des Kunden und die Mail-Senke leeren. patch aendert den Spiegel, stripe das Stripe-Abo. */ async function resetHosting({ id = 'sub_h', customerId = 'c1', mirror = hostingDoc, patch = {}, stripe = {}, paymentMethod = CARD } = {}) { await clearEvents(customerId) aw.seed('subscriptions', { ...mirror, ...patch }) st.subscriptions.set(id, stripeSubscription({ id, customerId, productKey: 'hosting', default_payment_method: paymentMethod, ...stripe })) st.nextSubscriptionUpdate = null mails.sent.length = 0 } // Rechnungszeilen in der Form der SDK-API-Version (2025-10-29.clover) const line = (priceId, amount, { proration = false, subscription = 'sub_h', periodStart = null } = {}) => ({ amount, quantity: 1, period: { start: periodStart ?? unix(hostingDoc.startedAt), end: unix(hostingDoc.currentPeriodEnd) }, pricing: { type: 'price_details', price_details: { price: priceId } }, parent: { type: 'subscription_item_details', subscription_item_details: { proration, subscription } }, }) const previewInvoice = (...lines) => ({ object: 'invoice', lines: { object: 'list', data: lines, has_more: false }, amount_due: lines.reduce((sum, l) => sum + l.amount, 0), }) // Wechselrechnung, wie subscriptions.update sie mit expand: ['latest_invoice'] liefert const switchInvoice = (overrides = {}) => ({ id: 'in_wechsel', object: 'invoice', status: 'paid', amount_due: 4100, billing_reason: 'subscription_update', hosted_invoice_url: 'https://invoice.stripe.test/i/in_wechsel', ...overrides, }) // Stripe haelt die Aenderung zurueck, bis die Rechnung bezahlt ist (Karte, z. B. 3-D Secure) const pendingUpdate = () => ({ expires_at: nowUnix() + 23 * 3600, billing_cycle_anchor: nowUnix(), subscription_items: [], trial_end: null, trial_from_plan: null }) const paidInvoice = (id, subscription = 'sub_h') => ({ id, object: 'invoice', number: `WK-${id}`, status: 'paid', billing_reason: 'subscription_update', amount_due: 4100, created: nowUnix(), status_transitions: { paid_at: nowUnix() }, parent: { type: 'subscription_details', subscription_details: { subscription } }, lines: { object: 'list', data: [line('price_hosting', -900, { proration: true, periodStart: nowUnix(), subscription }), line('price_wartung', 5000, { subscription })] }, }) const webhookEvent = (type, object) => ({ id: `evt_${Math.random().toString(36).slice(2, 10)}`, object: 'event', type, created: nowUnix(), data: { object }, }) const updateCalls = (id = 'sub_h', from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === `/v1/subscriptions/${id}`) const previewCalls = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/invoices/create_preview') const mailsWithSubject = (subject) => mails.sent.filter((m) => m.subject === subject) const recipients = (mail) => mail.to.map((t) => t.address) async function withCapturedLogs(fn) { const logs = { error: [], warn: [] } const original = { error: console.error, warn: console.warn } console.error = (...args) => logs.error.push(args.join(' ')) console.warn = (...args) => logs.warn.push(args.join(' ')) try { await fn(logs) } finally { console.error = original.error console.warn = original.warn } return logs } // --------------------------------------------------------------------------- // Vorschau // --------------------------------------------------------------------------- test('Vorschau: Gutschrift und heute fällig aus der Stripe-Vorschau', async () => { await resetHosting() // Stripe-Nachbau: POST /v1/invoices/create_preview liefert die Zeilen -900 (anteilig) und 5000 st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000)) const before = st.calls.length const p = await previewSwitch('c1') assert.equal(p.creditCents, 900); assert.equal(p.amountDueCents, 4100); assert.equal(p.monthlyCents, 5000) assert.ok(Math.abs(p.prorationDate - nowUnix()) <= 5, 'Vorschau-Zeitpunkt jetzt') // Neuer Abbuchungstag = Wechseltag, neue Mindestlaufzeit 12 Monate ab dem Wechseltag assert.equal(p.nextChargeAt, addMonths(new Date(p.prorationDate * 1000), 1).toISOString()) assert.equal(p.minTermEnd, addMonths(new Date(p.prorationDate * 1000), 12).toISOString()) const [call] = previewCalls(before) assert.equal(call.body['subscription'], 'sub_h') assert.equal(call.body['subscription_details[items][0][id]'], 'si_sub_h_abo') assert.equal(call.body['subscription_details[items][0][price]'], 'price_wartung') assert.equal(call.body['subscription_details[billing_cycle_anchor]'], 'now') assert.equal(call.body['subscription_details[proration_behavior]'], 'create_prorations') assert.equal(call.body['subscription_details[proration_date]'], String(p.prorationDate)) assert.equal(call.body['discounts[0][coupon]'], undefined, 'kein Testkonto: Rabatte wie am Abo') // Eine Vorschau aendert nichts bei Stripe assert.deepEqual(updateCalls('sub_h', before), []) }) test('Vorschau: Gutschrift nur aus den anteiligen Zeilen dieses Wechsels (auch Speicher), ältere zählen nicht', async () => { await resetHosting({ stripe: { storageUnits: 2 } }) const now = nowUnix() st.invoicePreview = previewInvoice( // Speicher-Plus von vorhin (schon vorgemerkte Rechnungsposition): im Betrag, aber keine Gutschrift dieses Wechsels line('price_mailStorage', -30, { proration: true, periodStart: now - 3 * 3600 }), line('price_mailStorage', 90, { proration: true, periodStart: now - 3 * 3600 }), line('price_hosting', -900, { proration: true, periodStart: now }), line('price_mailStorage', -120, { proration: true, periodStart: now }), line('price_wartung', 5000), line('price_mailStorage', 200) ) const p = await previewSwitch('c1') assert.equal(p.creditCents, 1020) assert.equal(p.amountDueCents, 5000 + 200 - 900 - 120 - 30 + 90) }) test('Vorschau: Voraussetzungen wie beim Wechsel (409), kein Abo 404, ohne Stripe-Vorschau', async () => { const before = st.calls.length for (const patch of [{ cancelAtPeriodEnd: true }, { productKey: 'wartung' }, { status: 'trialing' }, { status: 'past_due' }]) { await resetHosting({ patch }) await assert.rejects(previewSwitch('c1'), (e) => e.status === 409, JSON.stringify(patch)) } await assert.rejects(previewSwitch('c3'), (e) => e.status === 404) assert.deepEqual(previewCalls(before), []) }) // --------------------------------------------------------------------------- // Wechsel: Karte, Karte mit offener Zahlung, SEPA-Lastschrift (Rechtsrunde 28.09.2026, F23/F24) // --------------------------------------------------------------------------- test('Wechsel: Stripe-Aufruf mit Anker jetzt, Metadaten, Ereignis', async () => { await resetHosting({ paymentMethod: CARD }) // Karte, Zahlung sofort erfolgreich: Stripe wendet die Aenderung an, die Wechselrechnung ist bezahlt st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } const prorationDate = nowUnix() const before = st.calls.length let r const logs = await withCapturedLogs(async () => { r = await switchToWartung('c1', consent, prorationDate) }) const call = st.calls.findLast((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST') assert.equal(call.body['billing_cycle_anchor'], 'now') assert.equal(call.body['metadata[productKey]'], 'wartung') assert.equal(call.body['metadata[switchedFrom]'], 'hosting') assert.equal((await listContractEvents('c1'))[0].type, 'wechsel') assert.equal(r.paymentUrl, null) // F23: Karte -> pending_if_incomplete; Preis der Abo-Position, Gutschrift ab dem Zeitpunkt der Vorschau assert.equal(call.body['payment_behavior'], 'pending_if_incomplete') assert.equal(call.body['proration_behavior'], 'create_prorations') assert.equal(call.body['proration_date'], String(prorationDate)) assert.equal(call.body['items[0][id]'], 'si_sub_h_abo') assert.equal(call.body['items[0][price]'], 'price_wartung') assert.equal(call.body['expand[0]'], 'latest_invoice') assert.equal(call.body['discounts[0][coupon]'], undefined) assert.equal(call.body['discounts[0][discount]'], undefined) // Metadaten: neue Mindestlaufzeit ab dem Wechseltag, Nachweis der Zustimmung const switchedAt = call.body['metadata[switchedAt]'] assert.ok(Math.abs(new Date(switchedAt).getTime() - Date.now()) < 5000, 'Wechsel jetzt') assert.equal(call.body['metadata[termStartAt]'], switchedAt) assert.equal(call.body['metadata[termsVersion]'], consent.termsVersion) assert.equal(call.body['metadata[termsAcceptedAt]'], consent.consentAt) assert.equal(call.body['metadata[earlyStartConsentAt]'], consent.earlyStartConsentAt) assert.equal(call.body['metadata[consentIp]'], '127.0.0.1') assert.equal(call.body['metadata[consentUserAgent]'], 'test') // F24: Zahlart aus der Standard-Zahlungsmethode (Abo, sonst Kunde) const retrieve = st.calls.slice(before).find((c) => c.method === 'GET' && c.path === '/v1/subscriptions/sub_h') const expand = Object.entries(retrieve.query).filter(([k]) => k.startsWith('expand')).map(([, v]) => v) assert.ok(expand.includes('default_payment_method')) assert.ok(expand.includes('customer.invoice_settings.default_payment_method')) // Ereignis: bezahlt und wirksam -> erledigt; Nachweis und Angaben fuer ein spaeteres Zuruecksetzen (F27) const [ev] = await listContractEvents('c1') assert.equal(ev.status, 'erledigt') assert.equal(ev.subscriptionId, 'sub_h') assert.equal(ev.productKey, 'wartung') assert.equal(ev.termsVersion, consent.termsVersion) assert.equal(ev.consentAt, consent.consentAt) assert.equal(ev.earlyStartConsentAt, consent.earlyStartConsentAt) assert.equal(ev.consentIp, '127.0.0.1') assert.equal(ev.consentUserAgent, 'test') assert.equal(ev.$id.length, 36) assert.equal(ev.details.from, 'hosting') assert.equal(ev.details.amountDueCents, 4100) assert.equal(ev.details.prorationDate, prorationDate) assert.equal(ev.details.switchedAt, switchedAt) assert.equal(ev.details.paymentMethod, 'card') assert.equal(ev.details.invoiceId, 'in_wechsel') assert.equal(ev.details.paymentUrl, null) 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) // 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') assert.equal(doc.productKey, 'wartung') assert.equal(doc.termStartAt, switchedAt) assert.equal(r.subscription.productKey, 'wartung') // K12 sofort (Vertragsbestaetigung mit PDF der Fassung 4), T12 sofort (Wechsel ist wirksam) const [k12] = mailsWithSubject(K12_SUBJECT) assert.ok(k12, 'K12 verschickt') assert.deepEqual(recipients(k12), ['k@example.invalid']) assert.deepEqual(k12.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']]) assert.ok(k12.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt') assert.ok(k12.text.includes('Guten Tag Kunde Eins,')) assert.ok(k12.text.includes('41,00 €'), k12.text) assert.ok(k12.text.includes('50,00 €')) assert.ok(k12.text.includes(`Mindestlaufzeit 12 Monate bis ${addMonths(new Date(switchedAt), 12).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })}.`)) assert.ok(k12.text.includes(`(Version ${consent.termsVersion})`)) assert.ok(!k12.text.includes(ZAHLUNG_OFFEN), 'kein Block zahlung_offen') assert.ok(!k12.text.includes(LASTSCHRIFT), 'kein Block lastschrift') const [t12] = mailsWithSubject(t12Subject('k@example.invalid')) assert.ok(t12, 'T12 verschickt') assert.ok(t12.text.includes(WIRKSAM)) assert.ok(t12.text.includes('sub_h')) assert.ok(t12.text.includes('Weitere Postfächer erst einrichten, wenn die Wechselrechnung bezahlt ist'), t12.text) assert.equal(mails.sent.length, 2) // Alle Platzhalter und Bloecke passen zu den Vorlagen assert.deepEqual(logs.warn.filter((w) => /Vorlage (K12|T12)/.test(w)), []) }) // Task 12 (Spec 3.5): {{monatspreis}} in K12 und T12 ist die Monatsgebühr des Wartungs-Abos mit gebuchtem // Zusatzspeicher; K12 sagt es im Block zusatzspeicher. Auch nach einem gescheiterten Wechsel (T12 aus dem Ereignis) test('K12/T12: Monatspreis mit gebuchtem Zusatzspeicher, auch im Hinweis zum gescheiterten Wechsel', async () => { await resetHosting({ stripe: { storageUnits: 2 } }) st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ id: 'in_speicher', status: 'open', amount_due: 4300, hosted_invoice_url: 'https://pay.example/inv2' }) } await switchToWartung('c1', consent, nowUnix()) const [k12] = mailsWithSubject(K12_SUBJECT) assert.ok(k12.text.includes('52,00 € / Monat'), k12.text) assert.ok(k12.text.includes('einschließlich Ihres gebuchten E-Mail-Zusatzspeichers'), k12.text) assert.ok(k12.text.includes('43,00 €')) assert.deepEqual(mailsWithSubject(t12Subject('k@example.invalid')), []) const [ev] = await listContractEvents('c1') assert.equal(ev.details.monthlyCents, 5200) await markSwitchFailed(ev.$id, SWITCH_FAILED_REASONS.abgelaufen) const [t12] = mailsWithSubject(t12Subject('k@example.invalid')) assert.ok(t12.text.includes('52,00 €'), t12.text) assert.ok(t12.text.includes('43,00 €')) // Ohne Zusatzspeicher: 50,00 € und kein Block await resetHosting() st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } await switchToWartung('c1', consent, nowUnix()) const [plain] = mailsWithSubject(K12_SUBJECT) assert.ok(plain.text.includes('50,00 € / Monat'), plain.text) assert.ok(!plain.text.includes('einschließlich Ihres gebuchten')) }) test('Offene Rechnung liefert paymentUrl', async () => { await resetHosting({ id: 'sub_h2', customerId: 'c2', mirror: hosting2Doc }) // Karte, Zahlung nicht sofort erfolgreich (3-D Secure): Stripe haelt die Aenderung in pending_update zurueck st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ id: 'in_offen', status: 'open', hosted_invoice_url: 'https://pay.example/inv' }) } const r = await switchToWartung('c2', consent, Math.floor(Date.now() / 1000)) assert.equal(r.paymentUrl, 'https://pay.example/inv') assert.equal((await listContractEvents('c2'))[0].type, 'wechsel') const call = updateCalls('sub_h2').at(-1) assert.equal(call.body['payment_behavior'], 'pending_if_incomplete') const [ev] = await listContractEvents('c2') assert.equal(ev.status, 'offen') assert.equal(ev.details.paymentUrl, 'https://pay.example/inv') assert.equal(ev.details.effectiveAt, null) // Metadaten und Spiegel aendern sich noch nicht assert.equal(aw.docs('subscriptions').get('sub_h2').productKey, 'hosting') assert.equal(aw.docs('subscriptions').get('sub_h2').termStartAt, hosting2Doc.termStartAt) assert.equal(st.subscriptions.get('sub_h2').metadata.productKey, 'hosting') assert.equal(st.subscriptions.get('sub_h2').metadata.switchedAt, undefined) // K12 sofort, mit Block zahlung_offen; T12 erst, wenn der Wechsel wirksam ist const [k12] = mailsWithSubject(K12_SUBJECT) assert.deepEqual(recipients(k12), ['k2@example.invalid']) assert.ok(k12.text.includes(ZAHLUNG_OFFEN), k12.text) assert.ok(!k12.text.includes(LASTSCHRIFT)) assert.deepEqual(mailsWithSubject(t12Subject('k2@example.invalid')), []) // Portal: Wechsel wartet auf die Zahlung, Link zur Zahlungsseite (F12) const view = await get('/api/abo', { cookie: customerCookie('c2') }) assert.deepEqual(view.subscription.switchPending, { since: consent.consentAt, paymentUrl: 'https://pay.example/inv' }) assert.equal(view.subscription.canSwitch, false) assert.equal(view.subscription.productKey, 'hosting') // Ein zweiter Wechsel, waehrend einer auf die Zahlung wartet await assert.rejects(switchToWartung('c2', consent, nowUnix()), (e) => e.status === 409 && e.message === 'Ein Wechsel wartet noch auf die Zahlung.') await clearEvents('c2') }) test('SEPA-Lastschrift: allow_incomplete, sofort wirksam, offen bis zur Zahlung, K12 mit Block lastschrift, T12 sofort', async () => { await resetHosting({ paymentMethod: SEPA }) // SEPA: Stripe wendet die Aenderung sofort an, die Lastschrift ist noch nicht eingeloest st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ status: 'open' }) } const r = await switchToWartung('c1', consent, nowUnix()) const call = updateCalls('sub_h').at(-1) assert.equal(call.body['payment_behavior'], 'allow_incomplete') assert.equal(r.paymentUrl, null, 'keine Weiterleitung bei SEPA') const [ev] = await listContractEvents('c1') assert.equal(ev.status, 'offen') assert.equal(ev.details.paymentMethod, 'sepa_debit') assert.equal(ev.details.paymentUrl, null) assert.equal(ev.details.effectiveAt, ev.details.switchedAt) // Wechsel gilt sofort: Stripe und Spiegel sind Wartung assert.equal(st.subscriptions.get('sub_h').metadata.productKey, 'wartung') assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'wartung') const [k12] = mailsWithSubject(K12_SUBJECT) assert.ok(k12.text.includes(LASTSCHRIFT), k12.text) assert.ok(k12.text.includes('Ihr Abbuchungstag bleibt der Wechseltag')) assert.ok(!k12.text.includes(ZAHLUNG_OFFEN)) const [t12] = mailsWithSubject(t12Subject('k@example.invalid')) assert.ok(t12.text.includes(WIRKSAM)) assert.ok(t12.text.includes('SEPA-Lastschrift')) // Portal: Zahlung offen (ohne Link), keine Speicheraenderung bis zur Zahlung const view = await get('/api/abo', { cookie: customerCookie() }) assert.deepEqual(view.subscription.switchPending, { since: consent.consentAt, paymentUrl: null }) assert.equal(view.subscription.canChangeStorage, false) assert.equal(view.subscription.productKey, 'wartung') }) test('Zahlart: Standard-Zahlungsmethode des Abos, sonst des Kunden; unbekannt oder keine → Karte', async () => { assert.equal(paymentMethodTypeOf({ default_payment_method: SEPA }), 'sepa_debit') assert.equal(paymentMethodTypeOf({ default_payment_method: CARD, customer: { invoice_settings: { default_payment_method: SEPA } } }), 'card') assert.equal(paymentMethodTypeOf({ default_payment_method: null, customer: { id: 'cus_1', invoice_settings: { default_payment_method: SEPA } } }), 'sepa_debit') assert.equal(paymentMethodTypeOf({ default_payment_method: 'pm_nicht_erweitert', customer: 'cus_1' }), null) assert.equal(paymentMethodTypeOf({}), null) // Kunde mit SEPA als Standard, Abo ohne eigene Zahlungsmethode -> allow_incomplete await resetHosting({ paymentMethod: null, stripe: { customer: { id: 'cus_c1', object: 'customer', email: 'k@example.invalid', invoice_settings: { default_payment_method: SEPA } } }, }) st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ status: 'open' }) } await switchToWartung('c1', consent, nowUnix()) assert.equal(updateCalls('sub_h').at(-1).body['payment_behavior'], 'allow_incomplete') // Keine Zahlungsmethode bekannt -> Weg fuer Karten (Stripe haelt die Aenderung bis zur Zahlung zurueck) await resetHosting({ paymentMethod: null }) st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } await switchToWartung('c1', consent, nowUnix()) assert.equal(updateCalls('sub_h').at(-1).body['payment_behavior'], 'pending_if_incomplete') assert.equal((await listContractEvents('c1'))[0].details.paymentMethod, null) }) // --------------------------------------------------------------------------- // Voraussetzungen und Frische der Vorschau // --------------------------------------------------------------------------- test('Gekündigt, Wartung, trialing, past_due: 409', async () => { await resetHosting() const before = st.calls.length for (const patch of [{ cancelAtPeriodEnd: true }, { productKey: 'wartung' }, { status: 'trialing' }, { status: 'past_due' }]) { aw.seed('subscriptions', { ...hostingDoc, ...patch }) await assert.rejects(switchToWartung('c1', consent, Math.floor(Date.now() / 1000)), (e) => e.status === 409, JSON.stringify(patch)) } // Kuendigung nur per cancelAt zaehlt ebenso aw.seed('subscriptions', { ...hostingDoc, cancelAt: '2027-10-05T10:00:00.000Z' }) await assert.rejects(switchToWartung('c1', consent, nowUnix()), (e) => e.status === 409) assert.deepEqual(updateCalls('sub_h', before), [], 'keine Aenderung bei Stripe') aw.seed('subscriptions', hostingDoc) }) test('Alte Vorschau (> 30 min): 409', async () => { await resetHosting() const before = st.calls.length await assert.rejects(switchToWartung('c1', consent, Math.floor(Date.now() / 1000) - 31 * 60), (e) => e.status === 409 && /Bitte Seite neu laden/.test(e.message)) assert.equal(st.calls.length, before, 'kein Stripe-Aufruf') }) test('Frische der Vorschau: 30 Minuten, derselbe Kalendertag in Deutschland, nicht aus der Zukunft', () => { const at = (iso) => new Date(iso).getTime() assert.equal(previewProblem(unix('2026-10-01T10:00:00Z'), at('2026-10-01T10:29:00Z')), null) assert.equal(previewProblem(unix('2026-10-01T10:00:00Z'), at('2026-10-01T10:31:00Z'))?.status, 409) // 23:55 und 00:05 Uhr in Deutschland (Sommerzeit): der angezeigte Wechseltag stimmt nicht mehr const midnight = previewProblem(unix('2026-10-01T21:55:00Z'), at('2026-10-01T22:05:00Z')) assert.equal(midnight?.status, 409) assert.match(midnight.message, /Datumswechsel/) // Aus der Zukunft (mehr als 5 Minuten) oder keine ganze Zahl: 400 assert.equal(previewProblem(unix('2026-10-01T10:10:00Z'), at('2026-10-01T10:00:00Z'))?.status, 400) assert.equal(previewProblem(unix('2026-10-01T10:02:00Z'), at('2026-10-01T10:00:00Z')), null, 'kleine Uhrabweichung erlaubt') for (const bad of ['1790000000', 1.5, null, undefined, 0, -5]) assert.equal(previewProblem(bad, at('2026-10-01T10:00:00Z'))?.status, 400, String(bad)) }) test('Abrechnungsmonat hat seit der Vorschau neu begonnen: 409', async () => { await resetHosting() st.subscriptions.get('sub_h').items.data[0].current_period_start = nowUnix() const before = st.calls.length await assert.rejects(switchToWartung('c1', consent, nowUnix() - 60), (e) => e.status === 409 && /Bitte Seite neu laden/.test(e.message)) assert.deepEqual(updateCalls('sub_h', before), []) }) test('Ein zweiter Wechsel, während einer auf die Zahlung wartet: 409', async () => { await resetHosting() await recordContractEvent({ customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: { from: 'hosting', paymentUrl: 'https://pay.example/alt' }, consent, status: 'offen', }) const before = st.calls.length await assert.rejects(switchToWartung('c1', consent, nowUnix()), (e) => e.status === 409 && e.message === 'Ein Wechsel wartet noch auf die Zahlung.') await assert.rejects(previewSwitch('c1'), (e) => e.status === 409) assert.equal(st.calls.length, before) await clearEvents('c1') }) test('Stripe ist die Wahrheit: schon Wartung oder ausstehende Änderung bei Stripe → 409 ohne Änderung', async () => { // Spiegel veraltet (Webhook noch nicht da), Stripe hat schon den Wartungs-Preis await resetHosting() st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'wartung', default_payment_method: CARD })) const before = st.calls.length await assert.rejects(switchToWartung('c1', consent, nowUnix()), (e) => e.status === 409) // Stripe haelt noch eine Aenderung zurueck (Ereignis fehlt im Portal) await resetHosting({ stripe: { pending_update: pendingUpdate() } }) await assert.rejects(switchToWartung('c1', consent, nowUnix()), (e) => e.status === 409 && /wartet noch auf die Zahlung/.test(e.message)) await assert.rejects(previewSwitch('c1'), (e) => e.status === 409) assert.deepEqual(updateCalls('sub_h', before), []) }) test('Ohne Zustimmung zu Bedingungen oder Sofortbeginn kein Wechsel (400)', async () => { await resetHosting() const before = st.calls.length const { earlyStartConsentAt: _drop, ...withoutEarlyStart } = consent await assert.rejects(switchToWartung('c1', withoutEarlyStart, nowUnix()), (e) => e.status === 400) await assert.rejects(switchToWartung('c1', { ...consent, termsVersion: '' }, nowUnix()), (e) => e.status === 400) await assert.rejects(switchToWartung('c1', null, nowUnix()), (e) => e.status === 400) assert.equal(st.calls.length, before) }) test('Testkonto: Testrabatt Hosting fällt weg, Testrabatt Wartung kommt dazu, andere Rabatte bleiben', async () => { const subId = 'sub_t' await clearEvents('c4') aw.seed('subscriptions', subscriptionDoc(subId, { ...hostingDoc, customerId: 'c4', email: 'test@example.invalid', stripeCustomerId: 'cus_c4' })) st.subscriptions.set( subId, stripeSubscription({ id: subId, customerId: 'c4', productKey: 'hosting', default_payment_method: CARD, // expand: ['discounts'] -> Rabatt-Objekte (API 2025-10-29.clover: discount.source.coupon) discounts: [ { id: 'di_test_hosting', object: 'discount', source: { type: 'coupon', coupon: 'WEBKLAR-TEST-050-HOSTING' } }, { id: 'di_angebot', object: 'discount', source: { type: 'coupon', coupon: { id: 'WEBKLAR-ANGEBOT-X', object: 'coupon' } } }, ], }) ) st.invoicePreview = previewInvoice(line('price_hosting', -30, { proration: true, periodStart: nowUnix(), subscription: subId }), line('price_wartung', 50, { subscription: subId })) try { const before = st.calls.length const p = await previewSwitch('c4') const [previewCall] = previewCalls(before) assert.equal(previewCall.body['discounts[0][discount]'], 'di_angebot') assert.equal(previewCall.body['discounts[1][coupon]'], 'WEBKLAR-TEST-050') assert.equal(previewCall.body['discounts[2][coupon]'], undefined) st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_test', amount_due: 20 }) } await switchToWartung('c4', consent, p.prorationDate) const call = updateCalls(subId).at(-1) assert.equal(call.body['discounts[0][discount]'], 'di_angebot') assert.equal(call.body['discounts[1][coupon]'], 'WEBKLAR-TEST-050') assert.equal(call.body['discounts[2][coupon]'], undefined) assert.equal(call.body['discounts[2][discount]'], undefined) assert.ok(!Object.values(call.body).includes('di_test_hosting'), 'Testrabatt Hosting faellt weg') // Der Wartungs-Testrabatt existiert (angelegt, falls er fehlte) assert.equal(st.coupons.get('WEBKLAR-TEST-050').amount_off, 4950) } finally { await clearEvents('c4') await deleteDocument(config.collections.subscriptions, subId).catch(() => {}) } }) // --------------------------------------------------------------------------- // Fehler beim Nachweis und beim Mailversand // --------------------------------------------------------------------------- test('Mailfehler brechen den Wechsel nicht ab (nur im Log)', async () => { await resetHosting() st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP gestört') } }) try { let r const logs = await withCapturedLogs(async () => { r = await switchToWartung('c1', consent, nowUnix()) }) assert.equal(r.subscription.productKey, 'wartung') assert.equal((await listContractEvents('c1'))[0].status, 'erledigt') assert.ok(logs.error.some((e) => e.includes('K12') && e.includes('SMTP gestört')), logs.error.join('\n')) assert.ok(logs.error.some((e) => e.includes('Admin-Hinweis fehlgeschlagen'))) } finally { setMailTransportForTests(mails) } }) test('Nachweis nicht speicherbar: Wechsel läuft, T12 meldet es sofort (auch bei offener Zahlung)', async () => { await resetHosting() st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ status: 'open', hosted_invoice_url: 'https://pay.example/x' }) } aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 }) let r const logs = await withCapturedLogs(async () => { r = await switchToWartung('c1', consent, nowUnix()) }) assert.equal(r.paymentUrl, 'https://pay.example/x') assert.deepEqual(await listContractEvents('c1'), []) assert.ok(logs.error.some((e) => e.includes('Ereignis') && e.includes('sub_h')), logs.error.join('\n')) const [t12] = mailsWithSubject(t12Subject('k@example.invalid')) assert.ok(t12, 'T12 geht sofort raus, damit das Team den Wechsel von Hand verfolgt') assert.ok(t12.text.includes('Nachweis nicht gespeichert')) assert.ok(!t12.text.includes(WIRKSAM), 'Zahlung noch offen: nicht wirksam') assert.ok(mailsWithSubject(K12_SUBJECT)[0].text.includes(ZAHLUNG_OFFEN)) }) // --------------------------------------------------------------------------- // Routen // --------------------------------------------------------------------------- test('Route: ohne earlyStart 400', async () => { const res = await post('/api/abo/switch', { acceptTerms: true, termsVersion: config.abo.termsVersion, prorationDate: 1 }, { cookie: customerCookie() }) assert.equal(res.status, 400) }) 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(), amountDueCents: 4100 } const before = st.calls.length 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/) assert.equal((await post('/api/abo/switch', body)).status, 401) assert.equal((await post('/api/abo/switch/preview', {})).status, 401) assert.equal((await post('/api/abo/switch', body, { cookie: viewAsCookie() })).status, 403) assert.equal((await post('/api/abo/switch/preview', {}, { cookie: viewAsCookie() })).status, 403) assert.equal(st.calls.length, before) }) test('Route Vorschau: Beträge; Wartungs-Abo 409', async () => { await resetHosting() st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000)) const res = await post('/api/abo/switch/preview', {}, { cookie: customerCookie() }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.body.creditCents, 900) assert.equal(res.body.amountDueCents, 4100) assert.equal(res.body.monthlyCents, 5000) assert.ok(Number.isInteger(res.body.prorationDate)) assert.ok(res.body.nextChargeAt && res.body.minTermEnd) await resetHosting({ patch: { productKey: 'wartung' } }) const wartung = await post('/api/abo/switch/preview', {}, { cookie: customerCookie() }) assert.equal(wartung.status, 409) assert.ok(wartung.body.error) aw.seed('subscriptions', hostingDoc) }) 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(), amountDueCents: 4100 }, { cookie: customerCookie(), headers: { 'user-agent': 'Testbrowser/1.0' } } ) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.body.paymentUrl, 'https://pay.example/route') assert.equal(res.body.subscription.productKey, 'hosting') assert.equal(res.body.subscription.canSwitch, false) assert.equal(res.body.subscription.switchPending.paymentUrl, 'https://pay.example/route') // F16: Zustimmung zum Sofortbeginn aus earlyStart, Nachweis mit Fassung, Zeitpunkt, IP, Browser const [ev] = await listContractEvents('c1') assert.equal(ev.termsVersion, config.abo.termsVersion) assert.ok(ev.consentAt) assert.equal(ev.earlyStartConsentAt, ev.consentAt) assert.equal(ev.consentIp, '127.0.0.1') assert.equal(ev.consentUserAgent, 'Testbrowser/1.0') // 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(), 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(), amountDueCents: 4100 }, { cookie: customerCookie() }) assert.equal(again.status, 409) }) // --------------------------------------------------------------------------- // Webhooks (F23, F26, F27) // --------------------------------------------------------------------------- async function pendingCardSwitch(invoiceId = 'in_karte') { await resetHosting({ paymentMethod: CARD }) st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ id: invoiceId, status: 'open', hosted_invoice_url: 'https://pay.example/karte' }) } await switchToWartung('c1', consent, nowUnix()) assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'hosting') mails.sent.length = 0 } test('Webhook pending_update_applied: Ereignis erledigt, Spiegel Wartung, T12; Wiederholung ohne zweite Mail', async () => { await pendingCardSwitch() // Zahlung auf der Stripe-Seite: Stripe uebernimmt Preis, Anker und Metadaten st.applyPendingUpdate('sub_h') const event = webhookEvent('customer.subscription.pending_update_applied', { id: 'sub_h', object: 'subscription' }) const res = await postWebhook(event) assert.equal(res.status, 200, JSON.stringify(res.body)) const [ev] = await listContractEvents('c1') assert.equal(ev.status, 'erledigt') assert.equal(ev.doneBy, 'stripe') assert.ok(ev.details.effectiveAt) assert.ok(ev.details.paidAt) assert.equal(ev.details.paymentUrl, 'https://pay.example/karte', 'details bleiben erhalten') const doc = aw.docs('subscriptions').get('sub_h') assert.equal(doc.productKey, 'wartung') assert.equal(doc.termStartAt, ev.details.switchedAt) assert.equal(mailsWithSubject(t12Subject('k@example.invalid')).length, 1) assert.ok(mailsWithSubject(t12Subject('k@example.invalid'))[0].text.includes(WIRKSAM)) assert.deepEqual(mailsWithSubject(K12_SUBJECT), [], 'K12 ging schon beim Klick raus') // Wiederholung durch Stripe: nichts mehr offen, keine zweite Mail assert.equal((await postWebhook(event)).status, 200) assert.equal(mails.sent.length, 1) const view = await get('/api/abo', { cookie: customerCookie() }) assert.equal(view.subscription.switchPending, null) assert.equal(view.subscription.productKey, 'wartung') }) 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)) const [ev] = await listContractEvents('c1') assert.equal(ev.status, 'gescheitert') assert.equal(ev.doneBy, 'stripe') assert.equal(ev.details.grund, SWITCH_FAILED_REASONS.abgelaufen) assert.ok(ev.details.failedAt) const [k14] = mailsWithSubject(K14_SUBJECT) assert.ok(k14, 'K14 verschickt') assert.deepEqual(recipients(k14), ['k@example.invalid']) assert.ok(k14.text.includes(HOSTING_LAEUFT_WEITER), k14.text) assert.ok(k14.text.includes(SWITCH_FAILED_REASONS.abgelaufen)) assert.ok(!k14.text.includes('Abbuchungstag'), 'Karte: der alte Abbuchungstag bleibt, kein Hinweis darauf') assert.ok(!/23 Stunden/.test(k14.text)) const [t12] = mailsWithSubject(t12Subject('k@example.invalid')) assert.ok(t12.text.includes('nicht zustande gekommen'), t12.text) assert.ok(!t12.text.includes(WIRKSAM)) // Hosting-Abo laeuft weiter, der Wechsel kann neu bestellt werden; keine Widerrufsfrist fuer den gescheiterten Wechsel assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'hosting') const view = await get('/api/abo', { cookie: customerCookie() }) assert.equal(view.subscription.switchPending, null) assert.equal(view.subscription.canSwitch, true) assert.ok(!view.subscription.withdrawals.some((w) => w.type === 'wechsel')) // Wiederholung: keine zweite K14 mails.sent.length = 0 assert.equal((await postWebhook(event)).status, 200) assert.deepEqual(mails.sent, []) }) test('Webhook invoice.paid (SEPA): Ereignis erledigt, Stunde gutgeschrieben, kein zweites T12', async () => { await resetHosting({ paymentMethod: SEPA }) st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_sepa', status: 'open' }) } await switchToWartung('c1', consent, nowUnix()) st.invoices.set('in_sepa', paidInvoice('in_sepa')) mails.sent.length = 0 try { const event = webhookEvent('invoice.paid', { id: 'in_sepa', object: 'invoice', billing_reason: 'subscription_update', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } }) const res = await postWebhook(event) assert.equal(res.status, 200, JSON.stringify(res.body)) const [ev] = await listContractEvents('c1') assert.equal(ev.status, 'erledigt') assert.equal(ev.doneBy, 'stripe') assert.ok(ev.details.paidAt) assert.equal(ev.details.effectiveAt, ev.details.switchedAt, 'wirksam seit dem Klick') // Erste Stunde mit der bezahlten Wechselrechnung (Spec 3.5) assert.equal(aw.docs('hourCredits').get('in_sepa').minutes, 60) assert.deepEqual(mailsWithSubject(t12Subject('k@example.invalid')), [], 'T12 ging bei SEPA schon beim Klick raus') // Idempotent assert.equal((await postWebhook(event)).status, 200) assert.equal((await listContractEvents('c1'))[0].status, 'erledigt') const view = await get('/api/abo', { cookie: customerCookie() }) assert.equal(view.subscription.switchPending, null) assert.equal(view.subscription.canChangeStorage, true) } finally { await deleteDocument(config.collections.hourCredits, 'in_sepa').catch(() => {}) } }) test('Webhook invoice.paid vor pending_update_applied (Karte): erledigt und T12 genau einmal', async () => { await pendingCardSwitch('in_karte_zuerst') st.applyPendingUpdate('sub_h') st.invoices.set('in_karte_zuerst', paidInvoice('in_karte_zuerst')) try { const paid = webhookEvent('invoice.paid', { id: 'in_karte_zuerst', object: 'invoice', billing_reason: 'subscription_update', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } }) assert.equal((await postWebhook(paid)).status, 200) const [ev] = await listContractEvents('c1') assert.equal(ev.status, 'erledigt') assert.ok(ev.details.effectiveAt) assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'wartung') assert.equal(mailsWithSubject(t12Subject('k@example.invalid')).length, 1) const applied = webhookEvent('customer.subscription.pending_update_applied', { id: 'sub_h', object: 'subscription' }) assert.equal((await postWebhook(applied)).status, 200) assert.equal(mailsWithSubject(t12Subject('k@example.invalid')).length, 1, 'kein zweites T12') } finally { await deleteDocument(config.collections.hourCredits, 'in_karte_zuerst').catch(() => {}) } }) 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: 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' } } }) assert.equal((await postWebhook(paid)).status, 200) assert.equal((await listContractEvents('c1'))[0].status, 'offen') assert.deepEqual(mailsWithSubject(t12Subject('k@example.invalid')), []) } finally { await deleteDocument(config.collections.hourCredits, 'in_noch_offen').catch(() => {}) } }) test('Webhook invoice.payment_failed: SEPA-Wechselrechnung → T12 mit Handgriffen; Karte ohne T12', async () => { await resetHosting({ paymentMethod: SEPA }) st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_sepa_f', status: 'open' }) } await switchToWartung('c1', consent, nowUnix()) mails.sent.length = 0 const failed = webhookEvent('invoice.payment_failed', { id: 'in_sepa_f', object: 'invoice', number: 'WK-10', billing_reason: 'subscription_update', amount_due: 4100, customer_email: 'k@example.invalid', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } }, }) const res = await postWebhook(failed) 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, 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)) assert.equal(mails.sent.length, 2, 'dazu T04 wie bei jeder fehlgeschlagenen Zahlung') // Das Ereignis schliesst das Team (Skript), nicht der Webhook assert.equal((await listContractEvents('c1'))[0].status, 'offen') // Karte mit offener Zahlung: Stripe hat nichts uebernommen, kein switchedAt -> nur T04 await pendingCardSwitch('in_karte_f') const cardFailed = webhookEvent('invoice.payment_failed', { id: 'in_karte_f', object: 'invoice', billing_reason: 'subscription_update', amount_due: 4100, parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } }, }) assert.equal((await postWebhook(cardFailed)).status, 200) assert.deepEqual(mailsWithSubject(t12Subject('k@example.invalid')), []) assert.equal(mails.sent.length, 1) }) // --------------------------------------------------------------------------- // markSwitchFailed (F27) und das Skript fuers Team (SEPA) // --------------------------------------------------------------------------- test('markSwitchFailed: gescheitert, K14 (SEPA: Abbuchungstag bleibt), T12; zweiter Aufruf ohne Mails; wirksamer Wechsel bleibt', async () => { await resetHosting({ paymentMethod: SEPA }) st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ status: 'open' }) } await switchToWartung('c1', consent, nowUnix()) const [open] = await listContractEvents('c1') mails.sent.length = 0 const result = await markSwitchFailed(open.$id, SWITCH_FAILED_REASONS.lastschrift, { doneBy: 'skript' }) assert.equal(result.event.status, 'gescheitert') assert.equal(result.event.doneBy, 'skript') assert.equal(result.event.details.grund, SWITCH_FAILED_REASONS.lastschrift) assert.equal(result.event.details.paymentMethod, 'sepa_debit', 'details bleiben erhalten') const [k14] = mailsWithSubject(K14_SUBJECT) assert.ok(k14.text.includes(SWITCH_FAILED_REASONS.lastschrift)) assert.ok(k14.text.includes(HOSTING_LAEUFT_WEITER)) assert.ok(k14.text.includes('Ihr Abbuchungstag bleibt der Tag des Wechsels.'), k14.text) assert.ok(mailsWithSubject(t12Subject('k@example.invalid'))[0].text.includes('nicht zustande gekommen')) assert.equal(mails.sent.length, 2) mails.sent.length = 0 const again = await markSwitchFailed(open.$id, 'egal') assert.ok(again.skipped) assert.deepEqual(mails.sent, []) // Ein wirksamer, bezahlter Wechsel wird nicht zurueckgesetzt await resetHosting() st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } await switchToWartung('c1', consent, nowUnix()) const [done] = await listContractEvents('c1') mails.sent.length = 0 assert.ok((await markSwitchFailed(done.$id, 'egal')).skipped) assert.equal((await listContractEvents('c1'))[0].status, 'erledigt') assert.deepEqual(mails.sent, []) await assert.rejects(markSwitchFailed('gibt-es-nicht', 'x'), (e) => e.status === 404) }) /** 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(' ')) } 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 = [] const checked = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'pruefen' }, (line) => out.push(line)) assert.equal(checked.ok, true) const printed = out.join('\n') 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') assert.equal(ev.status, 'gescheitert') assert.equal(ev.doneBy, 'skript') assert.equal(mailsWithSubject(K14_SUBJECT).length, 1) out.length = 0 const nothing = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'pruefen' }, (line) => out.push(line)) assert.equal(nothing.ok, false) 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') })