Wechsel: Lastschrift-Fehler nur für den echten Wechsel, Abgleich ohne Webhook, Gutschrift netto, Skript prüft Stripe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-29 09:24:02 +00:00
1 parent bc20ce0673
commit 662f8d8144
7 files changed
+528 -76

No files matched your search

+57 -6
View File
@@ -9,26 +9,59 @@
*
* Usage (im Container, als Datei):
* node scripts/wechsel-gescheitert.mjs <Stripe-Abo-ID> --pruefen (nur anzeigen: offener Wechsel,
* Metadaten vor dem Wechsel)
* Stand bei Stripe, Metadaten vor dem Wechsel, offene Handgriffe)
* node scripts/wechsel-gescheitert.mjs <Stripe-Abo-ID> --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 <Stripe-Abo-ID> --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 }
}
+1 -1
View File
@@ -50,7 +50,7 @@ a{color:#111111;}
<!-- ENDE gescheitert -->
<!-- WENN lastschrift_gescheitert -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung: Lastschrift gescheitert</div><div class="mut" style="color:#4a4843;">Die SEPA-Lastschrift für die Wechselrechnung ist gescheitert. Der Wechsel gilt damit als nicht erfolgt (Vertragsbedingungen § 9 Abs. 3). Bitte gleich in Stripe:<br>1. Die Wechselrechnung sofort stornieren, damit ein automatischer Einzugsversuch sie nicht mehr einzieht.<br>2. Die Abo-Position ohne anteilige Verrechnung zurück auf den Hosting-Preis setzen. Der Abbuchungstag bleibt der Wechseltag.<br>3. Die Metadaten productKey, termStartAt, switchedAt und switchedFrom auf den Stand vor dem Wechsel zurücksetzen. Die Werte stehen im Ereignis „wechsel“; im Container zeigt sie: node scripts/wechsel-gescheitert.mjs {{stripe_abo_id}} --pruefen<br>4. Ereignis auf „gescheitert“ setzen und K14 an den Kunden senden: node scripts/wechsel-gescheitert.mjs {{stripe_abo_id}} --ausfuehren<br>Weitere Postfächer, die der Kunde seit dem Wechsel beauftragt hat, nicht einrichten.</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung: Lastschrift gescheitert</div><div class="mut" style="color:#4a4843;">Die SEPA-Lastschrift für die Wechselrechnung ist gescheitert. Der Wechsel gilt damit als nicht erfolgt (Vertragsbedingungen § 9 Abs. 3). Bitte gleich in Stripe:<br>1. Die Wechselrechnung sofort stornieren, damit ein automatischer Einzugsversuch sie nicht mehr einzieht.<br>2. Die Abo-Position ohne anteilige Verrechnung zurück auf den Hosting-Preis setzen. Der Abbuchungstag bleibt der Wechseltag.<br>3. Die Metadaten productKey, termStartAt, switchedAt, switchedFrom und die Zustimmungsdaten (termsVersion, termsAcceptedAt, earlyStartConsentAt, consentIp, consentUserAgent) auf den Stand vor dem Wechsel zurücksetzen. Die Werte stehen im Ereignis „wechsel“; im Container zeigt sie mit dem aktuellen Stand bei Stripe: node scripts/wechsel-gescheitert.mjs {{stripe_abo_id}} --pruefen<br>4. Ereignis auf „gescheitert“ setzen und K14 an den Kunden senden (das Skript prüft vorher die Schritte 1 bis 3): node scripts/wechsel-gescheitert.mjs {{stripe_abo_id}} --ausfuehren<br>Weitere Postfächer, die der Kunde seit dem Wechsel beauftragt hat, nicht einrichten.</div></td></tr></table></td></tr>
<!-- ENDE lastschrift_gescheitert -->
<!-- WENN nachweis_fehlt -->
+23 -10
View File
@@ -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)
+151 -36
View File
@@ -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
}
}
+264 -12
View File
@@ -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')
})
+13 -2
View File
@@ -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/)
})
+19 -9
View File
@@ -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() {