Files
Webklar-Kundenbereich/server/config.js
T

1139 lines
56 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 'dotenv/config'
import path from 'node:path'
/** Einzige erlaubte Datenbank (Ticketsystem). Keine separate Portal-DB mehr. */
export const WOMS_DATABASE_ID = 'woms-database'
const DEPRECATED_DATABASE_IDS = new Set([
'6943bf0e0003291f8c35', // frühere Portal-/Legacy-DB
])
function resolveDatabaseId() {
const fromEnv = (process.env.APPWRITE_DATABASE_ID || '').trim()
if (!fromEnv || fromEnv === WOMS_DATABASE_ID) return WOMS_DATABASE_ID
if (DEPRECATED_DATABASE_IDS.has(fromEnv)) {
console.warn(
`[config] Veraltete APPWRITE_DATABASE_ID "${fromEnv}" – es wird nur ${WOMS_DATABASE_ID} verwendet.`
)
return WOMS_DATABASE_ID
}
console.warn(
`[config] APPWRITE_DATABASE_ID="${fromEnv}" ignoriert – Portal nutzt ausschließlich ${WOMS_DATABASE_ID}.`
)
return WOMS_DATABASE_ID
}
/** Zahl >= 0 aus einer Umgebungsvariablen; leer, keine Zahl oder negativ ergibt den Vorgabewert. */
function nonNegativeNumber(raw, fallback) {
const value = Number(raw)
return String(raw ?? '').trim() !== '' && Number.isFinite(value) && value >= 0 ? value : fallback
}
const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase()
const unterkunftEnabled = (process.env.UNTERKUNFT_ENABLED || '').trim().toLowerCase()
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
/** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */
function commaList(raw, fallback) {
const list = String(raw || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
return list.length ? list : fallback
}
// Abo-Produkte (Spec 2026-09-27 "Verträge, Hosting-Abo und E-Mail-Zusatzspeicher"). Preise in Cent,
// Endpreise gemäß § 19 UStG. Texte, die Kunden sehen, stimmen mit den Vertragsbedingungen überein.
const products = {
wartung: {
key: 'wartung',
name: 'WEBklar Wartungs-Abo',
lookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
amountCents: 5000,
// Version 4 (Rechtsrunde 28.09.2026): beliebig viele E-Mail-Postfächer statt 1 E-Mail-Postfach (F07;
// Fix round 1, item 11: ausgeschrieben "E-Mail-Postfächer" statt bloß "Postfächer")
description:
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, beliebig viele E-Mail-Postfächer (zusammen 5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR WARTUNG',
priceNickname: 'Wartungs-Abo monatlich',
minTermMonths: 12,
minutesPerMonth: 60,
// Testrabatt: id passend zur amount_off-Regel (amountCents - 50), siehe global-constraints.md
testCouponId: 'WEBKLAR-TEST-050',
// Leistungen fuers Kundenportal (Vergleich, Bestellseite): [Titel, Detail], Spec 3.1 / Fassung 4
features: [
['Betrieb & Pflege Ihrer Website bzw. Web-Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', '.de, .com, .eu, .net oder .org, Registrierung und DNS-Verwaltung'],
['beliebig viele Postfächer', 'zusammen 5 GB Speicher'],
['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'],
],
},
hosting: {
key: 'hosting',
name: 'WEBklar Hosting-Abo',
lookupKey: 'webklar_hosting_monatlich_2000',
amountCents: 2000,
description:
'Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland (SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit, Updates von Betriebssystem und Server-Software), 1 Domain inklusive Verwaltung und 1 E-Mail-Postfach (5 GB). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR HOSTING',
priceNickname: 'Hosting-Abo monatlich',
minTermMonths: 12,
minutesPerMonth: 0,
testCouponId: 'WEBKLAR-TEST-050-HOSTING',
// Fix round 1, Important 3: row 1 war wörtlich von Wartung kopiert und suggerierte Website-Pflege
// (die es im Hosting-Abo nicht gibt); row 4 nennt jetzt die Abrechnung in 15-Minuten-Schritten.
features: [
['Bereitstellung Ihrer statischen Website', 'Server in Deutschland, SSL, Updates von Betriebssystem und Server-Software, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', '.de, .com, .eu, .net oder .org, Registrierung und DNS-Verwaltung'],
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'],
['Keine Pflege der Website', 'Pflege und Updates der Website nur im Wartungs-Abo'],
['Änderungen nur nach Freigabe', '100 € pro Stunde in 15-Minuten-Schritten (gemäß § 19 UStG ohne Umsatzsteuer)'],
],
},
}
// E-Mail-Zusatzspeicher: zweite Position (Stückzahl) im selben Stripe-Abo
const mailStorage = {
key: 'mailStorage',
name: 'WEBklar E-Mail-Zusatzspeicher 5 GB',
lookupKey: 'webklar_mailspeicher_5gb_monatlich_100',
amountCents: 100,
gbPerUnit: 5,
includedGb: 5,
maxUnits: 10,
priceNickname: 'E-Mail-Zusatzspeicher 5 GB monatlich',
// Hetzner-Paket (konsoleH), auf dem alle Postfaecher liegen: Gesamtgroesse und die Schwelle, ab der
// der Admin-Reiter "E-Mail-Speicher" rot warnt (Task 13)
hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 },
}
// News-Pinnwand: einmalige Einrichtung (Einmalpreis, kein Abo)
const newsSetup = {
key: 'newsSetup',
name: 'News-Pinnwand – Einrichtung',
amountCents: 3000,
lookupKey: 'webklar_news_einrichtung_3000',
oneTime: true,
priceNickname: 'News-Einrichtung 30 €',
}
// Terminplaner: einmalige Einrichtung (Einmalpreis, kein Abo); der Terminplaner selbst laeuft, solange das Wartungs-Abo laeuft
const termineSetup = {
key: 'termineSetup',
name: 'Terminplaner – Einrichtung',
amountCents: 14900,
lookupKey: 'webklar_termine_einrichtung_14900',
oneTime: true,
priceNickname: 'Terminplaner-Einrichtung 149 €',
}
// Webshop: einmalige Einrichtung (Einmalpreis, kein Abo); der Shop selbst verkauft ueber das eigene Stripe-Konto des Kunden (Connect)
const shopSetup = {
key: 'shopSetup',
name: 'Webshop – Einrichtung',
amountCents: 29900,
lookupKey: 'webklar_shop_einrichtung_29900',
oneTime: true,
priceNickname: 'Webshop-Einrichtung 299 €',
}
// Buchungskalender (Funktionsschluessel 'unterkunft'): einmalige Einrichtung (Einmalpreis, kein Abo)
const unterkunftSetup = {
key: 'unterkunftSetup',
name: 'Buchungskalender (Einrichtung)',
amountCents: 19900,
lookupKey: 'webklar_unterkunft_einrichtung_19900',
oneTime: true,
priceNickname: 'Buchungskalender-Einrichtung 199 €',
}
// Oeffnungszeiten live: einmalige Einrichtung (Einmalpreis, kein Abo); im Wartungs-Abo ist die Funktion enthalten
const zeitenSetup = {
key: 'zeitenSetup',
name: 'Öffnungszeiten live – Einrichtung',
amountCents: 3000,
lookupKey: 'webklar_zeiten_einrichtung_3000',
oneTime: true,
priceNickname: 'Öffnungszeiten-Einrichtung 30 €',
}
// Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut
const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '')
const ENABLE_BANKING_API_BASE = 'https://api.enablebanking.com'
const enableBankingAppId = (process.env.ENABLE_BANKING_APP_ID || '').trim()
const enableBankingKeyPath = (process.env.ENABLE_BANKING_KEY_PATH || '').trim()
/**
* Basisadresse der Enable-Banking-API. ENABLE_BANKING_API_BASE (nur fuer Tests: Nachbau) gilt nur fuer
* 127.0.0.1 oder localhost; jeder andere Wert wird ignoriert, damit ein verirrter Wert den signierten
* Zugriff (JWT) nie an einen fremden Server schickt.
*/
export function resolveEnableBankingApiBase(value) {
const raw = String(value || '').trim()
if (!raw) return ENABLE_BANKING_API_BASE
try {
const url = new URL(raw)
if (['http:', 'https:'].includes(url.protocol) && ['127.0.0.1', 'localhost'].includes(url.hostname)) return url.origin
} catch {
// keine gueltige Adresse: Standard
}
return ENABLE_BANKING_API_BASE
}
// Pflichthinweis zum Preis (Kleinunternehmer); die Funktionen-Register-Texte und die Preisangaben nutzen ihn
const ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG'
const ticketProjektName = (project) => project?.projectName || project?.subdomain || ''
/**
* Rechtsunterlage des Terminplaners mit Fassung und Adresse aus config.termine.avvVersion (bei jedem Zugriff gelesen).
* Tests duerfen sie ueberschreiben (Zuweisung); wird der aktuelle Standardwert zurueckgeschrieben (Wiederherstellen nach
* dem Test) oder undefined zugewiesen, gilt wieder der Standard und die Unterlage folgt der Konstante. null gilt ebenso,
* ausser bei nullIstWert (Widerrufsbelehrung): dort heisst null "keine Unterlage" (so setzen Tests eine Bestellung ohne sie).
*/
function termineRechtsunterlage(slug, { nullIstWert = false } = {}) {
let ueberschrieben // undefined = Standard
const standard = () => ({ version: config.termine.avvVersion, url: `/rechtliches/${slug}-${config.termine.avvVersion}.html` })
return {
get: () => (ueberschrieben === undefined ? standard() : ueberschrieben),
set(wert) {
const std = standard()
const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url)
ueberschrieben = zurueck ? undefined : wert
},
}
}
const termineLb = termineRechtsunterlage('leistungsbeschreibung-terminplaner')
const termineAvv = termineRechtsunterlage('avv-terminplaner')
const termineWiderruf = termineRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
const TERMINE_PREIS = '149 €'
const TERMINE_DAUER = 'meist innerhalb von 1–2 Wochen'
/**
* Bestelltexte des Terminplaners (Task F7): vier Haken, alle Besteller (D1), ohne Adressen im Text (die versionierten Links
* zeigt die Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.termine.avvVersion; die
* dynamischen Teile (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text.
* Die Haken werden je Fassung einmal gebaut und danach unveraendert herausgegeben (Tests aendern einzelne Texte und stellen sie
* zurueck); aendert sich die Fassung zur Laufzeit, entstehen sie neu. Bei Textaenderung neues Fassungsdatum und neuen Hash im
* Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und die Pflichtinformationen sind Entwurf bis
* zur Kanzlei (Kanzlei-Paket, Fragen Q3, Q5, Q19, Q20).
*/
function termineConsent() {
let versionGesetzt // undefined = config.termine.avvVersion
let gebaut = null // { version, items }
const version = () => versionGesetzt ?? config.termine.avvVersion
const items = (v) => [
{
id: 'unterlagen',
haken: true,
text: `Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${v} zu.`,
},
{
id: 'gesundheit',
haken: true,
text: 'Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).',
},
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
},
{
id: 'information',
haken: true,
text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis ${TERMINE_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${TERMINE_DAUER}, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`,
},
]
return {
get version() {
return version()
},
set version(wert) {
versionGesetzt = wert === undefined || wert === null || wert === config.termine.avvVersion ? undefined : wert
},
knopf: `Zahlungspflichtig bestellen · ${TERMINE_PREIS}`,
get items() {
const v = version()
if (gebaut?.version !== v) gebaut = { version: v, items: items(v) }
return gebaut.items
},
}
}
/**
* Rechtsunterlage des Webshops: wie termineRechtsunterlage, aber mit Fassung und Adresse aus config.shop.fassung (bei jedem
* Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.shop); test/legalDocs.test.js gleicht beide ab.
*/
function shopRechtsunterlage(slug, { nullIstWert = false } = {}) {
let ueberschrieben // undefined = Standard
const standard = () => ({ version: config.shop.fassung, url: `/rechtliches/${slug}-${config.shop.fassung}.html` })
return {
get: () => (ueberschrieben === undefined ? standard() : ueberschrieben),
set(wert) {
const std = standard()
const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url)
ueberschrieben = zurueck ? undefined : wert
},
}
}
const shopLb = shopRechtsunterlage('leistungsbeschreibung-webshop')
const shopAvv = shopRechtsunterlage('avv-webshop')
const shopWiderruf = shopRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
const SHOP_PREIS = '299 €'
const SHOP_DAUER = 'meist innerhalb von 2–3 Wochen'
/**
* Bestelltexte des Webshops: fuenf Haken (alle Besteller), ohne Adressen im Text (die versionierten Links zeigt die
* Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.shop.fassung; die dynamischen Teile
* (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text. Aufbau wie
* termineConsent (Haken je Fassung einmal gebaut; Tests duerfen einzelne Texte aendern und zurueckstellen). Bei Textaenderung
* neues Fassungsdatum und neuen Hash im Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und
* die Pflichtinformationen sind Entwurf bis zur Kanzlei.
*/
function shopConsent() {
let versionGesetzt // undefined = config.shop.fassung
let gebaut = null // { version, items }
const version = () => versionGesetzt ?? config.shop.fassung
const items = (v) => [
{
id: 'unterlagen',
haken: true,
text: `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${v} zu.`,
},
{
id: 'verantwortung',
haken: true,
text: 'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.',
},
{
id: 'stripe',
haken: true,
text: 'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.',
},
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
},
{
id: 'information',
haken: true,
text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis ${SHOP_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${SHOP_DAUER}, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`,
},
]
return {
get version() {
return version()
},
set version(wert) {
versionGesetzt = wert === undefined || wert === null || wert === config.shop.fassung ? undefined : wert
},
knopf: `Zahlungspflichtig bestellen · ${SHOP_PREIS}`,
get items() {
const v = version()
if (gebaut?.version !== v) gebaut = { version: v, items: items(v) }
return gebaut.items
},
}
}
/**
* Rechtsunterlage des Buchungskalenders: wie shopRechtsunterlage, aber mit Fassung und Adresse aus config.unterkunft.fassung
* (bei jedem Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.unterkunft); test/legalDocs.test.js gleicht beide ab.
*/
function unterkunftRechtsunterlage(slug, { nullIstWert = false } = {}) {
let ueberschrieben // undefined = Standard
const standard = () => ({ version: config.unterkunft.fassung, url: `/rechtliches/${slug}-${config.unterkunft.fassung}.html` })
return {
get: () => (ueberschrieben === undefined ? standard() : ueberschrieben),
set(wert) {
const std = standard()
const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url)
ueberschrieben = zurueck ? undefined : wert
},
}
}
const unterkunftLb = unterkunftRechtsunterlage('leistungsbeschreibung-unterkunft')
const unterkunftAvv = unterkunftRechtsunterlage('avv-unterkunft')
const unterkunftWiderruf = unterkunftRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
const UNTERKUNFT_PREIS = '199 €'
const UNTERKUNFT_DAUER = 'meist innerhalb von 1–2 Wochen'
/**
* Bestelltexte des Buchungskalenders: sechs Haken (alle Besteller), Aufbau wie shopConsent (Haken je Fassung einmal gebaut).
* Die Fassung (consent.version) ist config.unterkunft.fassung. Bei Textaenderung neues Fassungsdatum und neuen Hash im Pin von
* test/featureConsentTexte.test.js. Zitat, Wertersatz-Satz und Pflichtinformationen sind Entwurf bis zur Kanzlei.
*/
function unterkunftConsent() {
let versionGesetzt // undefined = config.unterkunft.fassung
let gebaut = null // { version, items }
const version = () => versionGesetzt ?? config.unterkunft.fassung
const items = (v) => [
{
id: 'unterlagen',
haken: true,
text: `Ich stimme der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender in der Fassung ${v} zu.`,
},
{
id: 'verantwortung',
haken: true,
text: 'Ich vermiete in eigenem Namen; für Preise, Stornobedingungen, Rechnungen und Meldepflichten bin ich verantwortlich.',
},
{
id: 'rechnung',
haken: true,
text: 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG). Für Richtigkeit von Preisen und Steuerangaben bin ich verantwortlich.',
},
{
id: 'stripe',
haken: true,
text: 'Online-Zahlungen meiner Gäste laufen, sofern ich sie nutze, über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Gäste und erhebt keine Umsatzbeteiligung.',
},
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Buchungskalenders beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
},
{
id: 'information',
haken: true,
text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Buchungskalenders für diese Website: Einbau des Kalender-Widgets, Verwaltung im Portal, Rechnungserstellung für Gäste), Gesamtpreis ${UNTERKUNFT_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${UNTERKUNFT_DAUER}, keine laufende Gebühr von WEBklar; der Buchungskalender läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`,
},
]
return {
get version() {
return version()
},
set version(wert) {
versionGesetzt = wert === undefined || wert === null || wert === config.unterkunft.fassung ? undefined : wert
},
knopf: `Zahlungspflichtig bestellen · ${UNTERKUNFT_PREIS}`,
get items() {
const v = version()
if (gebaut?.version !== v) gebaut = { version: v, items: items(v) }
return gebaut.items
},
}
}
/**
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner, Webshop): Preis, Zustimmungen, Rechtsunterlagen,
* Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig,
* featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar,
* bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem
* Eintrag setzt, ueberschreibt den Standard aus featureRegistry.js.
* ticket.topic(project) liefert den Ticket-Betreff, ticket.details(ctx) den Text des Einrichtungs-Tickets
* (ctx = { feature, doc, project, customer }: Register-Eintrag, portalFeatures-, websiteProjects- und Kunden-Dokument).
*/
const features = {
news: {
key: 'news',
product: 'newsSetup',
label: { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' },
priceLabel: '30 €',
setupEstimate: 'meist innerhalb von 1–2 Wochen',
// Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 5 Nr. 2 BGB,
// Wertersatz § 357a Abs. 2 BGB). Aufbau wie der Gaestetext ZUSTIMMUNG_VORZEITIG; Zitat und Wertersatz-Satz sind
// Entwurf bis zur Kanzlei (Kanzlei-Paket, Frage Q3). Die Fassung folgt config.termine.avvVersion (<F>); bestehende
// Bestellungen behalten Text und Fassung, die am Dokument und im Nachweis gespeichert sind.
consent: {
get version() {
return config.termine.avvVersion
},
knopf: 'Zahlungspflichtig bestellen · 30 €',
items: [
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
},
],
},
// Stand der Leistungsbeschreibung (public/vertragsunterlagen/funktionen-leistungsbeschreibung.html) = <F>
leistungsbeschreibung: {
get version() {
return config.termine.avvVersion
},
url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html',
},
avv: null,
widerruf: null,
returnHash: 'news',
returnProjekt: false,
ticket: {
topic: (project) => `News-Pinnwand einbauen – ${ticketProjektName(project)}`.trim(),
details: async (ctx) => {
const { feature, doc, project } = ctx || {}
const siteKey = doc?.siteKey || ''
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
return [
`Funktion: ${feature?.key || 'news'}`,
`Projekt: ${ticketProjektName(project)}`,
`Domain: ${domain}`,
`siteKey: ${siteKey}`,
`Einbau: <div data-webklar-news="${siteKey}"></div> + https://project.webklar.com/embed/news.js`,
'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)',
].join('\n')
},
},
mails: {
funktion: 'News-Pinnwand',
formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' },
knopf: 'Beitrag schreiben',
danach: 'Danach kannst Du Beiträge im Portal schreiben.',
bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.',
preisHinweis: `einmalig, ${ustHinweis}; die Nutzung ist kostenlos, es läuft kein Abo`,
anleitung: false,
},
requiresWartungsAbo: false,
ticketFreischaltung: true,
disabledMessage:
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.',
orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.',
},
termine: {
key: 'termine',
product: 'termineSetup',
label: { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' },
priceLabel: TERMINE_PREIS,
setupEstimate: TERMINE_DAUER,
consent: termineConsent(),
// Fassung und Adresse folgen config.termine.avvVersion bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js
// importiert keine Dienste, test/legalDocs.test.js gleicht beide ab)
get leistungsbeschreibung() {
return termineLb.get()
},
set leistungsbeschreibung(wert) {
termineLb.set(wert)
},
get avv() {
return termineAvv.get()
},
set avv(wert) {
termineAvv.set(wert)
},
get widerruf() {
return termineWiderruf.get()
},
set widerruf(wert) {
termineWiderruf.set(wert)
},
returnHash: 'termine',
returnProjekt: true,
ticket: {
topic: (project) => `Terminplaner einbauen – ${ticketProjektName(project)}`,
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => {
const { termineTicketDetails } = await import('./services/termine/ticketDetails.js')
return termineTicketDetails(ctx)
},
},
mails: {
funktion: 'Terminplaner',
formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' },
knopf: 'Termine öffnen',
// Platzhalter: den Wortlaut schreibt der Inhaber in seiner Mailrunde
danach: 'ENTWURF (Mailrunde): Danach kannst Du im Portal Termine und Einstellungen verwalten.',
bereit: 'ENTWURF (Mailrunde): Du kannst ab sofort Termine online buchen lassen.',
preisHinweis: `einmalig, ${ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft`,
anleitung: true,
},
requiresWartungsAbo: true,
ticketFreischaltung: true,
disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.',
},
shop: {
key: 'shop',
product: 'shopSetup',
label: { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' },
priceLabel: SHOP_PREIS,
setupEstimate: SHOP_DAUER,
consent: shopConsent(),
// Fassung und Adresse folgen config.shop.fassung bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js
// importiert keine Dienste, test/legalDocs.test.js gleicht beide ab)
get leistungsbeschreibung() {
return shopLb.get()
},
set leistungsbeschreibung(wert) {
shopLb.set(wert)
},
get avv() {
return shopAvv.get()
},
set avv(wert) {
shopAvv.set(wert)
},
get widerruf() {
return shopWiderruf.get()
},
set widerruf(wert) {
shopWiderruf.set(wert)
},
returnHash: 'webshop',
returnProjekt: true,
ticket: {
topic: (project) => `Webshop einbauen – ${ticketProjektName(project)}`,
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => {
const { shopTicketDetails } = await import('./services/shop/ticketDetails.js')
return shopTicketDetails(ctx)
},
},
mails: {
funktion: 'Webshop',
formen: { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' },
knopf: 'Webshop öffnen',
danach: 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.',
bereit: 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.',
preisHinweis: `einmalig, ${ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`,
anleitung: false,
},
requiresWartungsAbo: false,
ticketFreischaltung: true,
disabledMessage: 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Webshop ist noch nicht verfügbar.',
},
unterkunft: {
key: 'unterkunft',
product: 'unterkunftSetup',
label: { name: 'Buchungskalender', kurz: 'Buchungskalender', keine: 'keinen Buchungskalender' },
priceLabel: UNTERKUNFT_PREIS,
setupEstimate: UNTERKUNFT_DAUER,
consent: unterkunftConsent(),
get leistungsbeschreibung() {
return unterkunftLb.get()
},
set leistungsbeschreibung(wert) {
unterkunftLb.set(wert)
},
get avv() {
return unterkunftAvv.get()
},
set avv(wert) {
unterkunftAvv.set(wert)
},
get widerruf() {
return unterkunftWiderruf.get()
},
set widerruf(wert) {
unterkunftWiderruf.set(wert)
},
returnHash: 'buchungen',
returnProjekt: true,
ticket: {
topic: (project) => `Buchungskalender einbauen – ${ticketProjektName(project)}`,
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => {
const { unterkunftTicketDetails } = await import('./services/unterkunft/ticketDetails.js')
return unterkunftTicketDetails(ctx)
},
},
mails: {
funktion: 'Buchungskalender',
formen: { dein: 'Dein Buchungskalender', nom: 'der Buchungskalender', akk: 'den Buchungskalender', gen: 'des Buchungskalenders', pron: 'ihn' },
knopf: 'Buchungen öffnen',
danach: 'Danach kannst Du im Portal Unterkünfte, Preise und Anfragen verwalten.',
bereit: 'Dein Buchungskalender ist eingebaut. Du kannst ab sofort Anfragen und Buchungen im Portal verwalten.',
preisHinweis: `einmalig, ${ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`,
anleitung: false,
},
requiresWartungsAbo: false,
ticketFreischaltung: true,
disabledMessage: 'Der Buchungskalender ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
orderedConflictMessage: 'Der Buchungskalender ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Buchungskalender ist noch nicht verfügbar.',
},
// Oeffnungszeiten live (Spec 2026-10-04): 30 EUR einmalig, im Wartungs-Abo ohne Aufpreis (aboInklusive, consentFrei)
zeiten: {
key: 'zeiten',
product: 'zeitenSetup',
label: { name: 'Öffnungszeiten live', kurz: 'Zeiten', keine: 'keine Öffnungszeiten live' },
priceLabel: '30 €',
setupEstimate: 'meist innerhalb weniger Tage',
// Wortlaut der Zustimmung (bezahlter Weg); wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 5 Nr. 2 BGB,
// Wertersatz § 357a Abs. 2 BGB; der Wertersatz-Satz steht wörtlich wie bei News und Terminplaner, Entwurf bis zur Rechtsrunde, ZQ3).
// Fassung 2026-10-05: mit dem Wertersatz-Satz; bestehende Bestellungen behalten Text und Fassung aus ihrem Nachweis.
consent: {
version: '2026-10-05',
knopf: 'Zahlungspflichtig bestellen · 30 €',
items: [
{
id: 'leistung',
haken: true,
text: 'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen und bestelle die Einrichtung.',
},
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
},
],
},
// Zustimmung des Gratisweges (Wartungs-Abo laeuft): ein Haken, kein Widerrufsverlangen, kein Preis
consentFrei: {
version: '2026-10-04',
knopf: 'Einschalten',
items: [
{
id: 'leistung',
haken: true,
text: 'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen und schalte die Funktion ein. Sie ist in meinem Wartungs-Abo ohne Aufpreis enthalten.',
},
],
},
leistungsbeschreibung: { version: '2026-10-04', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' },
avv: null,
widerruf: null,
returnHash: 'zeiten',
returnProjekt: true,
ticket: {
topic: (project) => `Öffnungszeiten live einbauen – ${ticketProjektName(project)}`.trim(),
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => (await import('./services/zeiten/ticketDetails.js')).zeitenTicketDetails(ctx),
},
mails: {
funktion: 'Öffnungszeiten live',
formen: { dein: 'Deine Öffnungszeiten live', nom: 'Öffnungszeiten live', akk: 'Öffnungszeiten live', gen: 'von Öffnungszeiten live', pron: 'sie' },
knopf: 'Zeiten bearbeiten',
danach: 'Danach kannst Du Deine Zeiten im Portal eintragen.',
bereit: 'Deine Öffnungszeiten stehen ab sofort live auf Deiner Seite.',
preisHinweis: 'einmalig, ohne Umsatzsteuer nach § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo',
anleitung: false,
},
requiresWartungsAbo: false,
// Im Wartungs-Abo ohne Aufpreis enthalten (Gratisweg POST /api/funktionen/zeiten/einschalten)
aboInklusive: true,
ticketFreischaltung: true,
// Die oeffentliche Schnittstelle liefert in diesen Zustaenden dasselbe (Ersatz-Tabelle bis zur Freischaltung)
teilweiseLive: ['setup', 'active'],
disabledMessage:
'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.',
orderedConflictMessage: 'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.',
unavailableMessage: 'Öffnungszeiten live ist noch nicht verfügbar.',
},
}
export const config = {
port: Number(process.env.PORT) || 3000,
sessionSecret: process.env.SESSION_SECRET || '',
cookieName: process.env.SESSION_COOKIE_NAME || 'webklar_portal_session',
allowedCustomerStatuses: (process.env.ALLOWED_CUSTOMER_STATUSES || 'active')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
appwrite: {
endpoint: process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1',
projectId: process.env.APPWRITE_PROJECT_ID || '6a1058610003c5a13a05',
databaseId: resolveDatabaseId(),
apiKey: process.env.APPWRITE_API_KEY || '',
},
collections: {
customers: process.env.APPWRITE_COLLECTION_CUSTOMERS || 'customers',
customerPortalAccess: process.env.APPWRITE_COLLECTION_CUSTOMER_PORTAL_ACCESS || 'customerPortalAccess',
websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects',
portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures',
analyseConsents: process.env.APPWRITE_COLLECTION_ANALYSE_CONSENTS || 'analyseConsents',
// Zustimmungsnachweis zur Bestellung einer Funktion (nur Server-Key; scripts/feature-setup.mjs)
featureConsents: process.env.APPWRITE_COLLECTION_FEATURE_CONSENTS || 'featureConsents',
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
portalMessages: process.env.APPWRITE_COLLECTION_PORTAL_MESSAGES || 'portalMessages',
subscriptions: process.env.APPWRITE_COLLECTION_SUBSCRIPTIONS || 'subscriptions',
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits',
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts',
customerViewLog: process.env.APPWRITE_COLLECTION_CUSTOMER_VIEW_LOG || 'customerViewLog',
// Finanzen (Bankkonto, Buchungen, Einstellungen, Fixkosten)
bankConnections: process.env.APPWRITE_COLLECTION_BANK_CONNECTIONS || 'bankConnections',
bankTransactions: process.env.APPWRITE_COLLECTION_BANK_TRANSACTIONS || 'bankTransactions',
financeSettings: process.env.APPWRITE_COLLECTION_FINANCE_SETTINGS || 'financeSettings',
financeFixedCosts: process.env.APPWRITE_COLLECTION_FINANCE_FIXED_COSTS || 'financeFixedCosts',
// Terminplaner (Einstellungen, Buchungen, Belegungszellen, Mail-Warteschlange; scripts/termine-setup.mjs)
termineConfig: process.env.APPWRITE_COLLECTION_TERMINE_CONFIG || 'termineConfig',
termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings',
termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims',
termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox',
// Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe, Archiv der bestaetigten Rechtstexte; scripts/shop-setup.mjs)
shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig',
shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts',
shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders',
shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe',
shopRechtstexteArchiv: process.env.APPWRITE_COLLECTION_SHOP_RECHTSTEXTE_ARCHIV || 'shopRechtstexteArchiv',
// Buchungskalender (Unterkuenfte, Preise, Sperren, Anfragen, Rechnungen, Einstellungen; scripts/unterkunft-setup.mjs)
ukUnterkuenfte: process.env.APPWRITE_COLLECTION_UK_UNTERKUENFTE || 'ukUnterkuenfte',
ukPreise: process.env.APPWRITE_COLLECTION_UK_PREISE || 'ukPreise',
ukSperren: process.env.APPWRITE_COLLECTION_UK_SPERREN || 'ukSperren',
ukAnfragen: process.env.APPWRITE_COLLECTION_UK_ANFRAGEN || 'ukAnfragen',
ukRechnungen: process.env.APPWRITE_COLLECTION_UK_RECHNUNGEN || 'ukRechnungen',
ukEinstellungen: process.env.APPWRITE_COLLECTION_UK_EINSTELLUNGEN || 'ukEinstellungen',
// Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs)
oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten',
zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen',
},
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
// Ticketsystem (WOMS), z. B. fuer den Link "Ticket oeffnen" in der Push-Zeitmail
womsBaseUrl,
// Push-Zeitmail: signierte Links zum Eintragen der Zeit eines Git-Pushes (ohne Login)
pushTime: {
secret: process.env.PUSH_TIME_SECRET || process.env.SESSION_SECRET || '',
ttlDays: 14,
},
products,
mailStorage,
newsSetup,
termineSetup,
shopSetup,
unterkunftSetup,
zeitenSetup,
ustHinweis,
// Register der kaufbaren Funktionen (siehe oben)
features,
// Funktionen-Tab (News-Pinnwand): Alias auf den News-Eintrag des Registers, Zeiteinschaetzung auf der
// Buchungsseite und Wortlaut der Zustimmung (die Getter folgen config.features.news zur Laufzeit)
funktionen: {
get setupEstimate() {
return config.features.news.setupEstimate
},
get consentText() {
return config.features.news.consent.items[0].text
},
},
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
// Die Werte kommen aus dem Produktkatalog (products.wartung).
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
// Nur für Tests: Stripe-Nachbau statt api.stripe.com (z. B. http://127.0.0.1:12111)
apiBase: process.env.STRIPE_API_BASE || '',
priceLookupKey: products.wartung.lookupKey,
priceAmountCents: products.wartung.amountCents,
productName: products.wartung.name,
statementDescriptor: products.wartung.statementDescriptor,
// Generisches Logo, allen drei Stripe-Produkten mitgegeben (ensurePrice); ein Katalogeintrag
// kann das mit einem eigenen "image"-Feld ueberschreiben
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
invoiceFooter:
process.env.STRIPE_INVOICE_FOOTER ||
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler',
},
// Finom-Geschaeftskonto lesend ueber Enable Banking (PSD2). Der private Schluessel liegt als Datei auf dem
// Server (nie im Repo); der Client liest ihn bei jedem Aufruf, ein Schluesseltausch wirkt ohne Neustart.
enableBanking: {
appId: enableBankingAppId,
keyPath: enableBankingKeyPath,
apiBase: resolveEnableBankingApiBase(process.env.ENABLE_BANKING_API_BASE),
redirectUrl: `${womsBaseUrl}/finanzen/bank-callback`,
aspsp: { name: 'Finom', country: 'DE' },
enabled: Boolean(enableBankingAppId && enableBankingKeyPath),
},
abo: {
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
termsVersion: '2026-09-30-v5',
// Mindestlaufzeit ab Leistungsbeginn; Kuendigung zum Ende bis 1 Monat vorher, danach monatlich
minTermMonths: products.wartung.minTermMonths,
minutesPerMonth: products.wartung.minutesPerMonth,
creditValidityMonths: 12,
usageRoundingMinutes: 15,
// Widerrufsfrist und "Beginn nach Ablauf der Widerrufsfrist": server/services/deadlines.js
// Pilotbetrieb: nur diese Kunden-E-Mails duerfen bestellen (leer = alle)
pilotEmails: (process.env.ABO_PILOT_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Testkonten zahlen nur den Stripe-Mindestbetrag (0,50 €) ueber einen Rabatt auf den echten Preis
testEmails: (process.env.ABO_TEST_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Fix round 1, Minor 8: einzige Quelle ist der Katalog (products.wartung.testCouponId); Hosting
// hat sein eigenes unter products.hosting.testCouponId
testCouponId: products.wartung.testCouponId,
},
// Individuelle Abo-Angebote (z. B. erste 2 Monate je 25 €): je Angebot ein einmal einloesbarer Stripe-Coupon
offers: {
maxMonths: 12,
defaultValidityDays: 30,
maxValidityDays: 366,
},
// Uebernahmegutschrift: beim bisherigen Anbieter bereits bezahlte Zeit wird mit den Abo-Gebuehren verrechnet
takeover: {
// Bei jeder Textaenderung an den Bedingungen der Gutschrift hochziehen (wird mit dem Antrag gespeichert).
// Entwurf vom 27.09.2026 (Gutschrift fuer beide Abos); das Freigabedatum wird beim Livegang eingetragen.
conditionsVersion: '2026-09-30',
// Nachweise liegen ausserhalb von public/ (gitignored, ueberlebt Deploys wie preview-data/)
uploadDir: process.env.TAKEOVER_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'takeover-credits'),
maxFiles: 3,
maxFileBytes: 8 * 1024 * 1024,
maxTotalBytes: 10 * 1024 * 1024,
// Plausibilitaetsgrenze fuer eine einzelne Gutschrift (Tippfehler abfangen)
maxAmountCents: 100000,
maxOpenPerCustomer: 3,
// Nachweise abgelehnter Antraege werden nach dieser Frist geloescht (Datenschutzerklaerung Abschnitt 7)
rejectedRetentionDays: 183,
},
// News-Pinnwand: Fotos liegen ausserhalb von public/ (gitignored, ueberleben Deploys)
news: {
uploadDir: process.env.NEWS_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'news'),
},
// Chat-Anhaenge (Bilder, Dateien, Kamerafotos, Sprachnachrichten): liegen ausserhalb von public/
// (gitignored, ueberleben Deploys wie preview-data/)
chat: {
uploadDir: process.env.CHAT_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'chat'),
maxFileBytes: 25 * 1024 * 1024,
maxFilesPerMessage: 10,
// Uploads werden im Speicher gepuffert: hoechstens so viele gleichzeitig, weitere warten
maxParallelUploads: 4,
// Missbrauchsschutz: Uploads je Kunde und Stunde, Gesamtspeicher je Kunde, freier Platz auf dem Server
maxUploadsPerHour: 200,
maxBytesPerCustomer: 1024 * 1024 * 1024,
minFreeDiskBytes: 2 * 1024 * 1024 * 1024,
// Hochgeladen, aber nie abgeschickt: nach dieser Zeit loeschen (Datenschutzerklaerung Abschnitt 6)
pendingTtlHours: 24,
},
smtp: {
host: process.env.SMTP_HOST || '',
port: Number(process.env.SMTP_PORT) || 587,
secure: (process.env.SMTP_SECURE || 'false') === 'true',
user: process.env.SMTP_USERNAME || '',
pass: process.env.SMTP_PASSWORD || '',
from: process.env.SMTP_FROM || process.env.SMTP_USERNAME || '',
// Antworten auf Kundenmails landen hier statt im Versandpostfach (noreply@)
replyTo: process.env.SMTP_REPLY_TO || '',
},
chatNotify: {
emails: (process.env.CHAT_NOTIFY_EMAILS || 'kenso@webklar.com,andrej@webklar.com')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
throttleMin: Number(process.env.CHAT_NOTIFY_THROTTLE_MIN) || 15,
},
gitea: {
webhookToken: process.env.GITEA_WEBHOOK_TOKEN || '',
baseUrl: process.env.GITEA_BASE_URL || 'https://git.webklar.com',
apiToken: process.env.GITEA_API_TOKEN || '',
templateOwner: process.env.GITEA_TEMPLATE_OWNER || 'knso',
templateRepo: process.env.GITEA_TEMPLATE_REPO || 'webklar-preview-template',
defaultOwner: process.env.GITEA_DEFAULT_OWNER || 'knso',
// Ziel-Organisation des Umzugs (siehe docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md)
org: process.env.GITEA_ORG || 'WEBklar',
// Login des System-Kontos, das automatische Commits/Pushes ausfuehrt (kein Arbeitsblatt dafuer)
systemUser: process.env.GITEA_SYSTEM_USER || 'webklar-system',
// Interne Repos, die trotz Zuweisung nicht nach GITEA_ORG umziehen
keepInPlace: (
process.env.GITEA_KEEP_IN_PLACE ||
'ANDJ/Emailsorter,ANDJ/Steuerungstechnik,JUSN/tickte-system,JUSN/Webklar,knso/eship,knso/Webklar-Kundenbereich'
)
.split(',')
.map((s) => s.trim())
.filter(Boolean),
// Beim Zuweisen eines Projekts werden Pushes der letzten so-vielen Tage nachgetragen
backfillDays: Number(process.env.GITEA_BACKFILL_DAYS) || 30,
// Zeitlimit fuer jeden Gitea-API-Aufruf (giteaFetch, fetchPreviewConfig); verhindert ein
// haengendes Portal, wenn Gitea nicht antwortet (Controller-Ruling final-fix Punkt 1).
timeoutMs: Number(process.env.GITEA_TIMEOUT_MS) || 30000,
},
cors: {
allowedOrigins: (process.env.ADMIN_CORS_ORIGINS || 'https://ticket.webklar.com,http://localhost:5173')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
preview: {
baseHost: process.env.PREVIEW_BASE_HOST || 'project.webklar.com',
deployRoot: process.env.PREVIEW_DEPLOY_ROOT || '',
traefikDynamicDir: process.env.TRAEFIK_DYNAMIC_DIR || '',
sessionSecret:
process.env.PREVIEW_SESSION_SECRET || process.env.SESSION_SECRET || '',
cookieDomain: process.env.PREVIEW_COOKIE_DOMAIN || '.project.webklar.com',
sessionMaxAge: Number(process.env.PREVIEW_SESSION_MAX_AGE || 60 * 60 * 24 * 7),
},
previewRunner: {
enabled: process.env.PREVIEW_RUNNER === 'on',
url: process.env.PREVIEW_RUNNER_URL || 'http://webklar-preview-runner:8080',
token: process.env.PREVIEW_RUNNER_TOKEN || '',
logDir: process.env.PREVIEW_LOG_DIR || path.join(process.cwd(), 'upload-data', 'preview-logs'),
},
analyse: {
enabled: process.env.ANALYSE_ENABLED === 'on',
url: process.env.ANALYTICS_URL || 'http://webklar-analytics:8091',
adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '',
heatSecret: process.env.ANALYTICS_HEAT_SECRET || '',
avvVersion: '2026-10-02',
},
// Terminplaner (Funktionsschluessel 'termine'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur
// Laufzeit); Schalter siehe server/services/termine/schalter.js
termine: {
// off (Vorgabe) | pilot (nur TERMINE_PILOT_EMAILS) | on (alle Kunden)
mode: ['pilot', 'on'].includes(termineEnabled) ? termineEnabled : 'off',
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
pilotEmails: (process.env.TERMINE_PILOT_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Fassung der Terminplaner-Bestellunterlagen (Leistungsbeschreibung, AVV, Widerrufsbelehrung, Bestelltexte, News-
// Zustimmung): Berliner Kalendertag des Fassungs-Commits (JJJJ-MM-TT). Eine ausgelieferte Fassung wird nie
// ueberschrieben: nach Uebergabe an die Kanzlei oder Livegang heisst jede Textaenderung neues Datum und neue Dateien.
avvVersion: '2026-10-05',
holdMinutes: 30,
zelleMin: 5,
mailsProStunde: 200,
// Mail-Fairness (outbox.js): hoechstens so viele Mails je Funktion und Stunde (gleitendes Fenster, unter mailsProStunde)
mailsProFunktion: 40,
// Anteil der Stunde, der Vorrang- und Normal-Mails vorbehalten bleibt: nachrangige Mails (G05, K20, K21) nehmen hoechstens
// max(1, floor(mailsProStunde x (1 - mailsReserve))) Plaetze
mailsReserve: 0.25,
// Eintraege je Abfrage der Mail-Warteschlange (verarbeite, Minutenjob): hoechstens so viele fuer eine Klasse und Runde
mailListe: 100,
// Hoechstens so viele Buchungen loescht ein Schritt der Aufbewahrungs-Loeschung je Lauf (Tagesjob): je Status, bei erledigt und
// nichterschienen zusammen je Funktion; der Rest folgt am naechsten Tag
loeschDeckel: 2000,
// Alle Schritte der Aufbewahrungs-Loeschung eines Laufs zusammen loeschen hoechstens so viele Buchungen (Gesamtdeckel ueber alle Funktionen)
loeschDeckelGesamt: 20000,
// Hoechstens so viele alte Belegungszellen (bis mehr als einen Tag vorbei) loescht der Zellen-Sweep des Tagesjobs je Lauf; der Rest folgt am naechsten Tag
zellenDeckel: 5000,
// Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot)
formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3),
// Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob
// eine Adresse oder Buchung bekannt ist (Zeitunterschied). Tests setzen 0.
minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300),
},
// Webshop (Funktionsschluessel 'shop'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur Laufzeit);
// Schalter siehe server/services/shop/schalter.js
shop: {
// off (Vorgabe) | pilot (nur SHOP_PILOT_EMAILS) | on (alle Kunden)
mode: ['pilot', 'on'].includes(shopEnabled) ? shopEnabled : 'off',
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
pilotEmails: (process.env.SHOP_PILOT_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Fassung der Webshop-Bestellunterlagen (Leistungsbeschreibung, AVV, Bestelltexte): Berliner Kalendertag des
// Fassungs-Commits (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben
fassung: '2026-10-05',
// Artikelbilder der Shops: <uploadDir>/<shopId>/<datei>
uploadDir: process.env.SHOP_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'shop'),
// Signing-Secret des Stripe-Connect-Webhooks (Ereignisse der verbundenen Konten); leer = Webhook antwortet 503
connectWebhookSecret: process.env.STRIPE_CONNECT_WEBHOOK_SECRET || '',
// Eine unbezahlte Bestellung haelt den Bestand (und die Stripe-Checkout-Sitzung) so lange
bestellAblaufMinuten: 30,
maxArtikel: 500,
maxBilder: 4,
maxVarianten: 30,
// Bestellungen werden so viele Jahre nach Abschluss anonymisiert (Aufbewahrungsfrist), siehe Block J
anonymisierenJahre: 3,
// Mindestzeit (Sekunden) zwischen Laden und Absenden des Widerrufs-/Bestellformulars (schneller = Bot)
formMinSekunden: nonNegativeNumber(process.env.SHOP_FORM_MIN_SEKUNDEN, 3),
// Kuerzeste Antwortzeit (ms) der POST-Routen der oeffentlichen Schnittstelle (verwischt Zeitunterschiede). Tests setzen 0.
minAntwortMs: nonNegativeNumber(process.env.SHOP_MIN_ANTWORT_MS, 300),
// Tage nach Abschalten der Funktion bis zur Loeschung der Shop-Daten
loeschTageNachAbschalten: 90,
// Unbezahlte (offene/stornierte) Bestellungen werden nach so vielen Tagen geloescht
unbezahltLoeschTage: 30,
},
// Buchungskalender (Funktionsschluessel 'unterkunft'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur
// Laufzeit); Schalter siehe server/services/unterkunft/schalter.js
unterkunft: {
// off (Vorgabe) | pilot (nur UNTERKUNFT_PILOT_EMAILS) | on (alle Kunden)
mode: ['pilot', 'on'].includes(unterkunftEnabled) ? unterkunftEnabled : 'off',
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
pilotEmails: (process.env.UNTERKUNFT_PILOT_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Fassung der Rechtstexte: Berliner Kalendertag (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben
fassung: '2026-10-07',
// Mindestzeit (Sekunden) zwischen Laden und Absenden des Anfrageformulars (schneller = Bot)
formMinSekunden: nonNegativeNumber(process.env.UNTERKUNFT_FORM_MIN_SEKUNDEN, 3),
// Kuerzeste Antwortzeit (ms) der POST-Routen der oeffentlichen Schnittstelle. Tests setzen 0.
minAntwortMs: nonNegativeNumber(process.env.UNTERKUNFT_MIN_ANTWORT_MS, 300),
// iCal-Import: Abrufintervall, Groessen-/Zeitgrenzen, Fehlermail nach so vielen Stunden anhaltenden Fehlern
icalIntervallMin: 15,
icalMaxBytes: 1_000_000,
icalTimeoutMs: 10_000,
icalFehlerStunden: 6,
// Buchbar sind so viele Monate in die Zukunft
horizontMonate: 18,
maxPosten: 30,
postenTextMax: 200,
nachrichtMax: 2000,
// Anfragen je Unterkunfts-Seite und Stunde (Missbrauchsbremse)
anfragenJeSiteStunde: 30,
// IP-Hashes werden nach so vielen Tagen geloescht
ipHashTage: 30,
// Unverbindliche Anfragen werden nach so vielen Monaten anonymisiert/geloescht
unverbindlichMonate: 6,
// Offene (neue) Anfragen verfallen nach so vielen Tagen
neuVerfallTage: 60,
// Hoechstzahl Schreib-/Loeschvorgaenge der Aufbewahrung je Tageslauf
aufbewahrungDeckel: 500,
// Anfragen werden so viele Jahre nach Abschluss anonymisiert; Rechnungen bleiben rechnungJahre Jahre
anonymisierenJahre: 3,
rechnungJahre: 8,
// Tage nach Abschalten der Funktion bis zur Loeschung der Daten
loeschTageNachAbschalten: 90,
},
// Oeffnungszeiten live (Funktionsschluessel 'zeiten'); Geschwister von termine (termineSchalter.test.js pinnt
// config.termine). Wird bei jedem Aufruf gelesen; Schalter siehe server/services/zeiten/schalter.js
zeiten: {
// off (Vorgabe) | pilot (nur ZEITEN_PILOT_EMAILS) | on (alle Kunden)
mode: ['pilot', 'on'].includes(zeitenEnabled) ? zeitenEnabled : 'off',
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
pilotEmails: commaList(process.env.ZEITEN_PILOT_EMAILS, []),
// Subdomains, fuer die der Neubau nie angestossen wird (Einbau von Hand); ZEITEN_KEIN_NEUBAU ist eine
// Komma-Liste, die Vorgabe ist emmas-schatzkiste
keinNeubau: commaList(process.env.ZEITEN_KEIN_NEUBAU, ['emmas-schatzkiste']),
},
invoiceNinja: {
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
},
}
export function assertServerConfig() {
if (!config.sessionSecret || config.sessionSecret.length < 32) {
console.warn('[config] SESSION_SECRET fehlt oder ist zu kurz (min. 32 Zeichen).')
}
if (!config.pushTime.secret || config.pushTime.secret.length < 32) {
console.warn('[config] PUSH_TIME_SECRET (bzw. SESSION_SECRET) fehlt oder ist zu kurz – Zeit-Links aus Push-Mails funktionieren nicht.')
}
if (!config.appwrite.apiKey) {
console.warn('[config] APPWRITE_API_KEY fehlt – DB-Zugriff und Webhook schlagen fehl.')
}
}