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
@@ -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
@@ -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
@@ -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