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:
1 parent
bc20ce0673
commit
662f8d8144
7 files changed
+528
-76
No files matched your search
+151
-36
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user