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

385 lines
19 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 (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 }
}