import { readFile } from 'node:fs/promises' import { config } from '../config.js' import { getDocument, updateDocument } from './appwriteAdmin.js' import { listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { contractAttachmentFor, describeItems, priceIdsOfInvoice, regularStorageLine } from './products.js' import { ACTIVE_STATUSES, ensurePrice, getCurrentSubscription, getStripe, invoiceLines, resolvePriceKeys, subscriptionIdOfInvoice, syncSubscription, } from './stripeBilling.js' /** * E-Mail-Zusatzspeicher (Spec 3.4, Bedingungen Fassung 4 § 10): Pakete zu 5 GB fuer 1,00 € je Monat als * zweite Position (Stueckzahl) im Stripe-Abo des Kunden, 0 bis 10 Pakete, in beiden Abos. * * - Plus (Bestellung, "Zahlungspflichtig bestellen"): sofort. Den Rest des laufenden Monats berechnet * Stripe anteilig mit der naechsten Abbuchung (proration_behavior create_prorations). Pakete, die fuer * den laufenden Monat schon bezahlt sind (nach einem Minus im selben Monat), kosten nicht noch einmal: * bis zur bezahlten Menge proration_behavior none, darueber anteilig - notfalls in zwei Schritten * (planStorageChange). * - Minus ("Speichern"): Stueckzahl sofort bei Stripe, ohne Erstattung (none). Die Postfachgrenze in * konsoleH sinkt erst zum Ende des Abrechnungsmonats (Aufgabe fuers Team, dueAt = Periodenende). * - Jede Aenderung ist ein Ereignis `speicher` mit Status offen (= Aufgabe fuers Team in konsoleH); nur ein * Plus traegt Zustimmungsdaten, ein Minus keine (Rechtsrunde 28.09.2026). Eine neue Aenderung ersetzt * die aeltere offene Aufgabe desselben Abos (F21): eine Aufgabe je Abo mit Zielgroesse und Datum. * - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus gesetzt, sonst * aus der regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid). */ function httpError(status, message) { const err = new Error(message) err.status = status return err } const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) : '–' // Preis wie in den Bedingungen: "2,00 €" (normales Leerzeichen) const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €` const toIso = (unixSeconds) => (unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null) const unitsOf = (doc) => Number(doc?.storageUnits) || 0 const totalGbOf = (units) => config.mailStorage.includedGb + units * config.mailStorage.gbPerUnit const monthlyCentsOf = (units) => units * config.mailStorage.amountCents /** * Stripe-Schritte fuer eine Aenderung auf newUnits Pakete: { direction: 'plus' | 'minus' | 'none', steps: * [{ quantity, proration_behavior }] }. Ein Plus bis zur schon bezahlten Menge (paidUnits) laeuft ohne * Verrechnung (none); darueber hinaus anteilig (create_prorations), bei currentUnits < paidUnits in zwei * Schritten. Ein Minus laeuft ohne Erstattung (none). newUnits muss eine ganze Zahl von 0 bis maxUnits sein. */ export function planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits }) { if (!Number.isInteger(newUnits) || newUnits < 0 || newUnits > maxUnits) { throw httpError(400, `Bitte wählen Sie zwischen 0 und ${maxUnits} Paketen Zusatzspeicher.`) } const current = Number(currentUnits) || 0 const paid = Number(paidUnits) || 0 if (newUnits === current) return { direction: 'none', steps: [] } if (newUnits < current) return { direction: 'minus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] } if (newUnits <= paid) return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] } if (current < paid) { return { direction: 'plus', steps: [ { quantity: paid, proration_behavior: 'none' }, { quantity: newUnits, proration_behavior: 'create_prorations' }, ], } } return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'create_prorations' }] } } /** * Fuer den laufenden Abrechnungsmonat schon bezahlte Pakete: storagePaidUnits, wenn storagePaidPeriodEnd * der laufende Zeitraum ist (Appwrite gibt Datumswerte mit +00:00 zurueck, der Spiegel schreibt Z: gleicher * Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere Rechnung des Monats hat sie berechnet). */ export function paidUnitsOf(doc) { const paidEnd = doc?.storagePaidPeriodEnd const periodEnd = doc?.currentPeriodEnd const samePeriod = Boolean(paidEnd && periodEnd) && (paidEnd === periodEnd || new Date(paidEnd).getTime() === new Date(periodEnd).getTime()) return samePeriod ? Number(doc.storagePaidUnits) || 0 : unitsOf(doc) } async function requireSubscription(customerId) { const doc = await getCurrentSubscription(customerId) if (!doc) throw httpError(404, 'Kein Abo vorhanden.') return doc } // Position fuer subscriptions.update / invoices.createPreview: vorhandener Speicher-Posten per ID, sonst neu per Preis const storageItemParam = (storageItemId, priceId, quantity) => (storageItemId ? { id: storageItemId, quantity } : { price: priceId, quantity }) /** * Ein Minus wirkt zum Ende des Abrechnungsmonats; endet das Abo frueher (cancelAt), endet der Speicher * mit ihm (Spec 3.4 "Der Zusatzspeicher endet automatisch mit dem Abo"). */ function reductionEffectiveAt(doc) { const periodEnd = doc.currentPeriodEnd || null const cancelAt = doc.cancelAt || null if (periodEnd && cancelAt && new Date(cancelAt) < new Date(periodEnd)) return cancelAt return periodEnd || cancelAt } /** * Anteiliger Betrag der Speicher-Zeilen einer Stripe-Vorschau (naechste Rechnung): Belastung fuer die neue * Menge abzueglich Gutschrift fuer die alte, jeweils ab prorationDate. Aeltere anteilige Zeilen (eine * fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die regulaeren Zeilen des * naechsten Monats zaehlen nicht. */ function storageProrationCents(preview, priceIds, prorationDate) { return (preview?.lines?.data || []) .filter((line) => { const parent = line.parent || {} const details = parent.subscription_item_details || parent.invoice_item_details || {} if (details.proration !== true) return false if (!priceIds.has(line.pricing?.price_details?.price)) return false // Toleranz fuer Sekunden zwischen Vorschau und Stripe-Rundung return !line.period?.start || line.period.start >= prorationDate - 300 }) .reduce((sum, line) => sum + (Number(line.amount) || 0), 0) } /** * Vorschau fuer newUnits Pakete: Richtung, Gesamtgroesse, Monatspreis des Speichers, Mehr-/Minderpreis je * Monat, anteiliger Betrag fuer den Rest des laufenden Monats (aus invoices.createPreview), naechste Abbuchung * und Wirksamkeit (Minus: Periodenende, sonst jetzt). Bis zur schon bezahlten Menge kostet ein Plus nichts. */ export async function previewStorageChange(customerId, newUnits) { const doc = await requireSubscription(customerId) const currentUnits = unitsOf(doc) const paidUnits = paidUnitsOf(doc) const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits }) const now = new Date() let prorationCents = 0 if (plan.direction === 'plus' && newUnits > paidUnits) { const stripe = getStripe() const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)]) const items = stripeSub.items?.data || [] const { storageItemId } = describeItems(items) const prorationDate = Math.floor(now.getTime() / 1000) const preview = await stripe.invoices.createPreview({ subscription: doc.$id, subscription_details: { items: [storageItemParam(storageItemId, priceId, newUnits)], proration_behavior: 'create_prorations', proration_date: prorationDate, }, }) // Gutschrift-Zeilen tragen den Preis des vorhandenen Postens (kann ein aelterer Speicher-Preis sein) const priceIds = new Set([priceId, items.find((item) => item.id === storageItemId)?.price?.id].filter(Boolean)) const previewed = storageProrationCents(preview, priceIds, prorationDate) // F19: Stripe rechnet die Vorschau von der heutigen Menge (currentUnits) aus; berechnet werden nur die // Pakete ueber der schon bezahlten Menge, der erste Schritt bis dahin laeuft ohne Verrechnung prorationCents = Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits)) } return { direction: plan.direction, units: newUnits, totalGb: totalGbOf(newUnits), monthlyCents: monthlyCentsOf(newUnits), deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits), prorationCents, nextChargeAt: doc.currentPeriodEnd || null, effectiveAt: plan.direction === 'minus' ? reductionEffectiveAt(doc) : now.toISOString(), } } /** * Darf der Kunde die Menge aendern? Im Portal nur bei Status active, ohne Kuendigung und ohne Wechsel, der * auf die Zahlung wartet (wie canChangeStorage in GET /api/abo, F12). Ueber das Kuendigungsformular * (viaCancellation, Task 10; Bedingungen § 10 Abs. 5 "jederzeit") ist eine Verringerung in jedem laufenden * Status erlaubt, auch bei gekuendigtem Abo oder offener Zahlung - eine Erhoehung dort nie (F20). */ function assertChangeAllowed(doc, events, direction, viaCancellation) { if (viaCancellation) { if (direction !== 'minus') throw httpError(409, 'Über die Kündigung lässt sich der Zusatzspeicher nur verringern.') if (!ACTIVE_STATUSES.has(doc.status)) throw httpError(409, 'Das Abo läuft nicht mehr.') return } if (doc.status !== 'active') { throw httpError( 409, 'Der Zusatzspeicher lässt sich nur bei laufendem Abo ändern, nicht in der Wartezeit bis zum Leistungsbeginn und nicht bei einem Zahlungsproblem.' ) } if (doc.cancelAtPeriodEnd || doc.cancelAt) throw httpError(409, 'Ihr Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.') if (events.some((e) => e.type === 'wechsel' && e.status === 'offen')) { throw httpError(409, 'Ihr Wechsel wartet noch auf die Zahlung. Bitte versuchen Sie es danach erneut.') } } /** * Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team). Aeltere offene Aufgaben desselben * Abos sind damit ueberholt (F21: erledigt, doneBy 'ersetzt'). Ein Fehler beim Speichern bricht die schon * bei Stripe wirksame Aenderung nicht ab: er wird protokolliert, T13 traegt die Aufgabe trotzdem zum Team. */ async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation }) { try { const now = new Date().toISOString() for (const e of events.filter((e) => e.type === 'speicher' && e.status === 'offen')) { await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' }) } return await recordContractEvent({ customerId: doc.customerId, subscriptionId: doc.$id, type: 'speicher', productKey: doc.productKey || 'wartung', details: { fromUnits: change.fromUnits, toUnits: change.toUnits, effectiveAt: change.effectiveAt, totalGb: change.totalGb, monthlyCents: change.monthlyCents, deltaMonthlyCents: change.deltaMonthlyCents, ...(viaCancellation ? { viaCancellation: true } : {}), }, // Nur eine Bestellung (Plus) traegt Zustimmungsdaten; ein Minus keine (Rechtsrunde 28.09.2026) consent: change.direction === 'plus' ? { termsVersion: consent.termsVersion, consentAt: consent.consentAt, consentIp: consent.consentIp || null, consentUserAgent: consent.consentUserAgent || null, } : null, status: 'offen', dueAt, }) } catch (err) { console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message) return null } } /** Vertragsunterlagen (PDF) in der zugestimmten Fassung als Mail-Anhang; ohne Datei geht die Mail ohne Anhang raus. */ async function contractPdfAttachment(termsVersion) { const { filename, path } = contractAttachmentFor(termsVersion) const content = await readFile(path).catch((err) => { console.error(`[speicher] Vertragsunterlagen ${filename} nicht lesbar:`, err.message) return null }) return content ? [{ filename, content, contentType: 'application/pdf' }] : [] } /** * K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team. Ueber das * Kuendigungsformular bestaetigt K03, K13 entfaellt dort (F31). Ein Mailfehler bricht die Aenderung nie ab. */ async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation, proofMissing }) { const plus = change.direction === 'plus' const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null) const email = customer?.email || doc.email || '' const wirksamAb = fmtDate(change.effectiveAt) if (!viaCancellation && email) { const attachments = plus ? await contractPdfAttachment(consent.termsVersion) : [] await sendCustomerMail(email, { template: 'K13', values: { name: customer?.name || '', pakete: change.toUnits, gesamt_gb: change.totalGb, monatspreis_zusatz: fmtEuro(change.monthlyCents), wirksam_ab: wirksamAb, }, blocks: { plus, minus: !plus }, attachments, }).catch((e) => console.error(`[speicher] K13 an ${email} fehlgeschlagen:`, e.message)) } await sendAdminNotice({ template: 'T13', values: { aktion: plus ? 'einrichten' : `reduzieren zum ${wirksamAb}`, email, postfaecher_hinweis: (doc.productKey || 'wartung') === 'hosting' ? 'Hosting-Abo: 1 Postfach' : 'Wartungs-Abo: alle Postfächer des Kunden zusammen', neue_groesse_gb: change.totalGb, faellig: fmtDate(dueAt), }, blocks: proofMissing ? { nachweis_fehlt: true } : {}, }) } /** * Menge auf newUnits Pakete aendern: Stripe-Schritte nacheinander (die erste Position mit { price, quantity }, * danach { id, quantity }; bei 0 bleibt die Position mit Menge 0 bestehen), Spiegel und bezahlte Menge * (Plus: max(paidUnits, newUnits) fuer den laufenden Monat), Ereignis `speicher` (offen; dueAt jetzt bzw. * Periodenende), K13 und T13. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim Plus * Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20). * Liefert { subscription, event, change }. */ export async function changeStorage(customerId, newUnits, consent, options = {}) { const viaCancellation = options.viaCancellation === true const doc = await requireSubscription(customerId) const currentUnits = unitsOf(doc) const paidUnits = paidUnitsOf(doc) const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits }) if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.') const plus = plan.direction === 'plus' if (plus && !(consent?.termsVersion && consent?.consentAt)) { throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.') } const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id) assertChangeAllowed(doc, events, plan.direction, viaCancellation) // Stripe: Speicher-Posten anlegen oder Menge aendern, Schritt fuer Schritt (planStorageChange) const stripe = getStripe() const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)]) let { storageItemId } = describeItems(stripeSub.items?.data) const prorationDate = Math.floor(Date.now() / 1000) for (const step of plan.steps) { const updated = await stripe.subscriptions.update(doc.$id, { items: [storageItemParam(storageItemId, priceId, step.quantity)], proration_behavior: step.proration_behavior, ...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}), }) storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId } // Spiegel (storageUnits aus den Positionen) und bezahlte Menge des laufenden Monats. Die Aenderung ist // bei Stripe schon wirksam: scheitert die Buchung der bezahlten Menge (z. B. Attribut fehlt, Setup-Skript // nicht gelaufen), laufen Ereignis und Mails trotzdem; ein Plus nach einem Minus im selben Monat wuerde // dann anteilig berechnet statt frei bleiben - Fehler im Log. let subscription = (await syncSubscription(doc.$id)) || doc if (plus) { const paid = { storagePaidUnits: Math.max(paidUnits, newUnits), storagePaidPeriodEnd: subscription.currentPeriodEnd || doc.currentPeriodEnd || null } subscription = await updateDocument(config.collections.subscriptions, doc.$id, paid).catch((err) => { console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message) return subscription }) } const now = new Date().toISOString() const effectiveAt = plus ? now : reductionEffectiveAt(subscription) const change = { direction: plan.direction, fromUnits: currentUnits, toUnits: newUnits, effectiveAt, totalGb: totalGbOf(newUnits), monthlyCents: monthlyCentsOf(newUnits), deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits), } // Aufgabe fuers Team: Plus sofort einrichten, Minus zum Periodenende reduzieren const dueAt = plus ? now : effectiveAt const event = await recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation }) await sendStorageMails({ doc: subscription, change, dueAt, consent, viaCancellation, proofMissing: !event }) return { subscription, event, change } } /** * Bezahlte Rechnung (Webhook invoice.paid, frisch geladen mit loadInvoice): hat sie eine regulaere * Zusatzspeicher-Zeile (nicht anteilig), ist deren Stueckzahl fuer den Zeitraum bis line.period.end bezahlt * (storagePaidUnits, storagePaidPeriodEnd). Idempotent; fehlt der Spiegel noch (Webhook-Reihenfolge), * wird er aus Stripe angelegt. */ export async function recordPaidStorage(invoice) { const lines = await invoiceLines(invoice) const loaded = { ...invoice, lines: { ...(invoice?.lines || {}), data: lines, has_more: false } } const line = regularStorageLine(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded))) if (!line) return { skipped: 'keine reguläre Speicher-Zeile' } const ref = line.parent?.subscription_item_details?.subscription || subscriptionIdOfInvoice(loaded) const subscriptionId = typeof ref === 'string' ? ref : ref?.id if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' } const data = { storagePaidUnits: Number(line.quantity) || 0, storagePaidPeriodEnd: toIso(line.period?.end) } try { await updateDocument(config.collections.subscriptions, subscriptionId, data) } catch (err) { if (err.status !== 404) throw err if (!(await syncSubscription(subscriptionId))) return { skipped: 'Abo ohne Kundenzuordnung' } await updateDocument(config.collections.subscriptions, subscriptionId, data) } return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd: data.storagePaidPeriodEnd } }