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() const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase() const kundenansichtNeuEnabled = (process.env.KUNDENANSICHT_NEU || '').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', // Katalog der Kundenansicht neu (P5, V5): Beschreibung (hoechstens zwei Saetze, du-Form, kein Preis), Bild, Ablauf // (direkt | fragebogen | anfrage), Dauer der Einrichtung und ob die Funktion nur Betriebe bekommen beschreibung: 'Zeig Neuigkeiten und Aktionen direkt auf deiner Website. Du schreibst sie im Portal, sie erscheinen sofort.', bild: '/neu/bilder/news.webp', ablauf: 'direkt', einrichtung: 'meist in 1–2 Wochen', nurBetrieb: false, 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 (); 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) = 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:
+ 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', // Katalog der Kundenansicht neu (P5, V5): Beschreibung (hoechstens zwei Saetze, du-Form, kein Preis), Bild, Ablauf // (direkt | fragebogen | anfrage), Dauer der Einrichtung und ob die Funktion nur Betriebe bekommen beschreibung: 'Deine Kunden buchen Termine online, rund um die Uhr. Zeiten, Team und Buchungen verwaltest du im Portal.', bild: '/neu/bilder/termine.webp', ablauf: 'fragebogen', einrichtung: 'meist in 1–2 Wochen', nurBetrieb: false, 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', // Katalog der Kundenansicht neu (P5, V5): Beschreibung (hoechstens zwei Saetze, du-Form, kein Preis), Bild, Ablauf // (direkt | fragebogen | anfrage), Dauer der Einrichtung und ob die Funktion nur Betriebe bekommen beschreibung: 'Verkaufe Artikel direkt über deine Website. Die Zahlung läuft über dein eigenes Stripe-Konto.', bild: '/neu/bilder/shop.webp', ablauf: 'anfrage', einrichtung: 'meist in 2–3 Wochen', nurBetrieb: true, 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', // Katalog der Kundenansicht neu (P5, V5): Beschreibung (hoechstens zwei Saetze, du-Form, kein Preis), Bild, Ablauf // (direkt | fragebogen | anfrage), Dauer der Einrichtung und ob die Funktion nur Betriebe bekommen beschreibung: 'Gäste fragen deine Unterkunft online an, du gibst frei und behältst Belegung und Preise im Blick. Den Kalender baut WEBklar in deine Website ein.', bild: '/neu/bilder/unterkunft.webp', ablauf: 'anfrage', einrichtung: 'meist in 1–2 Wochen', nurBetrieb: false, 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', // Katalog der Kundenansicht neu (P5, V5): Beschreibung (hoechstens zwei Saetze, du-Form, kein Preis), Bild, Ablauf // (direkt | fragebogen | anfrage), Dauer der Einrichtung und ob die Funktion nur Betriebe bekommen beschreibung: 'Deine Öffnungszeiten stehen live auf deiner Website und sind immer aktuell. Du änderst sie im Portal, auch für Feiertage und Urlaub.', bild: '/neu/bilder/zeiten.webp', ablauf: 'direkt', einrichtung: 'meist in wenigen Tagen', nurBetrieb: false, 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', // Aufträge und Rechnungen (Auftragsbestätigungen, Projektrechnungen; scripts/auftraege-setup.mjs) auftraege: process.env.APPWRITE_COLLECTION_AUFTRAEGE || 'auftraege', projektRechnungen: process.env.APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN || 'projektRechnungen', }, 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, // Abo-Modell der Funktionen (Kundenansicht neu, P5): Fassung der Einschalt-Erklaerung, Reihenfolge des Katalogs, Haekchen-Text und // der Katalogeintrag der Analyse (sie steht nicht im Register der kaufbaren Funktionen). fassung und texte.beginn sind Literale // der Fassung 6 (N33; config.js importiert keine Dienste, test/aboWortlautV6.test.js und test/knVerdrahtung.test.js pinnen die // Gleichheit mit FUNKTIONEN_FASSUNG_V6 und WORTLAUT_V6.beginn.einrichtung). Linkzeile und Hinweis stehen in WORTLAUT_V6 // (products.js); die Items baut featureRegistry.consentAbo bei jedem Aufruf. aboFunktionen: { fassung: '2026-10-16', reihenfolge: ['news', 'termine', 'shop', 'unterkunft', 'zeiten', 'analyse'], texte: { // Haken "Beginn" (nur Privatkunden innerhalb der Widerrufsfrist); wortgleich zu WORTLAUT_V6.beginn.einrichtung (P8) beginn: '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.', // Haken der Analyse (Kinder unter 16 Jahren, DSGVO Art. 8) zielgruppe: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.', analyseKnopf: 'Analyse einschalten', }, analyse: { name: 'Website-Analyse', beschreibung: 'Sieh, wie viele Besucher auf deine Website kommen und welche Seiten sie ansehen. Die Auswertung findest du direkt im Portal.', bild: '/neu/bilder/analyse.webp', ablauf: 'direkt', einrichtung: 'meist in wenigen Tagen', nurBetrieb: false, }, }, // 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', // Fassung 6 der Abo-Bedingungen: gilt nur fuer Sitzungen mit neuer Ansicht (aktuelleAboFassung in products.js). // Entwurfstag; bei der Freigabe durch den Inhaber das Datum auf den Freigabetag setzen (P8 Task 11, Ablauf) termsVersionNeu: '2026-10-16-v6', // 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', // WEBklars eigene Website (kein Kundenprojekt, kein Abo): fester Eintrag im Messdienst, Auswertung im WOMS // (server/services/analyseEigen.js). Der Schluessel ist nicht geheim, er steht im Skript-Tag von webklar.com. // aktiv: false schaltet die Messung ab (Messdienst liefert dann das leere Skript und verwirft Ereignisse). eigeneWebsite: { aktiv: true, siteKey: 'yiy992rvq7vchfd7bqtqivah', projectId: 'webklar-eigene-website', domains: ['webklar.com', 'www.webklar.com'], host: 'webklar.com', }, }, // 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: 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']), }, // Neue Kundenansicht (Spec 2026-10-15): Schalter fuer die neue Oberflaeche; wird bei jedem Aufruf gelesen, siehe // server/services/kundenansicht/schalter.js. Der Modus off schlaegt jede Liste. kundenansichtNeu: { // off (Vorgabe) | pilot (nur KUNDENANSICHT_NEU_PILOT_EMAILS und die Mitarbeiter-Kundenansicht) | on (alle Kunden) mode: ['pilot', 'on'].includes(kundenansichtNeuEnabled) ? kundenansichtNeuEnabled : 'off', // Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle pilotEmails: commaList(process.env.KUNDENANSICHT_NEU_PILOT_EMAILS, []), }, // Aufträge und Rechnungen: Schalter nur für die AB-Funktionen und die Rechnungsarten anzahlung/schluss; wird bei // jedem Aufruf gelesen, siehe server/services/auftraege/schalter.js auftraege: { // off (Vorgabe) | pilot (nur AUFTRAEGE_PILOT_EMAILS, '*' = alle) | on mode: ['pilot', 'on'].includes(auftraegeEnabled) ? auftraegeEnabled : 'off', pilotEmails: commaList(process.env.AUFTRAEGE_PILOT_EMAILS, []), // Gesendete AB-PDFs: //.pdf uploadDir: process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege'), }, 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.') } }