279 lines
14 KiB
JavaScript
279 lines
14 KiB
JavaScript
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { config } from '../config.js'
|
|
import { neueAnsicht } from './kundenansicht/schalter.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'
|
|
|
|
/**
|
|
* Fassungstabelle der Abo-Bedingungen (V12). Reihenfolge = Suche: der erste Eintrag, dessen muster auf die
|
|
* gespeicherte Fassung passt (nur als Endung, nie "-v60" oder "v6-..."), sonst der Eintrag sonst (Fassung 3).
|
|
* Fassung 6 liegt bis zum Umschalten (P10) im Vorschaupfad /neu/rechtliches/; Fassung 5 bleibt an den heutigen
|
|
* Adressen, archiv nennt die Adressen der Kopie unter public/vertragsunterlagen/v5/ (erst P10 schaltet sie ein).
|
|
* endParagraph: der Paragraph zum Vertragsende (Daten, AuthCode, Loeschung) fuer die Mails K02/K03/T05/T07 (F08).
|
|
*/
|
|
export const FASSUNGEN = Object.freeze([
|
|
Object.freeze({
|
|
muster: /-v6$/,
|
|
nummer: 6,
|
|
url: '/neu/rechtliches/abo-bedingungen.html',
|
|
pdfFile: 'neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf',
|
|
endParagraph: '§ 11',
|
|
}),
|
|
Object.freeze({
|
|
muster: /-v5$/,
|
|
nummer: 5,
|
|
url: '/abo-bedingungen.html',
|
|
pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf',
|
|
endParagraph: '§ 11',
|
|
archiv: Object.freeze({
|
|
url: '/vertragsunterlagen/v5/abo-bedingungen.html',
|
|
pdfFile: 'vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf',
|
|
}),
|
|
}),
|
|
Object.freeze({
|
|
muster: /-v4$/,
|
|
nummer: 4,
|
|
url: '/vertragsunterlagen/v4/abo-bedingungen.html',
|
|
pdfFile: 'vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf',
|
|
endParagraph: '§ 11',
|
|
}),
|
|
Object.freeze({
|
|
sonst: true,
|
|
nummer: 3,
|
|
url: '/vertragsunterlagen/v3/abo-bedingungen.html',
|
|
pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf',
|
|
endParagraph: '§ 9',
|
|
}),
|
|
])
|
|
|
|
/** Erster Tabelleneintrag mit passendem muster; ohne Treffer (auch bei Nicht-Text) der Eintrag sonst. */
|
|
function fassungFuer(termsVersion) {
|
|
const treffer = typeof termsVersion === 'string' ? FASSUNGEN.find((f) => f.muster && f.muster.test(termsVersion)) : null
|
|
return treffer || FASSUNGEN.find((f) => f.sonst)
|
|
}
|
|
|
|
/**
|
|
* Abo-Fassung der Sitzung: Fassung 6 nur bei neuer Ansicht (Pilot-Schalter), sonst die bisherige Fassung 5.
|
|
* req wie bei neueAnsicht(req) (req.session oder signiertes Cookie).
|
|
*/
|
|
export function aktuelleAboFassung(req) {
|
|
return neueAnsicht(req) ? config.abo.termsVersionNeu : config.abo.termsVersion
|
|
}
|
|
|
|
/** 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; v4, v5, v6)? Aeltere und fehlende Fassungen: nein. */
|
|
export function isTermsV4OrNewer(termsVersion) {
|
|
return typeof termsVersion === 'string' && FASSUNGEN.some((f) => f.muster && f.muster.test(termsVersion))
|
|
}
|
|
|
|
/**
|
|
* Vertragsunterlagen zur zugestimmten Fassung (Tabelle FASSUNGEN): Fassung 6 (Suffix "-v6") zeigt die neuen Bedingungen
|
|
* im Vorschaupfad, Fassung 5 die bisherigen 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.
|
|
* Die Rueckgabe hat immer nur diese vier Felder (kein archiv, muster, nummer).
|
|
*/
|
|
export function termsDocFor(termsVersion) {
|
|
const f = fassungFuer(termsVersion)
|
|
const version = typeof termsVersion === 'string' && termsVersion ? termsVersion : TERMS_VERSION_V3
|
|
return { version, url: f.url, pdfFile: f.pdfFile, endParagraph: f.endParagraph }
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------------------------
|
|
* Fassung 6 (Entwurf bis zur Freigabe, E20): Wortlaute fuer die Bestellseiten und Einschalt-Seiten
|
|
* sowie die Unterlagen-Tabelle der Funktionen. Dieses Paket liefert die Konstanten; verdrahtet werden
|
|
* sie erst mit P5/P7 (Nachtrag N33). Texte in "du"-Form, ohne Preise.
|
|
* ------------------------------------------------------------------------------------------- */
|
|
|
|
/** Fassung der Funktionsunterlagen (Leistungsbeschreibungen, AVV). Entwurfstag; bei der Freigabe das Datum setzen. */
|
|
export const FUNKTIONEN_FASSUNG_V6 = '2026-10-16'
|
|
|
|
/**
|
|
* Wortlaute der Oberflaeche. linkzeile: genau ein Platzhalter {fassung} (mitFassung fuellt ihn);
|
|
* bestellen gilt fuer Abo, Wechsel und Zusatzspeicher. beginn: das Haekchen nur fuer Privatkunden in der Frist.
|
|
* hinweis: die Pflichten der alten Haekchen verantwortung, stripe, gesundheit als ein Satz auf der Einschalt-Seite.
|
|
*/
|
|
export const WORTLAUT_V6 = Object.freeze({
|
|
linkzeile: Object.freeze({
|
|
bestellen: 'Es gelten die Vertragsbedingungen mit Leistungsbeschreibung, Auftragsverarbeitung und Widerrufsbelehrung (Fassung {fassung}). Alles kommt danach auch als PDF per Mail.',
|
|
einschaltenMitAvv: 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung {fassung}).',
|
|
einschaltenOhneAvv: 'Es gilt die Leistungsbeschreibung (Fassung {fassung}).',
|
|
}),
|
|
beginn: Object.freeze({
|
|
abo: 'Bitte beginnt sofort mit dem Abo. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.',
|
|
wechsel: 'Bitte beginnt sofort mit dem Wechsel. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.',
|
|
einrichtung: 'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.',
|
|
}),
|
|
hinweis: Object.freeze({
|
|
shop: 'Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.',
|
|
unterkunft: 'Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.',
|
|
termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.',
|
|
}),
|
|
})
|
|
|
|
/** Ersetzt {fassung} im Wortlaut; wirft bei leerer Fassung oder wenn der Text keinen Platzhalter enthaelt. */
|
|
export function mitFassung(text, fassung) {
|
|
if (typeof fassung !== 'string' || fassung.trim() === '') throw new Error('Fassung fehlt oder ist leer')
|
|
if (typeof text !== 'string' || !text.includes('{fassung}')) throw new Error('Platzhalter {fassung} fehlt im Text')
|
|
return text.split('{fassung}').join(fassung)
|
|
}
|
|
|
|
/** Ein Dokument der Funktionsunterlagen im Vorschaupfad: Seite, PDF und Dateiname des Mail-Anhangs. */
|
|
function dok(slug, anhang) {
|
|
return Object.freeze({
|
|
version: FUNKTIONEN_FASSUNG_V6,
|
|
url: `/neu/rechtliches/${slug}-${FUNKTIONEN_FASSUNG_V6}.html`,
|
|
pdfFile: `neu/rechtliches/${slug}-${FUNKTIONEN_FASSUNG_V6}.pdf`,
|
|
anhangName: `${anhang}-${FUNKTIONEN_FASSUNG_V6}.pdf`,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Unterlagen je Funktion in Fassung 6: { [key]: { lb, avv } }. avv ist null, wo keine personenbezogenen Daten im
|
|
* Auftrag verarbeitet werden (News, Oeffnungszeiten live). Reihenfolge: news, termine, shop, unterkunft, zeiten, analyse.
|
|
*/
|
|
export const FUNKTIONEN_UNTERLAGEN_V6 = Object.freeze({
|
|
news: Object.freeze({ lb: dok('leistungsbeschreibung-news', 'Leistungsbeschreibung-News-Pinnwand'), avv: null }),
|
|
termine: Object.freeze({ lb: dok('leistungsbeschreibung-terminplaner', 'Leistungsbeschreibung-Terminplaner'), avv: dok('avv-terminplaner', 'AVV-Terminplaner') }),
|
|
shop: Object.freeze({ lb: dok('leistungsbeschreibung-webshop', 'Leistungsbeschreibung-Webshop'), avv: dok('avv-webshop', 'AVV-Webshop') }),
|
|
unterkunft: Object.freeze({ lb: dok('leistungsbeschreibung-unterkunft', 'Leistungsbeschreibung-Buchungskalender'), avv: dok('avv-unterkunft', 'AVV-Buchungskalender') }),
|
|
zeiten: Object.freeze({ lb: dok('leistungsbeschreibung-zeiten', 'Leistungsbeschreibung-Oeffnungszeiten-live'), avv: null }),
|
|
analyse: Object.freeze({ lb: dok('leistungsbeschreibung-website-analyse', 'Leistungsbeschreibung-Website-Analyse'), avv: dok('avv-website-analyse', 'AVV-Website-Analyse') }),
|
|
})
|