572 lines
29 KiB
JavaScript
572 lines
29 KiB
JavaScript
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(),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Richtung und Zustimmung pruefen: unveraendert 400, Plus ohne Zustimmung 400. reconciled: der Plan wurde nach
|
||
* dem Abgleich mit Stripe neu berechnet (Spiegel und Stripe wichen ab) - dann sagt die Meldung, dass die
|
||
* Seite neu geladen werden muss; ueber das Kuendigungsformular ist dann nur ein Minus erlaubt (409).
|
||
*/
|
||
function assertPlanAllowed(plan, consent, { reconciled = false, viaCancellation = false } = {}) {
|
||
if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.')
|
||
if (reconciled && viaCancellation && plan.direction !== 'minus') {
|
||
throw httpError(409, 'Über die Kündigung lässt sich der Zusatzspeicher nur verringern.')
|
||
}
|
||
if (plan.direction === 'plus' && !(consent?.termsVersion && consent?.consentAt)) {
|
||
throw httpError(
|
||
400,
|
||
reconciled
|
||
? 'Ihr Speicherstand hat sich geändert. Bitte Seite neu laden und der Bestellung zustimmen.'
|
||
: '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 {
|
||
const event = 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: change.task ? 'offen' : 'erledigt',
|
||
dueAt: change.dueAt,
|
||
})
|
||
if (change.task) return event
|
||
// Kein Auftrag fuers Team: als erledigt markiert, damit die Aufgabenliste (Task 13) es nicht zeigt
|
||
return await updateContractEvent(event.$id, { doneAt: new Date().toISOString(), doneBy: 'keine Aufgabe' }).catch((e) => {
|
||
console.error(`[speicher] Ereignis ${event.$id} nicht als erledigt markiert:`, e.message)
|
||
return event
|
||
})
|
||
} 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),
|
||
},
|
||
// plus: Einrichtung mit anteiliger Berechnung; plus_bezahlt: bis zur schon bezahlten Menge (keine Kosten);
|
||
// bestellung: Unterlagen und Widerruf bei beiden; minus: Verringerung zum Periodenende
|
||
blocks: { plus: plus && change.kind === 'einrichten', plus_bezahlt: plus && change.kind !== 'einrichten', bestellung: plus, minus: !plus },
|
||
attachments,
|
||
}).catch((e) => console.error(`[speicher] K13 an ${email} fehlgeschlagen:`, e.message))
|
||
}
|
||
// Ohne Auftrag fuers Team (Plus genau zurueck auf die bezahlte Menge) kein T13 - es gibt nichts zu tun
|
||
if (!change.task && !proofMissing) return
|
||
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)
|
||
// Sperre pruefen und setzen ohne await dazwischen; die offenen Ereignisse erst unter der Sperre lesen, damit
|
||
// das Ersetzen aelterer Aufgaben nie auf einer veralteten Liste arbeitet
|
||
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 {
|
||
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id)
|
||
assertChangeAllowed(doc, events, plan.direction, viaCancellation)
|
||
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 })
|
||
// Weicht der frische Stand vom Spiegel ab, kann aus einem Minus ein Plus werden: Zustimmung und
|
||
// Kuendigungsweg erneut pruefen, bevor irgendetwas geschrieben wird (Fix 2)
|
||
assertPlanAllowed(plan, consent, { reconciled: true, viaCancellation })
|
||
const plus = plan.direction === 'plus'
|
||
const now = new Date().toISOString()
|
||
const kind = plus && newUnits > paidUnits ? 'einrichten' : 'periodenende'
|
||
// Ein Plus genau zurueck auf die bezahlte Menge: konsoleH steht schon darauf, zum Periodenende ist nichts zu
|
||
// tun - kein offener Auftrag fuers Team, das Ereignis bleibt als Nachweis (erledigt)
|
||
const task = !(plus && newUnits === paidUnits)
|
||
const reductionAt = reductionEffectiveAt(fresh)
|
||
const change = {
|
||
direction: plan.direction,
|
||
kind,
|
||
task,
|
||
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 (ohne Aufgabe: erledigt)
|
||
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 }
|
||
}
|