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

542 lines
27 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, 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.
* - Jede Aenderung ist ein Ereignis `speicher` (Nachweis; Status offen = Aufgabe fuers Team). Die Aufgabe
* hat eine Art (details.kind): "einrichten" (Plus ueber die bezahlte Menge, sofort, Ziel = neue Menge)
* oder "periodenende" (Minus oder Plus bis zur bezahlten Menge, faellig zum Periodenende, Ziel = neue
* Menge). Eine neue Aufgabe ersetzt aeltere offene derselben Art; eine Einrichtung ersetzt ausserdem eine
* geplante Verringerung mit kleinerem Ziel; eine Verringerung laesst eine offene Einrichtung stehen. So
* gibt es hoechstens eine offene Aufgabe je Art und Abo. Nur ein Plus traegt Zustimmungsdaten
* (Rechtsrunde 28.09.2026).
* - Reihenfolge: Ereignis zuerst (offen), dann Stripe. Lehnt Stripe ab, wird das Ereignis "gescheitert"
* und der Kunde sieht den Fehler; nach einer wirksamen Stripe-Aenderung bricht nichts mehr ab
* (Spiegel, Buchung, Aufgaben, Mails: Fehler im Log, T13 sagt es dem Team).
* - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus max(bezahlt,
* neu), beim Minus max(bezahlt, bisher) - jede Position war fuer den Monat berechnet; 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 sameInstant = (a, b) => a === b || new Date(a).getTime() === new Date(b).getTime()
// Appwrite-Fehler: 4xx dauerhaft (Attribut/Collection fehlt, ungueltige Daten), 5xx/Netz voruebergehend (wie Task 7)
const isPermanent = (err) => Boolean(err?.status) && err.status < 500
const shortError = (err) => String(err?.message || err).slice(0, 200)
const unitsOf = (doc) => Number(doc?.storageUnits) || 0
const totalGbOf = (units) => config.mailStorage.includedGb + units * config.mailStorage.gbPerUnit
const monthlyCentsOf = (units) => units * config.mailStorage.amountCents
// Seit API 2025-03 liegt current_period_end am Subscription-Item
const periodEndOf = (stripeSub) => stripeSub?.current_period_end || stripeSub?.items?.data?.[0]?.current_period_end || null
/**
* 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 (periodEnd, sonst doc.currentPeriodEnd; 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, periodEnd = doc?.currentPeriodEnd) {
const paidEnd = doc?.storagePaidPeriodEnd
const samePeriod = Boolean(paidEnd && periodEnd) && sameInstant(paidEnd, periodEnd)
return samePeriod ? Number(doc.storagePaidUnits) || 0 : unitsOf(doc)
}
/**
* Art der Aufgabe eines Ereignisses `speicher`: details.kind ("einrichten" | "periodenende"); aeltere
* Ereignisse ohne kind (vor Fix 1) nach der Richtung (Plus = einrichten, sonst periodenende).
*/
export function storageTaskKind(event) {
const details = event?.details || {}
if (details.kind === 'einrichten' || details.kind === 'periodenende') return details.kind
return Number(details.toUnits) > Number(details.fromUnits) ? 'einrichten' : 'periodenende'
}
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
}
// Toleranz zwischen dem eigenen proration_date und Stripes Zeilen (Sekundenrundung)
const PRORATION_TOLERANCE_SECONDS = 2
/**
* Anteiliger Betrag der Speicher-Zeilen einer Stripe-Vorschau (naechste Rechnung): Belastung fuer die neue
* Menge abzueglich Gutschrift fuer die alte, genau ab prorationDate (period.start, ±2 s). 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
return Math.abs(Number(line.period?.start) - prorationDate) <= PRORATION_TOLERANCE_SECONDS
})
.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.
*
* Abrechnungsmodus (Fix 1): Abos aus dem Checkout laufen im flexiblen Modus (billing_mode.type 'flexible',
* Stripes Standard) - dort schreibt Stripe den zuletzt berechneten Betrag gut, die Vorschau hat die im
* Monat schon bezahlten Pakete also selbst verrechnet und gilt unveraendert. Nur im klassischen Modus
* ('classic') rechnet Stripe von der heutigen Menge aus; dann zaehlen nur die Pakete ueber der bezahlten
* Menge (F19: p × (neu − max(aktuell, bezahlt)) / (neu − aktuell)).
*/
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)
const classic = stripeSub.billing_mode?.type === 'classic'
prorationCents = classic
? Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits))
: previewed
}
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(),
}
}
function assertPlanAllowed(plan, consent) {
if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.')
if (plan.direction === 'plus' && !(consent?.termsVersion && consent?.consentAt)) {
throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
}
}
/**
* 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), Status offen - vor dem Stripe-Aufruf.
* Laesst es sich nicht speichern, laeuft die Aenderung trotzdem: Fehler im Log, T13 traegt die Aufgabe mit
* dem Block nachweis_fehlt zum Team.
*/
async function recordStorageEvent({ doc, change, consent, viaCancellation }) {
try {
return await recordContractEvent({
customerId: doc.customerId,
subscriptionId: doc.$id,
type: 'speicher',
productKey: doc.productKey || 'wartung',
details: {
kind: change.kind,
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: change.dueAt,
})
} catch (err) {
console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message)
return null
}
}
/**
* Aeltere offene Aufgaben desselben Abos, die die neue Aufgabe ueberholt (F21, Fix 1): eine Einrichtung
* ersetzt offene Einrichtungen und geplante Verringerungen mit hoechstens gleichem Ziel; eine Aufgabe zum
* Periodenende ersetzt nur offene Aufgaben zum Periodenende, nie eine noch nicht erledigte Einrichtung.
* events sind die Ereignisse vor der neuen Aufgabe.
*/
async function supersedeStorageTasks(events, change) {
const now = new Date().toISOString()
const superseded = events.filter((e) => {
if (e.type !== 'speicher' || e.status !== 'offen') return false
const kind = storageTaskKind(e)
if (change.kind === 'einrichten') return kind === 'einrichten' || (Number(e.details?.toUnits) || 0) <= change.toUnits
return kind === 'periodenende'
})
for (const e of superseded) await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' })
}
/** 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 (Aufgabe nach Art, dazu
* die Bloecke teilweise und nachweis_fehlt). Ueber das Kuendigungsformular bestaetigt K03, K13 entfaellt dort
* (F31). Ein Mailfehler bricht die Aenderung nie ab.
*/
async function sendStorageMails({ doc, change, 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 dueLabel = fmtDate(change.dueAt)
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: fmtDate(change.effectiveAt),
},
blocks: { plus, minus: !plus },
attachments,
}).catch((e) => console.error(`[speicher] K13 an ${email} fehlgeschlagen:`, e.message))
}
await sendAdminNotice({
template: 'T13',
values: {
aktion: change.kind === 'einrichten' ? 'einrichten' : `reduzieren zum ${dueLabel}`,
email,
name: customer?.name || '',
stripe_abo_id: doc.$id,
postfaecher_hinweis:
(doc.productKey || 'wartung') === 'hosting' ? 'Hosting-Abo: 1 Postfach' : 'Wartungs-Abo: alle Postfächer des Kunden zusammen',
neue_groesse_gb: change.totalGb,
pakete: change.toUnits,
faellig: dueLabel,
},
blocks: {
einrichten: change.kind === 'einrichten',
periodenende: change.kind === 'periodenende',
teilweise: change.partial,
nachweis_fehlt: proofMissing,
},
})
}
// Abos mit laufender Aenderung (Doppelklick, zwei Tabs): die zweite gleichzeitige Aenderung bekommt 409
const activeChanges = new Set()
/**
* Menge auf newUnits Pakete aendern. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim
* Plus Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20).
* Ablauf: Vorpruefung mit dem Spiegel (400/404/409, ohne Stripe-Aufruf), Sperre je Abo, frischer Stand aus
* Stripe, Ereignis `speicher` (offen), Stripe-Schritte nacheinander (die erste Position mit { price, quantity },
* danach { id, quantity }; bei 0 bleibt die Position mit Menge 0), Spiegel und bezahlte Menge, ueberholte
* Aufgaben, K13 und T13. Liefert { subscription, event, change }; change = { direction, kind, fromUnits,
* toUnits, effectiveAt, dueAt, totalGb, monthlyCents, deltaMonthlyCents, partial }.
*/
export async function changeStorage(customerId, newUnits, consent, options = {}) {
const viaCancellation = options.viaCancellation === true
const doc = await requireSubscription(customerId)
// Vorpruefung mit dem Spiegel: Grenzen, Richtung, Zustimmung, Status - ohne Stripe-Aufruf
const plan = planStorageChange({ currentUnits: unitsOf(doc), paidUnits: paidUnitsOf(doc), newUnits, maxUnits: config.mailStorage.maxUnits })
assertPlanAllowed(plan, consent)
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id)
assertChangeAllowed(doc, events, plan.direction, viaCancellation)
// Sperre pruefen und setzen ohne await dazwischen
if (activeChanges.has(doc.$id)) throw httpError(409, 'Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.')
activeChanges.add(doc.$id)
try {
return await performChange({ doc, events, newUnits, consent, viaCancellation })
} finally {
activeChanges.delete(doc.$id)
}
}
async function performChange({ doc, events, newUnits, consent, viaCancellation }) {
const stripe = getStripe()
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
const items = stripeSub.items?.data || []
let { storageItemId } = describeItems(items)
// Frischer Stand aus Stripe (Menge, Periodenende): der Spiegel kann nach einem Webhook-Rueckstand hinterherhinken
const fresh = { ...doc, storageUnits: describeItems(items).storageUnits, currentPeriodEnd: toIso(periodEndOf(stripeSub)) || doc.currentPeriodEnd || null }
const currentUnits = unitsOf(fresh)
const paidUnits = paidUnitsOf(fresh)
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'
const now = new Date().toISOString()
const kind = plus && newUnits > paidUnits ? 'einrichten' : 'periodenende'
const reductionAt = reductionEffectiveAt(fresh)
const change = {
direction: plan.direction,
kind,
fromUnits: currentUnits,
toUnits: newUnits,
// Fuer den Kunden: ein Plus gilt sofort, ein Minus zum Periodenende; fuers Team: die Einrichtung ist
// sofort faellig, alles andere zum Periodenende
effectiveAt: plus ? now : reductionAt,
dueAt: kind === 'einrichten' ? now : reductionAt,
totalGb: totalGbOf(newUnits),
monthlyCents: monthlyCentsOf(newUnits),
deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits),
partial: false,
}
// 1. Ereignis zuerst: Nachweis und Aufgabe, Status offen
const event = await recordStorageEvent({ doc: fresh, change, consent, viaCancellation })
// 2. Stripe, Schritt fuer Schritt (planStorageChange)
const prorationDate = Math.floor(Date.now() / 1000)
let achievedUnits = currentUnits
for (const [index, step] of plan.steps.entries()) {
try {
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
achievedUnits = step.quantity
} catch (err) {
if (index === 0) {
// Nichts geaendert: Ereignis gescheitert, Fehler an den Kunden, keine Buchung, keine Mails
console.error(`[speicher] Abo ${doc.$id}: Stripe lehnt die Änderung ab:`, err.message)
if (event) {
await updateContractEvent(event.$id, { status: 'gescheitert', details: { error: shortError(err) } }).catch((e) =>
console.error(`[speicher] Ereignis ${event.$id} nicht auf gescheitert gesetzt:`, e.message)
)
}
const failure = httpError(502, 'Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuchen Sie es später erneut.')
failure.cause = err
throw failure
}
// Schritt 2 nach Schritt 1: Stripe steht auf der bezahlten Menge, die Bestellung bleibt offen - das Team
// setzt die Menge in Stripe von Hand (T13, Block teilweise)
console.error(`[speicher] Abo ${doc.$id}: Schritt ${index + 1} bei Stripe fehlgeschlagen, Menge bleibt ${achievedUnits}:`, err.message)
change.partial = true
if (event) {
await updateContractEvent(event.$id, { details: { partial: true, error: shortError(err) } }).catch((e) =>
console.error(`[speicher] Ereignis ${event.$id} nicht als teilweise markiert:`, e.message)
)
}
break
}
}
// 3. Spiegel (storageUnits aus den Positionen); scheitert er, holt der Stripe-Webhook das nach
let subscription
try {
subscription = (await syncSubscription(doc.$id)) || { ...fresh, storageUnits: achievedUnits }
} catch (err) {
console.error(`[speicher] Spiegel für Abo ${doc.$id} nicht aktualisiert (Webhook holt das nach):`, err.message)
subscription = { ...fresh, storageUnits: achievedUnits }
}
// 4. Bezahlte Menge des laufenden Monats: Plus max(bezahlt, erreicht), Minus max(bezahlt, bisher) - jede
// Position war fuer den Monat berechnet. Scheitert die Buchung (z. B. Attribut fehlt), laeuft der Rest weiter;
// ein Plus nach einem Minus im selben Monat wuerde dann anteilig berechnet statt frei bleiben.
const paid = {
storagePaidUnits: Math.max(paidUnits, plus ? achievedUnits : currentUnits),
storagePaidPeriodEnd: fresh.currentPeriodEnd,
}
try {
await updateDocument(config.collections.subscriptions, doc.$id, paid)
subscription = { ...subscription, ...paid }
} catch (err) {
console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message)
}
// 5. Ueberholte Aufgaben schliessen, erst nachdem die neue existiert
if (event) {
await supersedeStorageTasks(events, change).catch((err) =>
console.error(`[speicher] Ersetzte Aufgaben für Abo ${doc.$id} nicht geschlossen:`, err.message)
)
}
// 6. Mails
await sendStorageMails({ doc: subscription, change, 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). Im selben Monat gilt die groessere Menge (eine verspaetete
* Rechnung senkt eine Buchung nach einem Plus nicht), das Periodenende geht nie zurueck (wiederholte aeltere
* Rechnung). Fehlt der Spiegel noch (Webhook-Reihenfolge), wird er aus Stripe angelegt. Dauerhafte
* Appwrite-Fehler (4xx) werden protokolliert und uebersprungen, voruebergehende (5xx, Netz) geworfen, damit
* Stripe den Webhook wiederholt.
*/
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 periodEnd = toIso(line.period?.end)
if (!periodEnd) return { skipped: 'Speicher-Zeile ohne Zeitraum' }
const units = Number(line.quantity) || 0
const tag = `[speicher] Bezahlte Menge für Abo ${subscriptionId} (Rechnung ${loaded.id || '?'})`
let existing
try {
existing = await getDocument(config.collections.subscriptions, subscriptionId)
} catch (err) {
if (err.status !== 404) {
if (!isPermanent(err)) throw err
console.error(`${tag} nicht lesbar (${err.status}):`, err.message)
return { skipped: `Appwrite ${err.status}` }
}
existing = await syncSubscription(subscriptionId)
if (!existing) return { skipped: 'Abo ohne Kundenzuordnung' }
}
const knownEnd = existing.storagePaidPeriodEnd || null
if (knownEnd && new Date(periodEnd) < new Date(knownEnd)) return { skipped: 'ältere Rechnung' }
const samePeriod = Boolean(knownEnd) && sameInstant(knownEnd, periodEnd)
const data = {
storagePaidUnits: samePeriod ? Math.max(Number(existing.storagePaidUnits) || 0, units) : units,
storagePaidPeriodEnd: periodEnd,
}
try {
await updateDocument(config.collections.subscriptions, subscriptionId, data)
} catch (err) {
if (!isPermanent(err)) throw err
console.error(`${tag} nicht gespeichert (${err.status}):`, err.message)
return { skipped: `Appwrite ${err.status}` }
}
return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd }
}