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
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user