#!/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 --pruefen (nur anzeigen: offener Wechsel, * Stand bei Stripe, Metadaten vor dem Wechsel, offene Handgriffe) * node scripts/wechsel-gescheitert.mjs --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 --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) }) }