Zusatzspeicher: Plus sofort anteilig, Minus zum Monatsende, Aufgaben für konsoleH

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-28 10:48:29 +00:00
1 parent 887e1e54a0
commit 379cc26dcd
11 files changed
+1531 -27

No files matched your search

+384
View File
@@ -0,0 +1,384 @@
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 }
}