import path from 'node:path' import { fileURLToPath } from 'node:url' import { config } from '../config.js' /** * Abo-Produkte (Hosting-Abo, Wartungs-Abo) und E-Mail-Zusatzspeicher. * * Reine Funktionen ohne Netzwerk: Sie ordnen Stripe-Preise (lookup_key) den Produkten zu und * werten Abo-Positionen und Rechnungszeilen aus. Stripe-Objekte haben die Form der API-Version * des SDK (2025-10-29.clover). */ export const PRODUCT_KEYS = Object.freeze(['wartung', 'hosting']) function httpError(status, message) { const err = new Error(message) err.status = status return err } /** Produkt aus dem Katalog; unbekannter Schlüssel -> 400. */ export function getProduct(key) { const product = PRODUCT_KEYS.includes(key) ? config.products[key] : null if (!product) throw httpError(400, `Unbekanntes Abo: ${key}`) return product } /** Die archivierte Fassung 3 (public/vertragsunterlagen/v3): gilt fuer jede Zustimmung ohne Fassung "-v4"/"-v5". */ export const TERMS_VERSION_V3 = '2026-09-25-v3' /** Aktuelle Fassung 5 (Unterstuetzung in § 4 Abs. 4)? */ export function isCurrentTerms(termsVersion) { return typeof termsVersion === 'string' && termsVersion.endsWith('-v5') } /** Fassung 4 oder neuer (Hosting- und Wartungs-Abo, Zusatzspeicher, Wechsel)? Aeltere und fehlende Fassungen: nein. */ export function isTermsV4OrNewer(termsVersion) { return typeof termsVersion === 'string' && /-v[45]$/.test(termsVersion) } /** * Vertragsunterlagen zur zugestimmten Fassung: Fassung 5 (Suffix "-v5") zeigt die aktuellen Bedingungen samt PDF, * Fassung 4 das Archiv unter public/vertragsunterlagen/v4, jede aeltere Version (auch unbekannt/keine) das Archiv * der Version 3. * version: die Fassung, die eine Mail nennt - die gespeicherte, ohne gespeicherte die archivierte Fassung 3, * damit Versionsangabe und PDF-Anhang immer zusammenpassen (Task 12 Fix 1). * endParagraph: der Paragraph zum Vertragsende (Daten, AuthCode, Loeschung) in dieser Fassung, fuer die * Mails K02/K03/T05/T07 (F08) - ab Fassung 4 ist § 9 der Wechsel zwischen den Abos, § 11 das Vertragsende. */ export function termsDocFor(termsVersion) { if (isCurrentTerms(termsVersion)) { return { version: termsVersion, url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11' } } if (isTermsV4OrNewer(termsVersion)) { return { version: termsVersion, url: '/vertragsunterlagen/v4/abo-bedingungen.html', pdfFile: 'vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11', } } return { version: typeof termsVersion === 'string' && termsVersion ? termsVersion : TERMS_VERSION_V3, url: '/vertragsunterlagen/v3/abo-bedingungen.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 9', } } /** Katalogname ohne "WEBklar " (z. B. "Hosting-Abo") fuer {{produkt}} in den Mails (F33); ohne Schluessel "Abo". */ export function productLabel(key) { return PRODUCT_KEYS.includes(key) ? config.products[key].name.replace(/^WEBklar\s+/, '') : 'Abo' } const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public') /** * Vertragsunterlagen als Mail-Anhang in der Fassung, der der Kunde zugestimmt hat (§ 312f BGB): * { filename, path } mit dem absoluten Pfad unter public/. Ein Speicher-Plus haengt die Fassung aus der * Zustimmung an (consent.termsVersion), nicht die des Abos (F22): Bestandskunden mit Fassung 3 bestellen * den Zusatzspeicher nach Fassung 4. */ export function contractAttachmentFor(termsVersion) { const { pdfFile } = termsDocFor(termsVersion) return { filename: path.basename(pdfFile), path: path.join(PUBLIC_DIR, pdfFile) } } /** Ansicht eines Produkts fuers Kundenportal (Vergleich, Bestellseite): Preis und Leistungen. */ export function productView(key) { const product = getProduct(key) return { key: product.key, name: product.name, amountCents: product.amountCents, minTermMonths: product.minTermMonths, minutesPerMonth: product.minutesPerMonth, // Kopie, kein Verweis auf den Katalog: aufrufender Code darf das zurueckgegebene Array // veraendern, ohne den geteilten Katalogeintrag fuer alle anderen zu verfaelschen (B10) features: product.features.map((row) => [...row]), } } /** lookup_key eines Stripe-Preises -> 'wartung' | 'hosting' | 'mailStorage' | null */ export function productKeyForLookupKey(lookupKey) { if (!lookupKey) return null const product = PRODUCT_KEYS.find((key) => config.products[key].lookupKey === lookupKey) if (product) return product return config.mailStorage.lookupKey === lookupKey ? 'mailStorage' : null } /** * Positionen eines Stripe-Abos (subscription.items.data) auswerten. Der Abo-Posten ist die * erste Position mit einem Hosting- oder Wartungs-Preis, unabhängig von der Reihenfolge. */ export function describeItems(items) { const list = Array.isArray(items) ? items : [] const abo = list.find((item) => PRODUCT_KEYS.includes(productKeyForLookupKey(item?.price?.lookup_key))) const storage = list.find((item) => productKeyForLookupKey(item?.price?.lookup_key) === 'mailStorage') return { productKey: abo ? productKeyForLookupKey(abo.price.lookup_key) : null, aboItemId: abo?.id || null, aboPriceId: abo?.price?.id || null, lookupKey: abo?.price?.lookup_key || null, priceAmount: abo?.price?.unit_amount ?? null, storageItemId: storage?.id || null, storageUnits: storage ? Number(storage.quantity) || 0 : 0, } } const priceIdOfLine = (line) => line?.pricing?.price_details?.price || null // Regulaere Abo-Zeile (Monatsposition): keine anteilige Verrechnung, keine einzelne Rechnungsposition const isRegularSubscriptionLine = (line) => line?.parent?.type === 'subscription_item_details' && line.parent.subscription_item_details?.proration === false /** Preis-IDs aller Rechnungszeilen, jede nur einmal. */ export function priceIdsOfInvoice(invoice) { const ids = (invoice?.lines?.data || []).map(priceIdOfLine).filter(Boolean) return [...new Set(ids)] } /** * Regulaere Zusatzspeicher-Zeile einer bezahlten Rechnung (Stueckzahl des Abrechnungsmonats) oder null. * Anteilige Zeilen (Plus im laufenden Monat) zaehlen nicht. priceKeys wie bei creditMinutesForInvoice. */ export function regularStorageLine(invoice, priceKeys) { return ( (invoice?.lines?.data || []).find((line) => priceKeys.get(priceIdOfLine(line)) === 'mailStorage' && isRegularSubscriptionLine(line)) || null ) } /** * Arbeitszeit, die eine bezahlte Rechnung gutschreibt: die Minuten des Produkts der ersten * regulären (nicht anteiligen) Abo-Zeile über 0 €, deren Produkt Arbeitszeit enthält. Anteilige * Verrechnungen, Probezeiten (0 €), Hosting und Zusatzspeicher zählen nicht. * Unbekannte Preise zählen nur, wenn die Rechnung gar keinen bekannten Abo-Preis enthält: Solche * Abos gelten wie im Abo-Spiegel als Wartungs-Abo (Altbestand bzw. von Hand angelegt). * priceKeys: Map Preis-ID -> 'wartung' | 'hosting' | 'mailStorage' | null */ export function creditMinutesForInvoice(invoice, priceKeys) { const lines = invoice?.lines?.data || [] const legacy = !lines.some((line) => PRODUCT_KEYS.includes(priceKeys.get(priceIdOfLine(line)))) for (const line of lines) { const known = priceKeys.get(priceIdOfLine(line)) const key = legacy && known == null ? 'wartung' : known if (!PRODUCT_KEYS.includes(key)) continue const minutes = config.products[key].minutesPerMonth if (minutes > 0 && isRegularSubscriptionLine(line) && line.amount > 0) return minutes } return 0 }