Files
Webklar-Kundenbereich/scripts/wechsel-gescheitert.mjs
T

129 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Wechsel ins Wartungs-Abo als gescheitert abschließen, wenn die SEPA-Lastschrift der Wechselrechnung nicht
* eingelöst wurde (Vertragsbedingungen § 9 Abs. 3, Rechtsrunde 28.09.2026). Das Team bekommt dann T12 mit den
* Handgriffen: Wechselrechnung stornieren, Preis ohne Verrechnung zurück auf Hosting, Metadaten zurücksetzen.
* Danach setzt dieses Skript das Ereignis `wechsel` auf `gescheitert` und schickt K14 an den Kunden und T12
* (gescheitert) ans Team (markSwitchFailed in server/services/planSwitch.js, wie beim Webhook
* customer.subscription.pending_update_expired für Karten).
*
* Usage (im Container, als Datei):
* node scripts/wechsel-gescheitert.mjs <Stripe-Abo-ID> --pruefen (nur anzeigen: offener Wechsel,
* Stand bei Stripe, Metadaten vor dem Wechsel, offene Handgriffe)
* node scripts/wechsel-gescheitert.mjs <Stripe-Abo-ID> --ausfuehren [Grund] (Ereignis gescheitert, K14, T12)
* --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') {
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)
if (!sub) {
log(`❌ Abo ${subscriptionId} ist im Kundenportal nicht bekannt.`)
return { ok: false }
}
const event = await findOpenSwitchEvent(sub.customerId, subscriptionId)
if (!event) {
log(`❌ Kein offener Wechsel für Abo ${subscriptionId}.`)
return { ok: false }
}
const details = event.details || {}
const euro = `${(Number(details.amountDueCents || 0) / 100).toFixed(2).replace('.', ',')} €`
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 || '–'}`)
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, 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 }
}
async function main() {
const args = parseArgs(process.argv)
if (!args) {
console.error(`❌ ${USAGE}`)
process.exit(1)
}
const result = await run(args)
if (!result.ok) process.exitCode = 1
}
// Nur beim direkten Aufruf laufen lassen, nicht wenn ein Test das Modul importiert
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error('❌', err.message)
process.exit(1)
})
}