Öffnungszeiten: Register-Eintrag und Stripe-Katalog

Funktion zeiten in config.features (30 €, aboInklusive, consentFrei, teilweiseLive),
freiweg() und Standard-Haken in featureRegistry.js, Stripe-Katalog zeitenSetup
(Metadaten zeiten-einrichtung) in stripeBilling.js und scripts/stripe-products.mjs,
vorläufige zeitenTicketDetails; Schlüssellisten-Pins um zeiten ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 23:31:36 +00:00
1 parent 2314f7d940
commit db9f7df747
12 files changed
+319 -18

No files matched your search

+3 -2
View File
@@ -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]
}
+66
View File
@@ -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 = {
+19 -2
View File
@@ -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)
/**
+3 -1
View File
@@ -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) {
+10
View File
@@ -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)
}
+4 -4
View File
@@ -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
}
+3 -2
View File
@@ -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 ---
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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'])
+3 -2
View File
@@ -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)
+202
View File
@@ -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)
})