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

+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
}
}