Ö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:
1 parent
2314f7d940
commit
db9f7df747
12 files changed
+319
-18
No files matched your search
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user