Files
Webklar-Kundenbereich/server/services/planSwitch.js
T

728 lines
38 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.
import { readFile } from 'node:fs/promises'
import { config } from '../config.js'
import { getDocument } from './appwriteAdmin.js'
import { eventIdFor, getContractEvent, listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { contractAttachmentFor, describeItems } from './products.js'
import { getFeatureDoc } from './siteFeatures.js'
import {
addMonths,
ensurePrice,
ensureTestCoupon,
getCurrentSubscription,
getStripe,
subscriptionIdOfInvoice,
syncSubscription,
termStartOf,
} from './stripeBilling.js'
/**
* Wechsel vom Hosting- ins Wartungs-Abo (Spec 3.5; Bedingungen Fassung 4 § 9, § 7 Abs. 4, § 2 Abs. 5).
*
* Dasselbe Stripe-Abo wechselt den Preis der Abo-Position von Hosting auf Wartung: neuer Abbuchungstag
* (billing_cycle_anchor now), Gutschrift fuer die nicht genutzten Hosting-Tage (create_prorations mit dem
* proration_date der Vorschau) und 12 neue Monate Mindestlaufzeit ab dem Wechseltag (Metadaten termStartAt,
* switchedAt, switchedFrom).
*
* Rechtsrunde 28.09.2026: Der Wechsel wird erst mit der Zahlung wirksam (§ 9 Abs. 3).
* - Karte (und jede Zahlart ausser SEPA, auch unbekannt): payment_behavior pending_if_incomplete. Ist die
* Zahlung nicht sofort erfolgreich (3-D Secure, Ablehnung), haelt Stripe Preis, Anker, Metadaten und Rabatte
* in pending_update zurueck: Ereignis `wechsel` offen, paymentUrl = Stripe-Zahlungsseite der Rechnung,
* Spiegel unveraendert. Wirksam mit customer.subscription.pending_update_applied (oder invoice.paid, was
* zuerst ankommt); verfaellt die Aenderung (pending_update_expired, Stripe storniert die Rechnung), ist der
* Wechsel gescheitert (markSwitchFailed: K14 an den Kunden, T12 ans Team).
* - SEPA-Lastschrift: Stripe kennt dafuer keine ausstehenden Aktualisierungen, also allow_incomplete - der
* Wechsel gilt sofort, das Ereignis bleibt offen, bis invoice.paid kommt. Scheitert die Lastschrift
* (invoice.payment_failed), bekommt das Team T12 mit den Handgriffen und schliesst den Wechsel danach mit
* scripts/wechsel-gescheitert.mjs ab (markSwitchFailed).
* - K12 (Vertragsbestaetigung mit PDF der Fassung 4) geht sofort nach dem Klick raus, T12 erst, wenn der
* Wechsel wirksam ist. Mailfehler werden protokolliert und brechen den Wechsel nie ab.
*/
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
const fmtDate = (value) =>
value
? new Date(value).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
: '–'
const fmtDateTime = (value) =>
new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
// Preis wie in den Bedingungen: "41,00 €" (normales Leerzeichen)
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
const idOf = (ref) => (typeof ref === 'string' ? ref : ref?.id || null)
const DAY_MS = 86400000
// Die Bestelluebersicht rechnet ab dem Zeitpunkt der Vorschau; aelter als 30 Minuten -> Seite neu laden
const PREVIEW_MAX_AGE_SECONDS = 30 * 60
// Uhrabweichung zwischen Browser-Anfrage und Server, die eine Vorschau "aus der Zukunft" noch haben darf
const PREVIEW_CLOCK_SKEW_SECONDS = 5 * 60
// Toleranz fuer Sekunden zwischen proration_date und den Zeilen der Stripe-Vorschau (wie beim Zusatzspeicher)
const PRORATION_TOLERANCE_SECONDS = 300
// invoice.payment_failed einer Wechselrechnung zaehlt als gescheiterte Lastschrift, wenn der Wechsel so lange her ist
const SEPA_FAILURE_WINDOW_MS = 30 * DAY_MS
const PENDING_MESSAGE = 'Ein Wechsel wartet noch auf die Zahlung.'
const ONLY_HOSTING_MESSAGE = 'Nur ein Hosting-Abo kann ins Wartungs-Abo wechseln.'
const NOT_ACTIVE_MESSAGE = 'Der Wechsel ist erst nach Beginn Ihres Hosting-Abos möglich und nicht, solange eine Zahlung offen ist.'
const CANCELLED_MESSAGE = 'Ihr Hosting-Abo ist gekündigt. Ein Wechsel ist deshalb nicht möglich.'
/** Grund in K14 ({{grund}}) je nach Weg: Karte (ausstehende Aktualisierung verfallen) oder SEPA (Skript). */
export const SWITCH_FAILED_REASONS = Object.freeze({
abgelaufen:
'Die Zahlung der Wechselrechnung wurde nicht rechtzeitig abgeschlossen, zum Beispiel weil die von Ihrer Bank verlangte Bestätigung nicht erfolgt ist.',
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;
// 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,
* Betrag, Abbuchungstag und Mindestlaufzeit ab diesem Zeitpunkt, der Wechsel rechnet mit demselben
* proration_date. null = in Ordnung, sonst { status, message }: keine ganze Zahl oder aus der Zukunft 400;
* aelter als 30 Minuten oder an einem anderen Kalendertag in Deutschland (angezeigter Wechseltag) 409.
*/
export function previewProblem(prorationDate, now = Date.now()) {
if (!Number.isInteger(prorationDate) || prorationDate <= 0) {
return { status: 400, message: 'Die Vorschau fehlt. Bitte laden Sie die Seite neu.' }
}
const nowSeconds = Math.floor(now / 1000)
if (prorationDate > nowSeconds + PREVIEW_CLOCK_SKEW_SECONDS) {
return { status: 400, message: 'Die Vorschau ist ungültig. Bitte laden Sie die Seite neu.' }
}
if (nowSeconds - prorationDate > PREVIEW_MAX_AGE_SECONDS) {
return { status: 409, message: 'Die Vorschau ist älter als 30 Minuten. Bitte Seite neu laden.' }
}
if (fmtDate(prorationDate * 1000) !== fmtDate(now)) {
return { status: 409, message: 'Der Wechseltag hat sich durch den Datumswechsel verschoben. Bitte Seite neu laden.' }
}
return null
}
/**
* Zahlart des Abos (F24): die Standard-Zahlungsmethode des Abos, sonst die des Kunden, aus
* subscriptions.retrieve(id, { expand: ['default_payment_method', 'customer.invoice_settings.default_payment_method'] }).
* Nicht erweitert (nur eine ID), unbekannt oder keine: null - dann gilt der Weg fuer Karten.
*/
export function paymentMethodTypeOf(stripeSub) {
const customer = stripeSub?.customer && typeof stripeSub.customer === 'object' ? stripeSub.customer : null
for (const method of [stripeSub?.default_payment_method, customer?.invoice_settings?.default_payment_method]) {
if (method && typeof method === 'object' && method.type) return method.type
}
return null
}
/** Offenes Ereignis `wechsel` eines Abos (hoechstens eines, siehe 409 in switchToWartung) oder null. */
export async function findOpenSwitchEvent(customerId, subscriptionId) {
if (!customerId || !subscriptionId) return null
const events = await listContractEvents(customerId)
return events.find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === subscriptionId) || null
}
async function requireSubscription(customerId) {
const doc = await getCurrentSubscription(customerId)
if (!doc) throw httpError(404, 'Kein Abo vorhanden.')
return doc
}
const customerOf = (customerId) =>
customerId ? getDocument(config.collections.customers, customerId).catch(() => null) : Promise.resolve(null)
/** Name und E-Mail fuer K12/K14/T12: Kundendaten des Portals, sonst die E-Mail am Abo. */
async function contactOf(customerId, fallbackEmail = '') {
const customer = await customerOf(customerId)
return { name: customer?.name || '', email: customer?.email || fallbackEmail || '' }
}
/**
* Voraussetzungen im Portal (Spiegel und Ereignisse, wie canSwitch in GET /api/abo): Hosting-Abo, Status
* active, nicht gekuendigt (cancelAtPeriodEnd oder cancelAt), kein Wechsel, der auf die Zahlung wartet.
*/
function assertCanSwitch(doc, events) {
if ((doc.productKey || 'wartung') !== 'hosting') throw httpError(409, ONLY_HOSTING_MESSAGE)
if (doc.status !== 'active') throw httpError(409, NOT_ACTIVE_MESSAGE)
if (doc.cancelAtPeriodEnd || doc.cancelAt) throw httpError(409, CANCELLED_MESSAGE)
if (events.some((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === doc.$id)) {
throw httpError(409, PENDING_MESSAGE)
}
}
// Coupon eines Rabatts (API 2025-10-29.clover: discount.source.coupon; aeltere Versionen: discount.coupon)
const couponIdOf = (discount) => idOf(discount?.source?.coupon ?? discount?.coupon)
/**
* Rabatte eines Testkontos nach dem Wechsel: WEBKLAR-TEST-050-HOSTING faellt weg, WEBKLAR-TEST-050 kommt dazu,
* andere Rabatte (z. B. ein laufendes Angebot) bleiben. Ohne Aufloesung (nur ID) bleibt ein Rabatt ebenfalls.
*/
async function testAccountDiscounts(stripeSub) {
const hostingCoupon = config.products.hosting.testCouponId
const wartungCoupon = await ensureTestCoupon('wartung')
const keep = (stripeSub.discounts || [])
.filter((discount) => discount && ![hostingCoupon, wartungCoupon].includes(couponIdOf(discount)))
.map((discount) => ({ discount: idOf(discount) }))
return [...keep, { coupon: wartungCoupon }]
}
/**
* Stripe-Stand des Abos fuer Vorschau und Wechsel. Stripe ist die Wahrheit: ein veralteter Spiegel (Webhook
* noch unterwegs) darf keinen zweiten Wechsel ausloesen - schon Wartung, zurueckgehaltene Aenderung
* (pending_update), anderer Status oder Kuendigung bei Stripe -> 409.
*/
async function stripeContext(doc, customer) {
const stripe = getStripe()
const [wartungPriceId, stripeSub] = await Promise.all([
ensurePrice('wartung'),
stripe.subscriptions.retrieve(doc.$id, {
expand: ['default_payment_method', 'customer.invoice_settings.default_payment_method', 'discounts'],
}),
])
const items = describeItems(stripeSub.items?.data)
if (items.productKey !== 'hosting' || !items.aboItemId) throw httpError(409, ONLY_HOSTING_MESSAGE)
if (stripeSub.pending_update) throw httpError(409, PENDING_MESSAGE)
if (stripeSub.status !== 'active') throw httpError(409, NOT_ACTIVE_MESSAGE)
if (stripeSub.cancel_at_period_end || stripeSub.cancel_at) throw httpError(409, CANCELLED_MESSAGE)
const aboItem = stripeSub.items.data.find((item) => item.id === items.aboItemId)
const testAccount = config.abo.testEmails.includes(String(customer?.email || doc.email || '').toLowerCase())
return {
stripeSub,
wartungPriceId,
aboItemId: items.aboItemId,
storageUnits: items.storageUnits,
// Beginn des laufenden Abrechnungsmonats (seit API 2025-03 am Posten)
periodStart: aboItem?.current_period_start ?? stripeSub.current_period_start ?? null,
paymentMethod: paymentMethodTypeOf(stripeSub),
discounts: testAccount ? await testAccountDiscounts(stripeSub) : null,
}
}
/**
* 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) {
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)) }
}
const isProrationLine = (line) => {
const details = line?.parent?.subscription_item_details || line?.parent?.invoice_item_details || {}
return details.proration === true
}
/**
* 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, 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)
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: {
items: [{ id: ctx.aboItemId, price: ctx.wartungPriceId }],
billing_cycle_anchor: 'now',
proration_behavior: 'create_prorations',
proration_date: prorationDate,
},
// Testkonto: die Rabatte nach dem Wechsel (sonst erbt die Vorschau die Rabatte des Abos)
...(ctx.discounts ? { discounts: ctx.discounts } : {}),
})
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 + creditOfLine(line), 0)
const start = new Date(prorationDate * 1000)
return {
prorationDate,
amountDueCents: Number(preview?.amount_due) || 0,
creditCents,
monthlyCents: config.products.wartung.amountCents,
nextChargeAt: addMonths(start, 1).toISOString(),
minTermEnd: addMonths(start, 12).toISOString(),
}
}
/** Vertragsunterlagen (PDF) in der zugestimmten Fassung; ohne Datei geht K12 ohne Anhang raus (Fehler im Log). */
async function contractPdfAttachment(termsVersion) {
const { filename, path } = contractAttachmentFor(termsVersion)
const content = await readFile(path).catch((err) => {
console.error(`[wechsel] Vertragsunterlagen ${filename} nicht lesbar:`, err.message)
return null
})
return content ? [{ filename, content, contentType: 'application/pdf' }] : []
}
/** K12 an den Kunden: Vertragsbestaetigung mit PDF. Ein Mailfehler bricht den Wechsel nicht ab. */
async function sendSwitchConfirmation(contact, consent, facts, blocks) {
if (!contact.email) {
console.error('[wechsel] K12 nicht verschickt: keine E-Mail-Adresse zum Kunden')
return
}
const attachments = await contractPdfAttachment(consent.termsVersion)
await sendCustomerMail(contact.email, {
template: 'K12',
values: {
name: contact.name,
wechseltag: fmtDate(facts.switchedAt),
betrag_heute: fmtEuro(facts.amountDueCents),
monatspreis: fmtEuro(monthlyCentsOf(facts)),
mindestlaufzeit_bis: fmtDate(addMonths(new Date(facts.switchedAt), 12)),
bedingungen_version: consent.termsVersion,
zustimmung_zeitpunkt: fmtDateTime(consent.consentAt),
},
blocks,
attachments,
}).catch((err) => console.error(`[wechsel] K12 an ${contact.email} fehlgeschlagen:`, err.message))
}
/** T12 ans Team (sendAdminNotice protokolliert Fehler selbst und wirft nie). */
async function sendSwitchNotice(contact, subscriptionId, facts, blocks) {
await sendAdminNotice({
template: 'T12',
values: {
email: contact.email,
name: contact.name,
stripe_abo_id: subscriptionId,
wechseltag: fmtDate(facts.switchedAt),
betrag_heute: fmtEuro(facts.amountDueCents),
monatspreis: fmtEuro(monthlyCentsOf(facts)),
},
blocks,
})
}
const factsOf = (event) => ({
switchedAt: event.details?.switchedAt || event.consentAt,
amountDueCents: event.details?.amountDueCents,
monthlyCents: event.details?.monthlyCents,
})
// Monatsgebuehr fuer {{monatspreis}}; Ereignisse von vor Task 12 kennen nur den Katalogpreis des Wartungs-Abos
const monthlyCentsOf = (facts) => facts.monthlyCents ?? config.products.wartung.amountCents
// Ein Wechsel je Abo zur selben Zeit (Doppelklick): der zweite wartet nicht, sondern bekommt 409
const switchesInFlight = new Set()
// Obergrenze fuer details des Ereignisses `wechsel` (Schutz des Nachweises): die gewuenschte Funktion (details.funktion) kommt nur hinein, wenn es passt
const DETAILS_MAX = 2000
/**
* Wechsel ins Wartungs-Abo (Button "Zahlungspflichtig bestellen"). consent: { termsVersion, consentAt,
* consentIp, consentUserAgent, earlyStartConsentAt, kundenart? } - ohne Zustimmung zum Sofortbeginn kein Wechsel (400, F16); einzige Ausnahme
* kundenart 'unternehmen' (Fassung 6, Betrieb): dann ohne Zeitstempel, der Grund steht als details.kundenart im Ereignis.
* 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.
* options.funktion ({ key, projectId }, von der Route geprueft; Kundenansicht neu, P6): die Funktion, die der Kunde mit dem Wechsel einschalten
* will, landet in event.details.funktion (GET /api/abo nennt sie als wechselFunktion). Sie ist nur eine Bequemlichkeit: passt sie nicht mehr
* in den Nachweis (details ueber 2000 Zeichen), bleibt sie weg, der Wechsel laeuft in jedem Fall.
*/
export async function switchToWartung(customerId, consent, prorationDate, { expectedAmountCents, funktion = null } = {}) {
if (!consent?.termsVersion || !consent?.consentAt) {
throw httpError(400, 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.')
}
// Fassung 6: Ein Betrieb (consent.kundenart 'unternehmen', vom Server aus dem Kundendokument) braucht kein Haekchen und bekommt keinen Zeitstempel
if (!consent.earlyStartConsentAt && consent.kundenart !== 'unternehmen') {
throw httpError(400, 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.')
}
const problem = previewProblem(prorationDate)
if (problem) throw httpError(problem.status, problem.message)
const doc = await requireSubscription(customerId)
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, expectedAmountCents, funktion)
} finally {
switchesInFlight.delete(doc.$id)
}
}
async function performSwitch(customerId, current, consent, prorationDate, expectedAmountCents, funktion = null) {
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()
// Spec 3.5: Monatsgebuehr des Wartungs-Abos mit gebuchtem Zusatzspeicher ({{monatspreis}} in K12 und T12)
const monthlyCents = config.products.wartung.amountCents + ctx.storageUnits * config.mailStorage.amountCents
// Stripe-Metadaten am Abo (max. 500 Zeichen je Wert): Produkt, neue Mindestlaufzeit, Nachweis der Zustimmung
const metadata = {
productKey: 'wartung',
termStartAt: switchedAt,
termsVersion: consent.termsVersion,
termsAcceptedAt: consent.consentAt,
// leer (Betrieb, Fassung 6): Stripe loescht den Schluessel, ein Betrieb erbt den Zeitstempel des Hosting-Abschlusses nicht
earlyStartConsentAt: consent.earlyStartConsentAt || '',
consentIp: String(consent.consentIp || '').slice(0, 64),
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
switchedFrom: 'hosting',
switchedAt,
}
const updated = await getStripe().subscriptions.update(doc.$id, {
items: [{ id: ctx.aboItemId, price: ctx.wartungPriceId }],
billing_cycle_anchor: 'now',
proration_behavior: 'create_prorations',
proration_date: prorationDate,
// Karte: erst mit der Zahlung wirksam; SEPA kennt keine ausstehenden Aktualisierungen (Rechtsrunde)
payment_behavior: sepa ? 'allow_incomplete' : 'pending_if_incomplete',
metadata,
...(ctx.discounts ? { discounts: ctx.discounts } : {}),
expand: ['latest_invoice'],
})
const invoice = updated.latest_invoice && typeof updated.latest_invoice === 'object' ? updated.latest_invoice : null
const pending = Boolean(updated.pending_update)
// Ohne Rechnung ist nichts zu zahlen; eine nicht aufgeloeste Rechnung (nur ID) gilt als offen
const paid = invoice ? invoice.status === 'paid' : !updated.latest_invoice
const effective = !pending
const paymentUrl = pending ? invoice?.hosted_invoice_url || null : null
const amountDueCents = Number(invoice?.amount_due) || 0
// Spiegel erst, wenn Stripe den Wechsel uebernommen hat; ein Fehler hier bricht nichts ab (der Webhook
// customer.subscription.updated spiegelt ohnehin nach)
let subscription = doc
if (effective) {
subscription =
(await syncSubscription(doc.$id).catch((err) => {
console.error(`[wechsel] Spiegel von Abo ${doc.$id} nach dem Wechsel nicht aktualisiert:`, err.message)
return null
})) || doc
}
const details = {
from: 'hosting',
amountDueCents,
monthlyCents,
prorationDate,
switchedAt,
paymentMethod: ctx.paymentMethod,
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 ? null : String(stripeSub.metadata[key]).slice(0, 300)])
),
// Fassung 6: Grund fuer den fehlenden Zeitstempel (Betrieb)
...(consent.kundenart === 'unternehmen' ? { kundenart: 'unternehmen' } : {}),
}
// Gewuenschte Funktion (nur Bequemlichkeit): der Nachweis darf dadurch nie zu gross werden
if (funktion && JSON.stringify({ ...details, funktion }).length <= DETAILS_MAX) details.funktion = funktion
const event = await recordSwitchEvent({
doc,
customerId,
consent,
status: effective && paid ? 'erledigt' : 'offen',
details,
})
const contact = { name: customer?.name || '', email: customer?.email || doc.email || '' }
const facts = { switchedAt, amountDueCents, monthlyCents }
const lastschrift = sepa && effective && !paid
// K12 sofort; zahlung_offen nur bei zurueckgehaltener Aenderung (Karte), lastschrift bei SEPA (F37)
await sendSwitchConfirmation(contact, consent, facts, { zahlung_offen: pending, lastschrift, zusatzspeicher: ctx.storageUnits > 0 })
// T12 erst, wenn der Wechsel wirksam ist - ohne Nachweis sofort, damit das Team ihn von Hand verfolgt
if (effective || !event) {
await sendSwitchNotice(contact, doc.$id, facts, { wirksam: effective, lastschrift, nachweis_fehlt: !event })
}
return { subscription, paymentUrl, event }
}
/** Frist, in der die beim Wechsel gewuenschte Funktion noch angeboten wird (Tage nach der Erledigung des Wechsels). */
const WECHSEL_FUNKTION_TAGE = 14
/**
* Die beim Wechsel gewuenschte Funktion (Kundenansicht neu, P6, V11), die der Kunde jetzt noch einschalten kann: { key, projectId } oder null.
* events: Vertragsereignisse des aktuellen Abos (listContractEvents, neueste zuerst, details geparst). Es zaehlt das juengste Ereignis
* `wechsel`: es ist erledigt, traegt details.funktion, wurde vor hoechstens 14 Tagen erledigt (doneAt, sonst details.paidAt, sonst
* details.effectiveAt) und das Funktionsdokument des Projekts fehlt oder ist off (die Funktion ist noch nicht eingeschaltet). Gescheitert,
* widerrufen und offen ergeben null, ebenso ein juengerer Wechsel ohne Funktion. Liest hoechstens ein Funktionsdokument; ein Lesefehler wird weitergereicht.
*/
export async function wechselFunktionVon(customerId, events, { now = new Date() } = {}) {
const event = (events || []).find((e) => e.type === 'wechsel')
if (!event || event.status !== 'erledigt') return null
const funktion = event.details?.funktion
if (!funktion || typeof funktion.key !== 'string' || typeof funktion.projectId !== 'string' || !funktion.key || !funktion.projectId) return null
const erledigt = Date.parse(event.doneAt || event.details?.paidAt || event.details?.effectiveAt || '')
if (!Number.isFinite(erledigt) || now.getTime() - erledigt > WECHSEL_FUNKTION_TAGE * 24 * 60 * 60 * 1000) return null
const doc = await getFeatureDoc(customerId, funktion.projectId, funktion.key)
if (doc && doc.state !== 'off') return null
return { key: funktion.key, projectId: funktion.projectId }
}
/**
* Ereignis `wechsel` (Nachweis der Bestellung und Stand der Zahlung). Feste ID je Abo und Wechselzeitpunkt
* (eventIdFor, 36 Zeichen). Ein Fehler beim Speichern bricht den schon bei Stripe ausgefuehrten Wechsel nicht
* ab: er wird protokolliert, T12 meldet ihn dem Team (nachweis_fehlt).
*/
async function recordSwitchEvent({ doc, customerId, consent, status, details }) {
try {
return await recordContractEvent({
id: eventIdFor('wechsel', `${doc.$id}:${details.switchedAt}`),
customerId,
subscriptionId: doc.$id,
type: 'wechsel',
productKey: 'wartung',
details,
consent: {
termsVersion: consent.termsVersion,
consentAt: consent.consentAt,
consentIp: consent.consentIp || null,
consentUserAgent: consent.consentUserAgent || null,
earlyStartConsentAt: consent.earlyStartConsentAt || '',
},
status,
})
} catch (err) {
console.error(`[wechsel] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message)
return null
}
}
/**
* 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(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 }
})
}
// 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)
}
}
/**
* Wechsel ist gescheitert (F27): Ereignis `gescheitert` mit Grund, K14 an den Kunden (bei SEPA mit dem Hinweis,
* dass der Abbuchungstag der Wechseltag bleibt), T12 ans Team. Aufgerufen vom Webhook
* customer.subscription.pending_update_expired und fuer SEPA vom Team ueber scripts/wechsel-gescheitert.mjs
* (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 }
const reason = String(grund || '').trim() || SWITCH_FAILED_REASONS.abgelaufen
const now = new Date().toISOString()
await updateContractEvent(eventId, { status: 'gescheitert', doneAt: now, doneBy, details: { failedAt: now, grund: reason } })
const mirror = await getDocument(config.collections.subscriptions, event.subscriptionId).catch(() => null)
const contact = await contactOf(event.customerId, mirror?.email)
if (contact.email) {
await sendCustomerMail(contact.email, {
template: 'K14',
values: { name: contact.name, grund: reason },
blocks: { lastschrift: event.details?.paymentMethod === 'sepa_debit' },
}).catch((err) => console.error(`[wechsel] K14 an ${contact.email} fehlgeschlagen:`, err.message))
} else {
console.error(`[wechsel] K14 nicht verschickt: keine E-Mail-Adresse zu Wechsel ${eventId}`)
}
await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { gescheitert: true })
return { failed: true, event: await getContractEvent(eventId) }
}
// ---------------------------------------------------------------------------
// Stripe-Webhooks (routes/abo.js)
// ---------------------------------------------------------------------------
/** customer.subscription.pending_update_applied: Stripe hat den Wechsel mit der Zahlung uebernommen. */
export async function handlePendingUpdateApplied(subscriptionId) {
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
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' }
return completeSwitch(event.$id, await contactOf(sub.customerId, sub.email))
}
/** customer.subscription.pending_update_expired: nicht rechtzeitig bezahlt, Stripe hat die Rechnung storniert. */
export async function handlePendingUpdateExpired(subscriptionId) {
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
const event = await findOpenSwitchEvent(sub.customerId, subscriptionId)
if (!event) {
console.warn(`[wechsel] pending_update_expired für Abo ${subscriptionId}, aber kein offener Wechsel`)
return { skipped: 'kein offener Wechsel' }
}
return markSwitchFailed(event.$id, SWITCH_FAILED_REASONS.abgelaufen)
}
/**
* invoice.paid (F26) einer Wechselrechnung (billing_reason subscription_update) mit offenem Ereignis: SEPA ->
* erledigt. Karte: kommt invoice.paid vor pending_update_applied, schliesst es den Wechsel ebenso (mit T12),
* aber erst, wenn Stripe die zurueckgehaltene Aenderung uebernommen hat. invoice ist die frisch geladene Rechnung.
*/
export async function handleSwitchInvoicePaid(invoice) {
if (invoice?.billing_reason !== 'subscription_update') return { skipped: 'keine Wechselrechnung' }
const subscriptionId = idOf(subscriptionIdOfInvoice(invoice))
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
const doc =
(await getDocument(config.collections.subscriptions, subscriptionId).catch(() => null)) || (await syncSubscription(subscriptionId))
if (!doc) return { skipped: 'Abo ohne Kundenzuordnung' }
const event = await findOpenSwitchEvent(doc.customerId, subscriptionId)
if (!event) return { skipped: 'kein offener Wechsel' }
if (event.details?.invoiceId && event.details.invoiceId !== invoice.id) return { skipped: 'andere Rechnung' }
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)
return completeSwitch(event.$id, await contactOf(doc.customerId, doc.email))
}
/**
* 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 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, monthlyCents: d.monthlyCents }, { 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' }
}
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,
monthlyCents: config.products.wartung.amountCents + describeItems(stripeSub.items?.data).storageUnits * config.mailStorage.amountCents,
},
{ lastschrift_gescheitert: true, nachweis_fehlt: 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
}
}