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

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