Ö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
|
#!/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
|
* Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext
|
||||||
* (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten").
|
* (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten").
|
||||||
* Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt.
|
* 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 { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js'
|
||||||
import { PRODUCT_KEYS } from '../server/services/products.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'
|
const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen'
|
||||||
|
|
||||||
export function parseArgs(argv) {
|
export function parseArgs(argv) {
|
||||||
@@ -31,6 +31,7 @@ export function catalogEntry(key) {
|
|||||||
if (key === 'mailStorage') return config.mailStorage
|
if (key === 'mailStorage') return config.mailStorage
|
||||||
if (key === 'newsSetup') return config.newsSetup
|
if (key === 'newsSetup') return config.newsSetup
|
||||||
if (key === 'termineSetup') return config.termineSetup
|
if (key === 'termineSetup') return config.termineSetup
|
||||||
|
if (key === 'zeitenSetup') return config.zeitenSetup
|
||||||
return config.products[key]
|
return config.products[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,72 @@ const features = {
|
|||||||
orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.',
|
orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.',
|
||||||
unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.',
|
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 = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { termineFuerKunde } from './termine/schalter.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:
|
// 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).
|
// innerhalb der Funktion (sonst entstuenden Importzyklen).
|
||||||
|
|
||||||
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
|
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
|
||||||
@@ -58,6 +59,14 @@ const STANDARD_HAKEN = {
|
|||||||
belehrung: async () => BELEHRUNG_PLATZHALTER,
|
belehrung: async () => BELEHRUNG_PLATZHALTER,
|
||||||
freischaltbar: termineFreischaltbar,
|
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). */
|
/** 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
|
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)
|
const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const PRODUCT_METADATA = {
|
|||||||
mailStorage: 'mail-speicher',
|
mailStorage: 'mail-speicher',
|
||||||
newsSetup: 'news-einrichtung',
|
newsSetup: 'news-einrichtung',
|
||||||
termineSetup: 'termine-einrichtung',
|
termineSetup: 'termine-einrichtung',
|
||||||
|
zeitenSetup: 'zeiten-einrichtung',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, Zusatzspeicher und Einrichtungen aus config. */
|
/** 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 === 'mailStorage') return config.mailStorage
|
||||||
if (key === 'newsSetup') return config.newsSetup
|
if (key === 'newsSetup') return config.newsSetup
|
||||||
if (key === 'termineSetup') return config.termineSetup
|
if (key === 'termineSetup') return config.termineSetup
|
||||||
|
if (key === 'zeitenSetup') return config.zeitenSetup
|
||||||
return getProduct(key)
|
return getProduct(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceIdCache = new Map()
|
const priceIdCache = new Map()
|
||||||
/**
|
/**
|
||||||
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
|
* 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.
|
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
|
||||||
*/
|
*/
|
||||||
export async function ensurePrice(key) {
|
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 ---
|
// ---------------------------------------------------------------------------------------- Register ---
|
||||||
test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => {
|
test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => {
|
||||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine'])
|
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'zeiten'])
|
||||||
NEWS.ticketFreischaltung = false
|
NEWS.ticketFreischaltung = false
|
||||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine'])
|
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'zeiten'])
|
||||||
NEWS.ticketFreischaltung = 'ja'
|
NEWS.ticketFreischaltung = 'ja'
|
||||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine'], 'nur true zaehlt')
|
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'zeiten'], 'nur true zaehlt')
|
||||||
NEWS.ticketFreischaltung = true
|
NEWS.ticketFreischaltung = true
|
||||||
TERMINE.ticketFreischaltung = false
|
TERMINE.ticketFreischaltung = false
|
||||||
try {
|
try {
|
||||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news'])
|
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'zeiten'])
|
||||||
} finally {
|
} finally {
|
||||||
TERMINE.ticketFreischaltung = true
|
TERMINE.ticketFreischaltung = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,10 +120,11 @@ test('FEATURE_LABELS und FEATURE_KEYS unverändert, aus dem Register abgeleitet'
|
|||||||
assert.deepEqual(sf.FEATURE_LABELS, {
|
assert.deepEqual(sf.FEATURE_LABELS, {
|
||||||
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
|
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
|
||||||
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
|
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(sf.FEATURE_KEYS, reg.featureKeys())
|
||||||
assert.deepEqual(reg.featureKeys(), ['news', 'termine'])
|
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'zeiten'])
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------- Schlüsselschutz ---
|
// ---------------------------------------------------------------------------------- Schlüsselschutz ---
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ test('liste_zustaende', async () => {
|
|||||||
assert.deepEqual(body.termine, { verfuegbar: false })
|
assert.deepEqual(body.termine, { verfuegbar: false })
|
||||||
|
|
||||||
// Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei 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.news.priceLabel, '30 €')
|
||||||
assert.equal(body.features.termine.priceLabel, '149 €')
|
assert.equal(body.features.termine.priceLabel, '149 €')
|
||||||
assert.equal(body.ustHinweis, config.ustHinweis)
|
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')
|
assert.equal(pt1.features.news.state, 'disabled')
|
||||||
const pt2 = body.projects.find((p) => p.projectId === 'pt2')
|
const pt2 = body.projects.find((p) => p.projectId === 'pt2')
|
||||||
assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off'])
|
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
|
// 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
|
// (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts
|
||||||
const aus = pt1.features.news
|
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: 'pt1', customerId: 'c1', projectName: 'Salon' })
|
||||||
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
|
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
|
||||||
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' })
|
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, {
|
assert.deepEqual(sf.FEATURE_LABELS, {
|
||||||
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
|
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
|
||||||
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
|
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 () => {
|
test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => {
|
||||||
const list = await sf.listCustomerFeatures('c1')
|
const list = await sf.listCustomerFeatures('c1')
|
||||||
const p = list.find((x) => x.projectId === 'pt1')
|
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.state, 'off')
|
||||||
assert.equal(p.features.termine.siteKey, null)
|
assert.equal(p.features.termine.siteKey, null)
|
||||||
assert.equal(p.features.termine.ticketWoid, 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 ids = ['f_setup_termine', 'f_setup_news', 'f_setup_analyse']
|
||||||
const reg = await import('../server/services/featureRegistry.js')
|
const reg = await import('../server/services/featureRegistry.js')
|
||||||
try {
|
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
|
// News wie bisher; Termine wartet auf Einstellung und Abo (Haken freischaltbar); Analyse hat keine ticketFreischaltung
|
||||||
const done = await sf.activateClosedTickets({ customerId: 'c1' })
|
const done = await sf.activateClosedTickets({ customerId: 'c1' })
|
||||||
assert.deepEqual(done.map((d) => d.featureKey), ['news'])
|
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)
|
assert.equal(st.calls.length, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('KEYS enthaelt den Terminplaner-Katalogschluessel', () => {
|
test('KEYS enthaelt den Terminplaner- und den Oeffnungszeiten-Katalogschluessel', () => {
|
||||||
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup'])
|
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'zeitenSetup'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => {
|
test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => {
|
||||||
assert.equal(script.catalogEntry('termineSetup'), config.termineSetup)
|
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('newsSetup'), config.newsSetup)
|
||||||
assert.equal(script.catalogEntry('mailStorage'), config.mailStorage)
|
assert.equal(script.catalogEntry('mailStorage'), config.mailStorage)
|
||||||
assert.equal(script.catalogEntry('wartung'), config.products.wartung)
|
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