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
+520
-68
No files matched your search
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
+21
-8
@@ -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
|
||||
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)
|
||||
|
||||
+145
-30
@@ -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) {
|
||||
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(event.$id, {
|
||||
await updateContractEvent(eventId, {
|
||||
status: 'erledigt',
|
||||
doneAt: now,
|
||||
doneBy: 'stripe',
|
||||
doneBy,
|
||||
details: { ...(wasEffective ? {} : { effectiveAt: now }), paidAt: now },
|
||||
})
|
||||
if (!wasEffective) await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { wirksam: true })
|
||||
return { completed: true, eventId }
|
||||
})
|
||||
}
|
||||
|
||||
// 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
@@ -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
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user