diff --git a/scripts/wechsel-gescheitert.mjs b/scripts/wechsel-gescheitert.mjs index 2f5c2c0..d7a693b 100644 --- a/scripts/wechsel-gescheitert.mjs +++ b/scripts/wechsel-gescheitert.mjs @@ -9,26 +9,59 @@ * * Usage (im Container, als Datei): * node scripts/wechsel-gescheitert.mjs --pruefen (nur anzeigen: offener Wechsel, - * Metadaten vor dem Wechsel) + * Stand bei Stripe, Metadaten vor dem Wechsel, offene Handgriffe) * node scripts/wechsel-gescheitert.mjs --ausfuehren [Grund] (Ereignis gescheitert, K14, T12) - * Ohne Grund nennt K14: "Die SEPA-Lastschrift für die Wechselrechnung wurde nicht eingelöst." + * --ausfuehren prueft vorher bei Stripe, dass die Wechselrechnung storniert ist, die Abo-Position wieder den + * Hosting-Preis hat und die Metadaten zurueckgesetzt sind; sonst nennt es die fehlenden Schritte, verschickt + * nichts und endet mit Exit-Code 1 (Fix 1). Ohne Grund nennt K14: "Die SEPA-Lastschrift für die Wechselrechnung + * wurde nicht eingelöst."; ein Grund hat hoechstens 300 Zeichen und keine Woerter mit "--" (Tippfehler bei + * Schaltern landen sonst in der Kundenmail). */ import 'dotenv/config' import { config } from '../server/config.js' import { getDocument } from '../server/services/appwriteAdmin.js' import { findOpenSwitchEvent, markSwitchFailed, SWITCH_FAILED_REASONS } from '../server/services/planSwitch.js' +import { describeItems } from '../server/services/products.js' +import { getStripe } from '../server/services/stripeBilling.js' const USAGE = 'Usage: node scripts/wechsel-gescheitert.mjs --pruefen | --ausfuehren [Grund]' +const MAX_REASON = 300 /** { subscriptionId, mode: 'pruefen' | 'ausfuehren', grund? } oder null bei falschem Aufruf. */ export function parseArgs(argv) { const [subscriptionId, mode, ...rest] = argv.slice(2) if (!/^sub_\w+$/.test(subscriptionId || '')) return null if (mode === '--pruefen' && rest.length === 0) return { subscriptionId, mode: 'pruefen' } - if (mode === '--ausfuehren') return { subscriptionId, mode: 'ausfuehren', grund: rest.join(' ').trim() || SWITCH_FAILED_REASONS.lastschrift } + if (mode === '--ausfuehren') { + if (rest.some((token) => token.startsWith('--'))) return null + return { subscriptionId, mode: 'ausfuehren', grund: (rest.join(' ').trim() || SWITCH_FAILED_REASONS.lastschrift).slice(0, MAX_REASON) } + } return null } +/** + * Stand bei Stripe zum Wechsel: Wechselrechnung, Produkt der Abo-Position, Metadaten. missing = Handgriffe aus + * T12, die noch fehlen (Rechnung nicht storniert, Preis nicht Hosting, Metadaten nicht auf dem Stand vor dem Wechsel). + */ +async function stripeState(event) { + const details = event.details || {} + const stripe = getStripe() + const sub = await stripe.subscriptions.retrieve(event.subscriptionId) + const invoice = details.invoiceId ? await stripe.invoices.retrieve(details.invoiceId).catch((err) => ({ error: err.message })) : null + const productKey = describeItems(sub.items?.data).productKey + const metadata = sub.metadata || {} + const wrongKeys = Object.entries(details.previousMetadata || {}) + .filter(([key, value]) => (metadata[key] ?? null) !== value) + .map(([key]) => key) + const missing = [] + if (details.invoiceId && invoice?.status !== 'void') { + missing.push(`1. Wechselrechnung ${details.invoiceId} stornieren (jetzt: ${invoice?.status || invoice?.error || 'unbekannt'})`) + } + if (productKey !== 'hosting') missing.push(`2. Abo-Position ohne anteilige Verrechnung zurück auf den Hosting-Preis (jetzt: ${productKey || 'unbekannt'})`) + if (wrongKeys.length) missing.push(`3. Metadaten zurücksetzen: ${wrongKeys.join(', ')}`) + return { invoice, productKey, metadata, missing } +} + /** Offenen Wechsel des Abos zeigen und (ausfuehren) als gescheitert abschließen. Liefert { ok, event? }. */ export async function run({ subscriptionId, mode, grund }, log = console.log) { const sub = await getDocument(config.collections.subscriptions, subscriptionId).catch(() => null) @@ -46,14 +79,32 @@ export async function run({ subscriptionId, mode, grund }, log = console.log) { log(`Offener Wechsel ${event.$id} (Kunde ${event.customerId}, ${sub.email || 'ohne E-Mail'})`) log(` Wechsel am ${details.switchedAt || '–'}, Zahlart ${details.paymentMethod || 'unbekannt'}, Rechnung ${details.invoiceId || '–'} über ${euro}`) log(` Mindestlaufzeit des Hosting-Abos vor dem Wechsel ab ${details.previousTermStartAt || '–'}`) - log(`Metadaten am Stripe-Abo ${subscriptionId} auf den Stand vor dem Wechsel zurücksetzen:`) - for (const [key, value] of Object.entries(details.previousMetadata || {})) log(` ${key} = ${value ?? '(löschen)'}`) + const state = await stripeState(event) + log('Stand bei Stripe:') + log(` Rechnung ${details.invoiceId || '(im Ereignis nicht bekannt, in Stripe prüfen)'}: ${state.invoice?.status || state.invoice?.error || '–'}`) + log(` Abo-Position: ${state.productKey || 'unbekannt'}`) + log(`Metadaten am Stripe-Abo ${subscriptionId} auf den Stand vor dem Wechsel zurücksetzen (Soll, dahinter der Stand jetzt):`) + for (const [key, value] of Object.entries(details.previousMetadata || {})) { + log(` ${key} = ${value ?? '(löschen)'} (${key}: jetzt ${state.metadata[key] ?? '(leer)'})`) + } + if (state.missing.length) { + log('Noch offene Handgriffe:') + for (const step of state.missing) log(` ${step}`) + } if (mode === 'pruefen') { log('') log(`Nur geprüft, nichts geändert. Abschließen: node scripts/wechsel-gescheitert.mjs ${subscriptionId} --ausfuehren`) - return { ok: true, event } + return { ok: true, event, missing: state.missing } + } + if (state.missing.length) { + log('❌ Erst die offenen Handgriffe in Stripe erledigen; K14 und T12 sind nicht verschickt, das Ereignis bleibt offen.') + return { ok: false, missing: state.missing } } const result = await markSwitchFailed(event.$id, grund, { doneBy: 'skript' }) + if (!result.failed) { + log(`❌ Nichts geändert: ${result.skipped}`) + return { ok: false } + } log(`✅ Wechsel ${event.$id} ist gescheitert: K14 an den Kunden und T12 ans Team verschickt (Fehler stehen im Log).`) return { ok: true, event: result.event } } diff --git a/server/mail-templates/T12 Wechsel.html b/server/mail-templates/T12 Wechsel.html index a63ab17..e7b6db6 100644 --- a/server/mail-templates/T12 Wechsel.html +++ b/server/mail-templates/T12 Wechsel.html @@ -50,7 +50,7 @@ a{color:#111111;} - + diff --git a/server/routes/abo.js b/server/routes/abo.js index 54d2db8..58f4a17 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -17,6 +17,7 @@ import { handleSwitchInvoicePaid, handleSwitchPaymentFailed, previewSwitch, + reconcilePendingSwitch, switchToWartung, } from '../services/planSwitch.js' import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js' @@ -156,16 +157,25 @@ function isPilotAllowed(customer, offer = null) { router.get('/', requireSession, async (req, res) => { try { const customerId = getSessionCustomerId(req) - const [sub, customer, events] = await Promise.all([ - getCurrentSubscription(customerId), - getDocument(config.collections.customers, customerId).catch(() => null), - // Ohne lesbare Ereignisse (z. B. Collection noch nicht angelegt) bleibt die Seite nutzbar: die - // Widerrufsfrist des Abschlusses zaehlt dann ab startedAt (F14), offene Aufgaben fehlen + // Ohne lesbare Ereignisse (z. B. Collection noch nicht angelegt) bleibt die Seite nutzbar: die + // Widerrufsfrist des Abschlusses zaehlt dann ab startedAt (F14), offene Aufgaben fehlen + const readEvents = () => listContractEvents(customerId).catch((e) => { console.error('[abo] Vertragsereignisse nicht lesbar:', e.message) return [] - }), + }) + let [sub, customer, events] = await Promise.all([ + getCurrentSubscription(customerId), + getDocument(config.collections.customers, customerId).catch(() => null), + readEvents(), ]) + // Wechsel wartet laenger als Stripe eine Aenderung zurueckhaelt (Webhook nicht angekommen): mit Stripe + // abgleichen (Task 9, Fix 1); Fehler landen nur im Log + const pendingSwitch = sub && events.find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === sub.$id) + if (pendingSwitch && (await reconcilePendingSwitch(pendingSwitch))) { + sub = await getCurrentSubscription(customerId) + events = await readEvents() + } const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status)) const [ledger, offer] = await Promise.all([ // Arbeitszeit gibt es nur im Wartungs-Abo @@ -339,7 +349,8 @@ router.post('/switch/preview', requireSession, async (req, res) => { }) /** - * Bestellung { acceptTerms: true, earlyStart: true, termsVersion, prorationDate } ("Zahlungspflichtig bestellen"). + * Bestellung { acceptTerms: true, earlyStart: true, termsVersion, prorationDate, amountDueCents } ("Zahlungspflichtig + * bestellen"); prorationDate und amountDueCents kommen aus der Vorschau. * Nachweis: Fassung, Zeitpunkt, IP, Browser und die Zustimmung zum Sofortbeginn (§ 357a Abs. 2 BGB) landen am * Abo und im Ereignis `wechsel`. Antwort: die Abo-Ansicht wie in GET /api/abo und paymentUrl (Karte: Zahlung * noch zu bestaetigen, das Portal leitet zur Stripe-Zahlungsseite weiter; sonst null). @@ -349,14 +360,14 @@ router.post('/switch', requireSession, async (req, res) => { if (req.session.viewAs) { return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht gewechselt werden.' }) } - const { acceptTerms, earlyStart, termsVersion, prorationDate } = req.body || {} + const { acceptTerms, earlyStart, termsVersion, prorationDate, amountDueCents } = req.body || {} if (acceptTerms !== true) { return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.' }) } if (earlyStart !== true) { return res.status(400).json({ error: 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.' }) } - if (!termsVersion || !Number.isInteger(prorationDate)) { + if (!termsVersion || !Number.isInteger(prorationDate) || !Number.isInteger(amountDueCents)) { return res.status(400).json({ error: 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.' }) } if (termsVersion !== config.abo.termsVersion) { @@ -367,7 +378,9 @@ router.post('/switch', requireSession, async (req, res) => { const { subscription, paymentUrl } = await switchToWartung( customerId, { termsVersion, consentAt: now, earlyStartConsentAt: now, consentIp: req.ip, consentUserAgent: req.get('user-agent') || '' }, - prorationDate + prorationDate, + // Fix 1 (8): der in der Bestelluebersicht gezeigte Betrag; weicht die neue Vorschau ab, 409 + { expectedAmountCents: amountDueCents } ) const events = await listContractEvents(customerId).catch((e) => { console.error('[abo] Vertragsereignisse nicht lesbar:', e.message) diff --git a/server/services/planSwitch.js b/server/services/planSwitch.js index 7ed745e..8088049 100644 --- a/server/services/planSwitch.js +++ b/server/services/planSwitch.js @@ -76,8 +76,15 @@ export const SWITCH_FAILED_REASONS = Object.freeze({ lastschrift: 'Die SEPA-Lastschrift für die Wechselrechnung wurde nicht eingelöst.', }) -// Metadaten, die der Wechsel ueberschreibt und die bei einer gescheiterten Lastschrift zurueckgesetzt werden (F27) -const RESTORABLE_METADATA = ['productKey', 'termStartAt', 'switchedAt', 'switchedFrom'] +// Metadaten, die der Wechsel ueberschreibt und die bei einer gescheiterten Lastschrift zurueckgesetzt werden (F27; +// Fix 1: auch die Zustimmungsdaten des Hosting-Abschlusses) +export const RESTORABLE_METADATA = Object.freeze([ + 'productKey', 'termStartAt', 'switchedAt', 'switchedFrom', + 'termsVersion', 'termsAcceptedAt', 'earlyStartConsentAt', 'consentIp', 'consentUserAgent', +]) +// Stripe haelt eine Aenderung hoechstens 23 Stunden zurueck; danach gleicht das Lesen des Abos ab (Fix 1) +const PENDING_MAX_MS = 23 * 3600 * 1000 +const AMOUNT_CHANGED_MESSAGE = 'Der Betrag hat sich geändert – bitte Seite neu laden.' /** * Passt der Zeitpunkt der Vorschau (proration_date, Unix-Sekunden)? Die Bestelluebersicht zeigt Gutschrift, @@ -197,10 +204,19 @@ async function stripeContext(doc, customer) { } } -/** Abo (Spiegel), Kunde und Stripe-Stand fuer Vorschau und Wechsel, nach allen Voraussetzungen (404/409). */ +/** + * Abo (Spiegel), Kunde und Stripe-Stand fuer Vorschau und Wechsel, nach allen Voraussetzungen (404/409). Ein + * offener Wechsel, dessen Zahlungsfrist abgelaufen ist, wird vorher mit Stripe abgeglichen (Fix 1). + */ async function switchContext(customerId, current = null) { - const doc = current || (await requireSubscription(customerId)) - const [customer, events] = await Promise.all([customerOf(customerId), listContractEvents(customerId)]) + let doc = current || (await requireSubscription(customerId)) + const customer = await customerOf(customerId) + let events = await listContractEvents(customerId) + const open = events.find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === doc.$id) + if (open && (await reconcilePendingSwitch(open))) { + doc = await requireSubscription(customerId) + events = await listContractEvents(customerId) + } assertCanSwitch(doc, events) return { doc, customer, ...(await stripeContext(doc, customer)) } } @@ -214,13 +230,24 @@ const isProrationLine = (line) => { * Vorschau fuer die Bestelluebersicht: { prorationDate, amountDueCents, creditCents, monthlyCents, * nextChargeAt, minTermEnd }. Grundlage ist invoices.createPreview mit dem Preiswechsel der Abo-Position, * billing_cycle_anchor now und create_prorations ab jetzt. creditCents ist die Gutschrift dieses Wechsels (die - * negativen anteiligen Zeilen ab prorationDate: nicht genutzte Hosting-Tage und gebuchter Zusatzspeicher, F29); + * negativen anteiligen Zeilen ab prorationDate, nach Rabatt: nicht genutzte Hosting-Tage und gebuchter + * Zusatzspeicher, F29); * aeltere, schon vorgemerkte Posten stecken nur im faelligen Betrag (amount_due, nach Guthaben und Rabatten). * Abbuchungstag und Mindestlaufzeit rechnen ab dem Wechseltag (1 bzw. 12 Monate). */ export async function previewSwitch(customerId) { const ctx = await switchContext(customerId) - const prorationDate = Math.floor(Date.now() / 1000) + return previewFor(ctx, Math.floor(Date.now() / 1000)) +} + +// Gutschrift einer anteiligen Zeile nach Rabatt (Fix 1): ein Rabatt auf das Hosting-Abo verkleinert auch die +// Gutschrift; discount_amounts sind bei Gutschriftzeilen negativ, deshalb mit Betraegen gerechnet +const creditOfLine = (line) => { + const discount = (line.discount_amounts || []).reduce((sum, d) => sum + Math.abs(Number(d.amount) || 0), 0) + return Math.max(0, Math.abs(Number(line.amount) || 0) - discount) +} + +async function previewFor(ctx, prorationDate) { const preview = await getStripe().invoices.createPreview({ subscription: ctx.doc.$id, subscription_details: { @@ -232,15 +259,15 @@ export async function previewSwitch(customerId) { // Testkonto: die Rabatte nach dem Wechsel (sonst erbt die Vorschau die Rabatte des Abos) ...(ctx.discounts ? { discounts: ctx.discounts } : {}), }) - const credit = (preview?.lines?.data || []) + const creditCents = (preview?.lines?.data || []) .filter((line) => isProrationLine(line) && line.amount < 0) .filter((line) => !line.period?.start || line.period.start >= prorationDate - PRORATION_TOLERANCE_SECONDS) - .reduce((sum, line) => sum + (Number(line.amount) || 0), 0) + .reduce((sum, line) => sum + creditOfLine(line), 0) const start = new Date(prorationDate * 1000) return { prorationDate, amountDueCents: Number(preview?.amount_due) || 0, - creditCents: Math.abs(credit), + creditCents, monthlyCents: config.products.wartung.amountCents, nextChargeAt: addMonths(start, 1).toISOString(), minTermEnd: addMonths(start, 12).toISOString(), @@ -304,11 +331,13 @@ const switchesInFlight = new Set() /** * Wechsel ins Wartungs-Abo (Button "Zahlungspflichtig bestellen"). consent: { termsVersion, consentAt, * consentIp, consentUserAgent, earlyStartConsentAt } - ohne Zustimmung zum Sofortbeginn kein Wechsel (400, F16). - * prorationDate: Zeitpunkt der Vorschau (previewProblem). Voraussetzungen wie previewSwitch (409). + * prorationDate: Zeitpunkt der Vorschau (previewProblem). Voraussetzungen wie previewSwitch (409). Mit + * options.expectedAmountCents (Route: angezeigter Betrag) rechnet der Server die Vorschau nach; weicht der Betrag + * ab, 409 ohne Aenderung bei Stripe. * Liefert { subscription, paymentUrl, event }: paymentUrl nur, wenn Stripe die Aenderung bis zur Zahlung * zurueckhaelt (pending_update, Karte), sonst null - auch bei SEPA, dort zieht Stripe die Lastschrift ein. */ -export async function switchToWartung(customerId, consent, prorationDate) { +export async function switchToWartung(customerId, consent, prorationDate, { expectedAmountCents } = {}) { if (!consent?.termsVersion || !consent?.consentAt) { throw httpError(400, 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.') } @@ -321,19 +350,24 @@ export async function switchToWartung(customerId, consent, prorationDate) { if (switchesInFlight.has(doc.$id)) throw httpError(409, 'Ihr Wechsel wird gerade verarbeitet.') switchesInFlight.add(doc.$id) try { - return await performSwitch(customerId, doc, consent, prorationDate) + return await performSwitch(customerId, doc, consent, prorationDate, expectedAmountCents) } finally { switchesInFlight.delete(doc.$id) } } -async function performSwitch(customerId, current, consent, prorationDate) { +async function performSwitch(customerId, current, consent, prorationDate, expectedAmountCents) { const ctx = await switchContext(customerId, current) const { doc, customer, stripeSub } = ctx // Seit der Vorschau hat ein neuer Abrechnungsmonat begonnen: Gutschrift und Betrag stimmen nicht mehr if (ctx.periodStart && prorationDate < ctx.periodStart) { throw httpError(409, 'Ihr Abrechnungsmonat hat seit der Vorschau neu begonnen. Bitte Seite neu laden.') } + // Fix 1 (8): bestellt wird genau der Betrag, den die Uebersicht gezeigt hat (neue Vorschau, dasselbe proration_date) + if (expectedAmountCents !== undefined) { + const { amountDueCents } = await previewFor(ctx, prorationDate) + if (amountDueCents !== expectedAmountCents) throw httpError(409, AMOUNT_CHANGED_MESSAGE) + } const sepa = ctx.paymentMethod === 'sepa_debit' const switchedAt = new Date().toISOString() // Stripe-Metadaten am Abo (max. 500 Zeichen je Wert): Produkt, neue Mindestlaufzeit, Nachweis der Zustimmung @@ -393,11 +427,15 @@ async function performSwitch(customerId, current, consent, prorationDate) { invoiceId: idOf(updated.latest_invoice), // F12/F27: Link zur Stripe-Zahlungsseite, solange die Zahlung offen ist (GET /api/abo switchPending) paymentUrl, + // Fix 1 (2): Ablauf der zurueckgehaltenen Aenderung, fuer den Abgleich ohne Webhook + pendingExpiresAt: pending && updated.pending_update.expires_at ? new Date(updated.pending_update.expires_at * 1000).toISOString() : null, effectiveAt: effective ? switchedAt : null, paidAt: effective && paid ? switchedAt : null, // F27: Stand vor dem Wechsel, damit eine gescheiterte Lastschrift ihn wiederherstellen kann previousTermStartAt: termStartOf(stripeSub).toISOString(), - previousMetadata: Object.fromEntries(RESTORABLE_METADATA.map((key) => [key, stripeSub.metadata?.[key] ?? null])), + previousMetadata: Object.fromEntries( + RESTORABLE_METADATA.map((key) => [key, stripeSub.metadata?.[key] == null ? null : String(stripeSub.metadata[key]).slice(0, 300)]) + ), }, }) @@ -446,16 +484,35 @@ async function recordSwitchEvent({ doc, customerId, consent, status, details }) * Wechsel ist wirksam und bezahlt: Ereignis erledigt (F01, updateContractEvent). War er noch nicht wirksam * (Karte, Zahlung stand aus), geht jetzt T12 raus; bei SEPA ging es schon beim Klick raus. */ -async function completeSwitch(event, contact) { - const now = new Date().toISOString() - const wasEffective = Boolean(event.details?.effectiveAt) - await updateContractEvent(event.$id, { - status: 'erledigt', - doneAt: now, - doneBy: 'stripe', - details: { ...(wasEffective ? {} : { effectiveAt: now }), paidAt: now }, +async function completeSwitch(eventId, contact, doneBy = 'stripe') { + return withEventLock(eventId, async () => { + // Frisch lesen: ein anderer Webhook (oder der Abgleich) kann den Wechsel gerade abgeschlossen haben + const event = await getContractEvent(eventId) + if (event.status !== 'offen') return { skipped: `Wechsel ist schon ${event.status}` } + const now = new Date().toISOString() + const wasEffective = Boolean(event.details?.effectiveAt) + await updateContractEvent(eventId, { + status: 'erledigt', + doneAt: now, + doneBy, + details: { ...(wasEffective ? {} : { effectiveAt: now }), paidAt: now }, + }) + if (!wasEffective) await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { wirksam: true }) + return { completed: true, eventId } }) - if (!wasEffective) await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { wirksam: true }) +} + +// Fix 1 (4): ein Wechsel-Ereignis wird im Prozess nur von einem Ablauf zugleich geaendert (invoice.paid und +// pending_update_applied, Wiederholungen, Abgleich); ein zweiter gleichzeitiger Ablauf tut nichts +const eventsInProgress = new Set() +async function withEventLock(eventId, fn) { + if (eventsInProgress.has(eventId)) return { skipped: 'wird gerade bearbeitet' } + eventsInProgress.add(eventId) + try { + return await fn() + } finally { + eventsInProgress.delete(eventId) + } } /** @@ -465,6 +522,10 @@ async function completeSwitch(event, contact) { * (options.doneBy 'skript'). Nur ein offenes Ereignis wird geaendert; sonst { skipped } ohne Mails. */ export async function markSwitchFailed(eventId, grund, { doneBy = 'stripe' } = {}) { + return withEventLock(eventId, () => failSwitch(eventId, grund, doneBy)) +} + +async function failSwitch(eventId, grund, doneBy) { const event = await getContractEvent(eventId) if (event.type !== 'wechsel') throw httpError(400, `Ereignis ${eventId} ist kein Wechsel.`) if (event.status !== 'offen') return { skipped: `Wechsel ist schon ${event.status}`, event } @@ -497,8 +558,7 @@ export async function handlePendingUpdateApplied(subscriptionId) { const event = await findOpenSwitchEvent(sub.customerId, subscriptionId) // Kein offenes Ereignis: invoice.paid war schneller (schon erledigt) oder eine Wiederholung if (!event) return { skipped: 'kein offener Wechsel' } - await completeSwitch(event, await contactOf(sub.customerId, sub.email)) - return { completed: true, eventId: event.$id } + return completeSwitch(event.$id, await contactOf(sub.customerId, sub.email)) } /** customer.subscription.pending_update_expired: nicht rechtzeitig bezahlt, Stripe hat die Rechnung storniert. */ @@ -531,31 +591,86 @@ export async function handleSwitchInvoicePaid(invoice) { const stripeSub = await getStripe().subscriptions.retrieve(subscriptionId) if (stripeSub.pending_update) return { skipped: 'Stripe hält die Änderung noch zurück' } if (!event.details?.effectiveAt) await syncSubscription(subscriptionId) - await completeSwitch(event, await contactOf(doc.customerId, doc.email)) - return { completed: true, eventId: event.$id } + return completeSwitch(event.$id, await contactOf(doc.customerId, doc.email)) } /** - * invoice.payment_failed einer Wechselrechnung bei SEPA-Lastschrift (Abo mit switchedAt in den letzten 30 - * Tagen, billing_reason subscription_update, keine zurueckgehaltene Aenderung): T12 mit den Handgriffen fuer - * das Team. Das Ereignis bleibt offen, bis das Team den Wechsel mit dem Skript als gescheitert abschliesst. - * Bei Karte haelt Stripe die Aenderung zurueck (kein switchedAt), dort greift pending_update_expired. + * invoice.payment_failed einer Wechselrechnung bei SEPA-Lastschrift: T12 mit den Handgriffen fuers Team. Nur fuer + * den echten Wechsel (Fix 1): offenes Ereignis `wechsel` des Abos mit Zahlart sepa_debit, schon wirksam und fuer + * genau diese Rechnung (details.invoiceId; fehlt sie, wird sie jetzt gespeichert). details.lastschriftFailedAt + * verhindert ein zweites T12 bei Wiederholungen oder erneuten Einzugsversuchen. Das Ereignis bleibt offen, bis das + * Team den Wechsel mit dem Skript als gescheitert abschliesst. Ohne Ereignis (Nachweis fehlte beim Klick) gilt + * ersatzweise der Stand bei Stripe: switchedAt in den letzten 30 Tagen, keine zurueckgehaltene Aenderung - dann T12 + * mit dem Hinweis nachweis_fehlt. Bei Karte haelt Stripe die Aenderung zurueck, dort greift pending_update_expired. */ export async function handleSwitchPaymentFailed(invoice) { if (invoice?.billing_reason !== 'subscription_update') return { skipped: 'keine Wechselrechnung' } const subscriptionId = idOf(subscriptionIdOfInvoice(invoice)) if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' } const stripeSub = await getStripe().subscriptions.retrieve(subscriptionId) - const switchedAt = Date.parse(stripeSub.metadata?.switchedAt || '') + const meta = stripeSub.metadata || {} + const customerId = meta.webklarCustomerId || null + const switches = customerId + ? (await listContractEvents(customerId)).filter((e) => e.type === 'wechsel' && e.subscriptionId === subscriptionId) + : [] + const open = switches.find((e) => e.status === 'offen') + const contact = await contactOf(customerId, meta.email || invoice.customer_email) + if (open) { + return withEventLock(open.$id, async () => { + const event = await getContractEvent(open.$id) + const d = event.details || {} + if (event.status !== 'offen' || d.paymentMethod !== 'sepa_debit' || !d.effectiveAt) return { skipped: 'kein wirksamer SEPA-Wechsel' } + if (d.invoiceId && d.invoiceId !== invoice.id) return { skipped: 'nicht die Wechselrechnung' } + if (d.lastschriftFailedAt) return { skipped: 'schon gemeldet' } + await updateContractEvent(event.$id, { details: { lastschriftFailedAt: new Date().toISOString(), invoiceId: invoice.id } }) + await sendSwitchNotice(contact, subscriptionId, { switchedAt: d.switchedAt, amountDueCents: invoice.amount_due }, { lastschrift_gescheitert: true }) + return { notified: true, eventId: event.$id } + }) + } + // Rueckfall ohne Ereignis: nur, wenn kein Ereignis zu diesem Wechsel existiert (ein erledigter bleibt unberuehrt) + const switchedAt = Date.parse(meta.switchedAt || '') if (!switchedAt || Date.now() - switchedAt > SEPA_FAILURE_WINDOW_MS || stripeSub.pending_update) { return { skipped: 'kein wirksamer Wechsel in den letzten 30 Tagen' } } - const contact = await contactOf(stripeSub.metadata?.webklarCustomerId, stripeSub.metadata?.email || invoice.customer_email) + if (switches.some((e) => e.details?.switchedAt === meta.switchedAt)) return { skipped: 'Wechsel schon abgeschlossen' } await sendSwitchNotice( contact, subscriptionId, { switchedAt: new Date(switchedAt).toISOString(), amountDueCents: invoice.amount_due }, - { lastschrift_gescheitert: true } + { lastschrift_gescheitert: true, nachweis_fehlt: true } ) - return { notified: true } + return { notified: true, eventId: null } +} + +/** + * Abgleich ohne Webhook (Fix 1): ein offener Wechsel, der noch nicht wirksam ist (Karte, Zahlung stand aus), und + * dessen zurueckgehaltene Aenderung laengst verfallen sein muesste (details.pendingExpiresAt, sonst 23 Stunden nach + * dem Wechsel), wird mit einem Abruf bei Stripe geklaert: keine zurueckgehaltene Aenderung mehr und Preis noch + * Hosting -> gescheitert (K14, wie pending_update_expired); Preis Wartung -> erledigt (T12, wie + * pending_update_applied). Aufgerufen beim Lesen des Abos (GET /api/abo) und vor Vorschau und Wechsel. Liefert + * 'gescheitert' | 'erledigt' | null; Fehler landen nur im Log. + */ +export async function reconcilePendingSwitch(event, now = Date.now()) { + try { + if (event?.type !== 'wechsel' || event.status !== 'offen' || event.details?.effectiveAt) return null + const switchedAt = Date.parse(event.details?.switchedAt || event.consentAt || '') + const expiresAt = Date.parse(event.details?.pendingExpiresAt || '') || (switchedAt ? switchedAt + PENDING_MAX_MS : NaN) + if (!expiresAt || now < expiresAt) return null + const stripeSub = await getStripe().subscriptions.retrieve(event.subscriptionId) + if (stripeSub.pending_update) return null + const productKey = describeItems(stripeSub.items?.data).productKey + if (productKey === 'hosting') { + const result = await markSwitchFailed(event.$id, SWITCH_FAILED_REASONS.abgelaufen, { doneBy: 'abgleich' }) + return result.failed ? 'gescheitert' : null + } + if (productKey === 'wartung') { + const sub = (await syncSubscription(event.subscriptionId)) || null + const result = await completeSwitch(event.$id, await contactOf(event.customerId, sub?.email), 'abgleich') + return result.completed ? 'erledigt' : null + } + return null + } catch (err) { + console.error(`[wechsel] Abgleich von Wechsel ${event?.$id} mit Stripe fehlgeschlagen:`, err.message) + return null + } } diff --git a/test/planSwitch.test.js b/test/planSwitch.test.js index a9c9072..6478f01 100644 --- a/test/planSwitch.test.js +++ b/test/planSwitch.test.js @@ -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') +}) diff --git a/test/support.test.js b/test/support.test.js index 0f00fd7..9926012 100644 --- a/test/support.test.js +++ b/test/support.test.js @@ -149,13 +149,24 @@ test('Stripe-Nachbau: ausstehende Aktualisierung bleibt zurückgehalten, bis sie // Wie Stripe: Antwort und gespeichertes Abo zeigen den alten Stand assert.equal(pending.metadata.productKey, 'hosting') assert.equal(pending.items.data[0].price.id, 'price_0') + // Wie Stripe (Fix 1): das Abo traegt pending_update, bis die Aenderung uebernommen oder verworfen wird const stored = await stripe.subscriptions.retrieve('sub_p') assert.equal(stored.metadata.productKey, 'hosting') - assert.equal(stored.pending_update, undefined) + assert.deepEqual(stored.pending_update, { expires_at: 1 }) // Bezahlt: die zurueckgehaltene Aenderung wird uebernommen const applied = st.applyPendingUpdate('sub_p') assert.equal(applied.metadata.productKey, 'wartung') - assert.equal((await stripe.subscriptions.retrieve('sub_p')).items.data[0].price.id, 'price_b') + const after = await stripe.subscriptions.retrieve('sub_p') + assert.equal(after.items.data[0].price.id, 'price_b') + assert.equal(after.pending_update, null) + assert.throws(() => st.applyPendingUpdate('sub_p'), /keine ausstehende/) + // Verfallen: Stripe verwirft die Aenderung, das Abo bleibt beim alten Stand + st.nextSubscriptionUpdate = { pending_update: { expires_at: 2 } } + await stripe.subscriptions.update('sub_p', { metadata: { productKey: 'neu' }, payment_behavior: 'pending_if_incomplete' }) + st.expirePendingUpdate('sub_p') + const expired = await stripe.subscriptions.retrieve('sub_p') + assert.equal(expired.metadata.productKey, 'wartung') + assert.equal(expired.pending_update, null) assert.throws(() => st.applyPendingUpdate('sub_p'), /keine ausstehende/) }) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index a416364..919d5ac 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -8,10 +8,11 @@ import http from 'node:http' * Abo-Änderungen (F17): POST /v1/subscriptions/:id mischt items[i][id|price|quantity|deleted] und * metadata (leerer Wert löscht) in das gespeicherte Abo; st.nextSubscriptionUpdate wird einmalig nur * in die nächste Antwort gemischt (z. B. latest_invoice). Enthält er pending_update, bleibt das Abo wie bei - * Stripe unverändert (ausstehende Aktualisierung, Task 9), bis st.applyPendingUpdate(id) die Änderung - * übernimmt. Wie Stripe: mit payment_behavior=pending_if_incomplete nur die dafür erlaubten Parameter, - * Metadaten-Schlüssel höchstens 40 und Werte höchstens 500 Zeichen. Rechnungen: GET /v1/invoices/:id - * bettet wie Stripe höchstens 10 Zeilen ein, GET /v1/invoices/:id/lines blättert (limit, starting_after); + * Stripe unverändert und trägt pending_update (ausstehende Aktualisierung, Task 9), bis st.applyPendingUpdate(id) + * die Änderung übernimmt oder st.expirePendingUpdate(id) sie verwirft. Wie Stripe: mit + * payment_behavior=pending_if_incomplete nur die dafür erlaubten Parameter, Metadaten-Schlüssel höchstens 40 + * und Werte höchstens 500 Zeichen. Rechnungen: GET /v1/invoices/:id bettet wie Stripe höchstens 10 Zeilen ein, + * GET /v1/invoices/:id/lines blättert (limit, starting_after); * POST /v1/invoices/create_preview liefert st.invoicePreview (Objekt, oder Funktion des Aufruf-Bodys). */ @@ -157,8 +158,10 @@ export async function startFakeStripe() { nextSubscriptionUpdate = null if (extra?.pending_update) { // Zahlung nicht erfolgt (Task 9): Stripe haelt die Aenderung in pending_update zurueck, das Abo bleibt - // unveraendert, bis die Rechnung bezahlt ist (im Test: applyPendingUpdate) + // unveraendert und traegt pending_update, bis die Rechnung bezahlt ist (applyPendingUpdate) oder die + // Aenderung verfaellt (expirePendingUpdate) pendingUpdates.set(m[1], updated) + subscriptions.set(m[1], { ...existing, pending_update: extra.pending_update }) return [200, { ...existing, ...extra }] } subscriptions.set(m[1], updated) @@ -372,8 +375,8 @@ export async function startFakeStripe() { checkoutSessions, /** * Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit - * pending_update bleibt das Abo wie bei Stripe unveraendert, bis applyPendingUpdate(id) die Aenderung - * uebernimmt. + * pending_update bleibt das Abo wie bei Stripe unveraendert (und traegt pending_update), bis + * applyPendingUpdate(id) die Aenderung uebernimmt oder expirePendingUpdate(id) sie verwirft. */ get nextSubscriptionUpdate() { return nextSubscriptionUpdate @@ -386,8 +389,15 @@ export async function startFakeStripe() { const updated = pendingUpdates.get(id) if (!updated) throw new Error(`Stripe-Nachbau: keine ausstehende Aktualisierung fuer ${id}`) pendingUpdates.delete(id) - subscriptions.set(id, updated) - return updated + const applied = { ...updated, pending_update: null } + subscriptions.set(id, applied) + return applied + }, + /** Zurueckgehaltene Aenderung verfaellt (Stripe storniert die Rechnung und verwirft pending_update). */ + expirePendingUpdate(id) { + if (!pendingUpdates.has(id)) throw new Error(`Stripe-Nachbau: keine ausstehende Aktualisierung fuer ${id}`) + pendingUpdates.delete(id) + subscriptions.set(id, { ...subscriptions.get(id), pending_update: null }) }, /** Antwort von POST /v1/invoices/create_preview: Objekt oder Funktion (body) => Objekt; null = leere Vorschau. */ get invoicePreview() {