diff --git a/scripts/stripe-products.mjs b/scripts/stripe-products.mjs index dac61f5..5ccb298 100644 --- a/scripts/stripe-products.mjs +++ b/scripts/stripe-products.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News- und Terminplaner-Einrichtung als Einmalpreise): legt fehlende + * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-, Terminplaner- und Oeffnungszeiten-Einrichtung als Einmalpreise): legt fehlende * Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext * (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten"). * Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt. @@ -17,7 +17,7 @@ import { config } from '../server/config.js' import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js' import { PRODUCT_KEYS } from '../server/services/products.js' -export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup'] +export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'zeitenSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'zeitenSetup'] const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen' export function parseArgs(argv) { @@ -31,6 +31,7 @@ export function catalogEntry(key) { if (key === 'mailStorage') return config.mailStorage if (key === 'newsSetup') return config.newsSetup if (key === 'termineSetup') return config.termineSetup + if (key === 'zeitenSetup') return config.zeitenSetup return config.products[key] } diff --git a/server/config.js b/server/config.js index 6fcc672..38773dc 100644 --- a/server/config.js +++ b/server/config.js @@ -283,6 +283,72 @@ const features = { orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.', unavailableMessage: 'Der Terminplaner 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) + consent: { + version: '2026-10-04', + 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).', + }, + ], + }, + // 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 = { diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index 893050d..489a8ce 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -1,9 +1,10 @@ import { config } from '../config.js' import { termineFuerKunde } from './termine/schalter.js' +import { zeitenFuerKunde } from './zeiten/schalter.js' -// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js). +// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Oeffnungszeiten live; Daten in server/config.js). // Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahme: -// termine/schalter.js, das nur config liest): Standard-Haken laden ihre Dienste per dynamischem import() +// termine/schalter.js und zeiten/schalter.js, die nur config lesen): Standard-Haken laden ihre Dienste per dynamischem import() // innerhalb der Funktion (sonst entstuenden Importzyklen). /** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */ @@ -58,6 +59,14 @@ const STANDARD_HAKEN = { belehrung: async () => BELEHRUNG_PLATZHALTER, freischaltbar: termineFreischaltbar, }, + // Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung und + // unterlagenBereit wie beim Terminplaner; freischaltbar ist ein Platzhalter (Task 18 ersetzt ihn) + zeiten: { + verfuegbar: (email) => zeitenFuerKunde(email), + unterlagenBereit: async () => true, + belehrung: async () => BELEHRUNG_PLATZHALTER, + freischaltbar: async () => true, + }, } /** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */ @@ -112,6 +121,14 @@ export function featureSperre(key, email) { return eintrag.unavailableMessage || NICHT_VERFUEGBAR } +/** + * Gratisweg einer Funktion mit aboInklusive (Wartungs-Abo laeuft): derselbe Eintrag, nur mit der Zustimmung consentFrei + * (ein Haken, Knopf "Einschalten") statt consent. Eine Kopie, das Register bleibt unveraendert. + */ +export function freiweg(feature) { + return { ...feature, consent: feature.consentFrei } +} + const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null) /** diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 0fe6f28..c0ffc80 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -58,6 +58,7 @@ const PRODUCT_METADATA = { mailStorage: 'mail-speicher', newsSetup: 'news-einrichtung', termineSetup: 'termine-einrichtung', + zeitenSetup: 'zeiten-einrichtung', } /** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, Zusatzspeicher und Einrichtungen aus config. */ @@ -65,13 +66,14 @@ function catalogFor(key) { if (key === 'mailStorage') return config.mailStorage if (key === 'newsSetup') return config.newsSetup if (key === 'termineSetup') return config.termineSetup + if (key === 'zeitenSetup') return config.zeitenSetup return getProduct(key) } const priceIdCache = new Map() /** * Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' | - * 'mailStorage' | 'newsSetup' | 'termineSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe + * 'mailStorage' | 'newsSetup' | 'termineSetup' | 'zeitenSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe * nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog. */ export async function ensurePrice(key) { diff --git a/server/services/zeiten/ticketDetails.js b/server/services/zeiten/ticketDetails.js new file mode 100644 index 0000000..c5f2cdc --- /dev/null +++ b/server/services/zeiten/ticketDetails.js @@ -0,0 +1,10 @@ +import { minimaleTicketDetails } from '../featureRegistry.js' + +/** + * Details des Einrichtungs-Tickets von Oeffnungszeiten live. Vorlaeufig die Notfall-Details des Registers (Kopfzeilen und + * Verweis auf die Anleitung im WOMS); Task 14 baut daraus die vollstaendige Anleitung. + * ctx = { feature, doc, project, customer }. + */ +export async function zeitenTicketDetails(ctx) { + return minimaleTicketDetails(ctx) +} diff --git a/test/featureFreischaltung.test.js b/test/featureFreischaltung.test.js index cb62441..d3dcde1 100644 --- a/test/featureFreischaltung.test.js +++ b/test/featureFreischaltung.test.js @@ -103,15 +103,15 @@ afterEach(() => { // ---------------------------------------------------------------------------------------- Register --- test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => { - assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'zeiten']) NEWS.ticketFreischaltung = false - assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'zeiten']) NEWS.ticketFreischaltung = 'ja' - assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine'], 'nur true zaehlt') + assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'zeiten'], 'nur true zaehlt') NEWS.ticketFreischaltung = true TERMINE.ticketFreischaltung = false try { - assert.deepEqual(reg.ticketFreischaltungKeys(), ['news']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'zeiten']) } finally { TERMINE.ticketFreischaltung = true } diff --git a/test/featureRegistry.test.js b/test/featureRegistry.test.js index b820e0d..3ac8f4f 100644 --- a/test/featureRegistry.test.js +++ b/test/featureRegistry.test.js @@ -120,10 +120,11 @@ test('FEATURE_LABELS und FEATURE_KEYS unverändert, aus dem Register abgeleitet' assert.deepEqual(sf.FEATURE_LABELS, { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, + zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' }, }) - assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine']) + assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'zeiten']) assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys()) - assert.deepEqual(reg.featureKeys(), ['news', 'termine']) + assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'zeiten']) }) // ---------------------------------------------------------------------------------- Schlüsselschutz --- diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index ee12332..acbc93f 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -69,7 +69,7 @@ test('liste_zustaende', async () => { assert.deepEqual(body.termine, { verfuegbar: false }) // Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false - assert.deepEqual(Object.keys(body.features).sort(), ['news', 'termine']) + assert.deepEqual(Object.keys(body.features).sort(), ['news', 'termine', 'zeiten']) assert.equal(body.features.news.priceLabel, '30 €') assert.equal(body.features.termine.priceLabel, '149 €') assert.equal(body.ustHinweis, config.ustHinweis) diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index a4ee08b..154c639 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -246,7 +246,7 @@ test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher' assert.equal(pt1.features.news.state, 'disabled') const pt2 = body.projects.find((p) => p.projectId === 'pt2') assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off']) - assert.deepEqual(Object.keys(pt2.features), ['news', 'termine']) + assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'zeiten']) // Abschaltgrund und Loeschfrist (E2, R105): News ist hier vom Team abgeschaltet: Grund und Zeitpunkt ja, ein Loeschdatum nein // (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts const aus = pt1.features.news diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index c27c133..12aba01 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -170,17 +170,18 @@ test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => { aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' }) aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' }) aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' }) - assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine']) + assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'zeiten']) assert.deepEqual(sf.FEATURE_LABELS, { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, + zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' }, }) }) test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => { const list = await sf.listCustomerFeatures('c1') const p = list.find((x) => x.projectId === 'pt1') - assert.deepEqual(Object.keys(p.features), ['news', 'termine']) + assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'zeiten']) assert.equal(p.features.termine.state, 'off') assert.equal(p.features.termine.siteKey, null) assert.equal(p.features.termine.ticketWoid, null) @@ -246,7 +247,7 @@ test('activateClosedTickets: Funktionen mit ticketFreischaltung; News verhaelt s const ids = ['f_setup_termine', 'f_setup_news', 'f_setup_analyse'] const reg = await import('../server/services/featureRegistry.js') try { - assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'zeiten']) // News wie bisher; Termine wartet auf Einstellung und Abo (Haken freischaltbar); Analyse hat keine ticketFreischaltung const done = await sf.activateClosedTickets({ customerId: 'c1' }) assert.deepEqual(done.map((d) => d.featureKey), ['news']) diff --git a/test/stripeProductsScript.test.js b/test/stripeProductsScript.test.js index 8c4de44..d3ac76c 100644 --- a/test/stripeProductsScript.test.js +++ b/test/stripeProductsScript.test.js @@ -31,12 +31,13 @@ test('Import startet main() nicht: kein Stripe-Aufruf, kein process.exit', () => assert.equal(st.calls.length, 0) }) -test('KEYS enthaelt den Terminplaner-Katalogschluessel', () => { - assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup']) +test('KEYS enthaelt den Terminplaner- und den Oeffnungszeiten-Katalogschluessel', () => { + assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'zeitenSetup']) }) test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => { assert.equal(script.catalogEntry('termineSetup'), config.termineSetup) + assert.equal(script.catalogEntry('zeitenSetup'), config.zeitenSetup) assert.equal(script.catalogEntry('newsSetup'), config.newsSetup) assert.equal(script.catalogEntry('mailStorage'), config.mailStorage) assert.equal(script.catalogEntry('wartung'), config.products.wartung) diff --git a/test/zeitenRegister.test.js b/test/zeitenRegister.test.js new file mode 100644 index 0000000..dd8a7b1 --- /dev/null +++ b/test/zeitenRegister.test.js @@ -0,0 +1,202 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +// Register-Eintrag config.features.zeiten ("Öffnungszeiten live"), freiweg() und der Stripe-Katalog zeitenSetup. +// Dienste (siteFeatures, stripeBilling) brauchen die Test-Umgebung vor dem ersten Import von server/config.js. +const aw = await startAppwriteMock() +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const reg = await import('../server/services/featureRegistry.js') +const sf = await import('../server/services/siteFeatures.js') +const { ensurePrice } = await import('../server/services/stripeBilling.js') + +const feature = () => reg.featureConfig('zeiten') + +test('zeiten steht im Register mit 30 € und Abo-Inklusive', () => { + const f = feature() + assert.equal(f, config.features.zeiten) + assert.equal(f.key, 'zeiten') + assert.equal(f.product, 'zeitenSetup') + assert.deepEqual(f.label, { name: 'Öffnungszeiten live', kurz: 'Zeiten', keine: 'keine Öffnungszeiten live' }) + assert.equal(f.priceLabel, '30 €') + assert.equal(f.setupEstimate, 'meist innerhalb weniger Tage') + assert.equal(f.requiresWartungsAbo, false) + assert.equal(f.aboInklusive, true) + assert.equal(f.ticketFreischaltung, true) + assert.deepEqual(f.teilweiseLive, ['setup', 'active']) + assert.equal(f.returnHash, 'zeiten') + assert.equal(f.returnProjekt, true) + assert.deepEqual(f.leistungsbeschreibung, { version: '2026-10-04', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' }) + assert.equal(f.avv, null) + assert.equal(f.widerruf, null) + assert.equal(f.disabledMessage, 'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.') + assert.equal(f.orderedConflictMessage, 'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.') + assert.equal(f.unavailableMessage, 'Öffnungszeiten live ist noch nicht verfügbar.') + // Funktionsschlüssel und Katalog stehen in den abgeleiteten Listen + assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'zeiten']) + assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'zeiten']) + assert.deepEqual(sf.FEATURE_LABELS.zeiten, { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' }) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'zeiten']) +}) + +test('Zustimmung bezahlt: zwei Haken, Knopf „Zahlungspflichtig bestellen · 30 €“, § 356 Abs. 5 Nr. 2', () => { + const { consent } = feature() + assert.equal(consent.version, '2026-10-04') + assert.equal(consent.knopf, 'Zahlungspflichtig bestellen · 30 €') + assert.deepEqual(consent.items.map((i) => [i.id, i.haken]), [['leistung', true], ['beginn', true]]) + assert.equal( + consent.items[0].text, + 'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen und bestelle die Einrichtung.' + ) + assert.equal( + consent.items[1].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).' + ) + assert.match(consent.items[1].text, /§ 356 Abs\. 5 Nr\. 2/) +}) + +test('Zustimmung gratis: ein Haken, Knopf „Einschalten“, kein „zahlungspflichtig“', () => { + const f = feature() + assert.equal(f.consentFrei.version, '2026-10-04') + assert.equal(f.consentFrei.knopf, 'Einschalten') + assert.deepEqual(f.consentFrei.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.', + }, + ]) + assert.doesNotMatch(JSON.stringify(f.consentFrei), /zahlungspflichtig/i) +}) + +test('freiweg ersetzt nur consent', () => { + const f = feature() + const frei = reg.freiweg(f) + assert.equal(frei.consent, f.consentFrei) + assert.notEqual(frei, f, 'eine Kopie, das Register bleibt unberührt') + assert.equal(f.consent.knopf, 'Zahlungspflichtig bestellen · 30 €') + assert.equal(frei.product, f.product) + assert.equal(frei.key, f.key) + assert.equal(frei.mails, f.mails) + assert.deepEqual(Object.keys(frei).sort(), Object.keys(f).sort()) + for (const key of Object.keys(f)) if (key !== 'consent') assert.equal(frei[key], f[key], key) +}) + +test('verfuegbar folgt ZEITEN_ENABLED', () => { + const f = feature() + const vorher = { ...config.zeiten } + try { + config.zeiten.mode = 'off' + assert.equal(reg.featureSperre('zeiten', 'a@b.de'), f.unavailableMessage) + config.zeiten.mode = 'on' + assert.equal(reg.featureSperre('zeiten', 'a@b.de'), null) + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['pilot@b.de'] + assert.equal(reg.featureSperre('zeiten', 'a@b.de'), f.unavailableMessage) + assert.equal(reg.featureSperre('zeiten', 'pilot@b.de'), null) + } finally { + Object.assign(config.zeiten, vorher) + } +}) + +test('Standard-Haken: verfuegbar, unterlagenBereit, belehrung, freischaltbar (keine Bestellprüfung)', async () => { + const haken = reg.featureHaken('zeiten') + assert.deepEqual(Object.keys(haken).sort(), ['belehrung', 'freischaltbar', 'unterlagenBereit', 'verfuegbar']) + const termine = reg.featureHaken('termine') + assert.equal(await haken.unterlagenBereit(), true) + assert.equal(await haken.belehrung(), await termine.belehrung(), 'wie beim Terminplaner') + assert.equal(await haken.belehrung(), reg.BELEHRUNG_PLATZHALTER) + assert.equal(await haken.freischaltbar({ $id: 'x' }), true, 'Platzhalter bis Task 18') +}) + +test('oeffentlicheAnsicht(zeiten) hat dieselben Schlüssel wie news', () => { + const zeiten = reg.oeffentlicheAnsicht('zeiten') + const news = reg.oeffentlicheAnsicht('news') + assert.deepEqual(Object.keys(zeiten).sort(), Object.keys(news).sort()) + assert.deepEqual(Object.keys(zeiten.consent).sort(), Object.keys(news.consent).sort()) + assert.equal(zeiten.aboInklusive, undefined) + assert.equal(zeiten.consentFrei, undefined) + assert.equal(zeiten.teilweiseLive, undefined) + assert.doesNotMatch(JSON.stringify(zeiten), /aboInklusive|consentFrei|Einschalten/) + assert.deepEqual(JSON.parse(JSON.stringify(zeiten)), zeiten) + assert.equal(zeiten.requiresWartungsAbo, false) + assert.equal(zeiten.name, 'Öffnungszeiten live') + assert.equal(zeiten.priceLabel, '30 €') +}) + +test('Mails: Werte aus dem Register, Preis aus zeitenSetup', () => { + const f = feature() + assert.deepEqual(f.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, + }) + const k15 = sf.bestellMail(f, { kundeName: 'Kunde Eins', projekt: 'Salon', invoiceUrl: '' }) + assert.equal(k15.values.preis, '30,00 €') + assert.equal(k15.values.portal_url, `${config.publicBaseUrl}/dashboard.html#zeiten`) +}) + +test('Ticket: Betreff mit Projektname, Details wie die Notfall-Details', async () => { + const f = feature() + const project = { projectName: 'Salon Haarlinie', subdomain: 'salon' } + assert.equal(f.ticket.topic(project), 'Öffnungszeiten live einbauen – Salon Haarlinie') + assert.equal(f.ticket.topic({ subdomain: 'salon' }), 'Öffnungszeiten live einbauen – salon') + const ctx = { feature: f, doc: { customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop' }, project, customer: { name: 'Kunde Eins' } } + const details = await f.ticket.details(ctx) + assert.equal(details, reg.minimaleTicketDetails(ctx)) + assert.match(details, /^Funktion: zeiten$/m) + const texte = await reg.ticketTexte(ctx) + assert.equal(texte.topic, 'Öffnungszeiten live einbauen – Salon Haarlinie') + assert.equal(texte.details, details) +}) + +test('Stripe-Katalog: zeitenSetup mit eigener Metadaten-Marke', async () => { + assert.deepEqual(config.zeitenSetup, { + key: 'zeitenSetup', + name: 'Öffnungszeiten live – Einrichtung', + amountCents: 3000, + lookupKey: 'webklar_zeiten_einrichtung_3000', + oneTime: true, + priceNickname: 'Öffnungszeiten-Einrichtung 30 €', + }) + const before = st.calls.length + const id = await ensurePrice('zeitenSetup') + const calls = st.calls.slice(before) + const preise = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices') + assert.equal(preise.length, 1) + assert.equal(preise[0].body['unit_amount'], '3000') + assert.equal(preise[0].body['lookup_key'], 'webklar_zeiten_einrichtung_3000') + assert.equal(preise[0].body['nickname'], 'Öffnungszeiten-Einrichtung 30 €') + assert.ok(!Object.keys(preise[0].body).some((k) => k.startsWith('recurring')), 'kein recurring') + assert.equal(st.prices.get(id).recurring, null) + const produkte = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products') + assert.equal(produkte.length, 1) + assert.equal(produkte[0].body['name'], 'Öffnungszeiten live – Einrichtung') + assert.equal(produkte[0].body['metadata[webklar]'], 'zeiten-einrichtung') + // zweiter Aufruf: kein zweiter Preis + const again = st.calls.length + assert.equal(await ensurePrice('zeitenSetup'), id) + assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0) +}) + +test('Skript stripe-products kennt zeitenSetup', async () => { + const realExit = process.exit + process.exit = () => {} + let script + try { + script = await import('../scripts/stripe-products.mjs') + } finally { + process.exit = realExit + } + assert.ok(script.KEYS.includes('zeitenSetup')) + assert.equal(script.catalogEntry('zeitenSetup'), config.zeitenSetup) +})