Die PDFs der Fassung 6 entstehen erst nach der Freigabe. Bis dahin gingen K01, K12 und K13 ohne Anhang raus, sagten aber "im Anhang" bzw. "beigefügt", und das Team erfuhr es nicht (nur K38/T24 hatten dafür den Block anhang_fehlt). - Neuer Helfer vertragsAnhang.js: lädt das PDF der zugestimmten Fassung und meldet fehlt nur bei Fassung 6 ohne lesbare Datei. - K01, K12, K13: Block anhang (Anhangsaussagen) und ohne_anhang (Satz, dass die Unterlagen gesondert kommen). - T02, T12, T13: Block anhang_fehlt "Unterlagen von Hand nachsenden" (T12 auch beim späteren Wirksamwerden, T13 auch ohne Aufgabe). - Alte Fassungen unverändert: Hash-Pins auf die Wiedergabe der Vorlagen vor der Änderung, Anhang und Texte wie bisher. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
724 lines
38 KiB
JavaScript
724 lines
38 KiB
JavaScript
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 { describeItems } from './products.js'
|
||
import { anhangFehlt, ladeVertragsAnhang } from './vertragsAnhang.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(),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* K12 an den Kunden: Vertragsbestaetigung mit PDF der zugestimmten Fassung. Ein Mailfehler bricht den Wechsel nicht ab.
|
||
* Fehlt das PDF der Fassung 6, geht K12 ohne Anhang und ohne die Behauptung "im Anhang" raus (Block ohne_anhang, F5); die Rueckgabe
|
||
* { anhangFehlt } sagt es dem Aufrufer fuer T12 (Block anhang_fehlt). Ohne Datei der alten Fassungen bleibt es beim bisherigen Verhalten.
|
||
*/
|
||
async function sendSwitchConfirmation(contact, consent, facts, blocks) {
|
||
if (!contact.email) {
|
||
console.error('[wechsel] K12 nicht verschickt: keine E-Mail-Adresse zum Kunden')
|
||
return { anhangFehlt: false }
|
||
}
|
||
const { attachments, fehlt } = await ladeVertragsAnhang(consent.termsVersion, 'wechsel')
|
||
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: { ...blocks, anhang: !fehlt, ohne_anhang: fehlt },
|
||
attachments,
|
||
}).catch((err) => console.error(`[wechsel] K12 an ${contact.email} fehlgeschlagen:`, err.message))
|
||
return { anhangFehlt: fehlt }
|
||
}
|
||
|
||
/** 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); sofortverlangen nur mit Zeitstempel (kein Betrieb in Fassung 6)
|
||
const { anhangFehlt: ohneAnhang } = await sendSwitchConfirmation(contact, consent, facts, { zahlung_offen: pending, lastschrift, zusatzspeicher: ctx.storageUnits > 0, sofortverlangen: Boolean(consent.earlyStartConsentAt) })
|
||
// 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, anhang_fehlt: ohneAnhang })
|
||
}
|
||
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 },
|
||
})
|
||
// Das T12 "wirksam" kommt hier erst nach K12: fehlen die PDFs der Fassung 6 noch, sagt es auch dieses T12 dem Team (F5)
|
||
if (!wasEffective) await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { wirksam: true, anhang_fehlt: await anhangFehlt(event.termsVersion) })
|
||
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
|
||
}
|
||
}
|