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

144 lines
6.3 KiB
JavaScript

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
}
/**
* Vertragsunterlagen zur zugestimmten Fassung: Version 4 (Suffix "-v4") zeigt die aktuellen
* Bedingungen samt PDF, jede aeltere Version (auch unbekannt/keine) das Archiv der Version 3.
* endParagraph: der Paragraph zum Vertragsende (Daten, AuthCode, Loeschung) in dieser Fassung, fuer die
* Mails K02/K03/T05/T07 (F08) - in Fassung 4 ist § 9 der Wechsel zwischen den Abos, § 11 das Vertragsende.
*/
export function termsDocFor(termsVersion) {
if (typeof termsVersion === 'string' && termsVersion.endsWith('-v4')) {
return { url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11' }
}
return { 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: 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
}