diff --git a/server/config.js b/server/config.js index 15062ba..1c63937 100644 --- a/server/config.js +++ b/server/config.js @@ -233,7 +233,85 @@ function termineConsent() { } /** - * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen, + * Rechtsunterlage des Webshops: wie termineRechtsunterlage, aber mit Fassung und Adresse aus config.shop.fassung (bei jedem + * Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.shop); test/legalDocs.test.js gleicht beide ab. + */ +function shopRechtsunterlage(slug, { nullIstWert = false } = {}) { + let ueberschrieben // undefined = Standard + const standard = () => ({ version: config.shop.fassung, url: `/rechtliches/${slug}-${config.shop.fassung}.html` }) + return { + get: () => (ueberschrieben === undefined ? standard() : ueberschrieben), + set(wert) { + const std = standard() + const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url) + ueberschrieben = zurueck ? undefined : wert + }, + } +} +const shopLb = shopRechtsunterlage('leistungsbeschreibung-webshop') +const shopAvv = shopRechtsunterlage('avv-webshop') +const shopWiderruf = shopRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true }) + +const SHOP_PREIS = '299 €' +const SHOP_DAUER = 'meist innerhalb von 2–3 Wochen' + +/** + * Bestelltexte des Webshops: fuenf Haken (alle Besteller), ohne Adressen im Text (die versionierten Links zeigt die + * Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.shop.fassung; die dynamischen Teile + * (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text. Aufbau wie + * termineConsent (Haken je Fassung einmal gebaut; Tests duerfen einzelne Texte aendern und zurueckstellen). Bei Textaenderung + * neues Fassungsdatum und neuen Hash im Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und + * die Pflichtinformationen sind Entwurf bis zur Kanzlei. + */ +function shopConsent() { + let versionGesetzt // undefined = config.shop.fassung + let gebaut = null // { version, items } + const version = () => versionGesetzt ?? config.shop.fassung + const items = (v) => [ + { + id: 'unterlagen', + haken: true, + text: `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${v} zu.`, + }, + { + id: 'verantwortung', + haken: true, + text: 'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.', + }, + { + id: 'stripe', + haken: true, + text: 'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.', + }, + { + id: 'beginn', + haken: true, + text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', + }, + { + id: 'information', + haken: true, + text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis ${SHOP_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${SHOP_DAUER}, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`, + }, + ] + return { + get version() { + return version() + }, + set version(wert) { + versionGesetzt = wert === undefined || wert === null || wert === config.shop.fassung ? undefined : wert + }, + knopf: `Zahlungspflichtig bestellen · ${SHOP_PREIS}`, + get items() { + const v = version() + if (gebaut?.version !== v) gebaut = { version: v, items: items(v) } + return gebaut.items + }, + } +} + +/** + * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner, Webshop): Preis, Zustimmungen, Rechtsunterlagen, * Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig, * featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar, * bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem @@ -360,6 +438,58 @@ const features = { orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.', unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.', }, + shop: { + key: 'shop', + product: 'shopSetup', + label: { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' }, + priceLabel: SHOP_PREIS, + setupEstimate: SHOP_DAUER, + consent: shopConsent(), + // Fassung und Adresse folgen config.shop.fassung bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js + // importiert keine Dienste, test/legalDocs.test.js gleicht beide ab) + get leistungsbeschreibung() { + return shopLb.get() + }, + set leistungsbeschreibung(wert) { + shopLb.set(wert) + }, + get avv() { + return shopAvv.get() + }, + set avv(wert) { + shopAvv.set(wert) + }, + get widerruf() { + return shopWiderruf.get() + }, + set widerruf(wert) { + shopWiderruf.set(wert) + }, + returnHash: 'webshop', + returnProjekt: true, + ticket: { + topic: (project) => `Webshop einbauen – ${ticketProjektName(project)}`, + // Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch + details: async (ctx) => { + const { shopTicketDetails } = await import('./services/shop/ticketDetails.js') + return shopTicketDetails(ctx) + }, + }, + mails: { + funktion: 'Webshop', + formen: { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' }, + knopf: 'Webshop öffnen', + danach: 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.', + bereit: 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.', + preisHinweis: `einmalig, ${ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`, + anleitung: false, + }, + requiresWartungsAbo: false, + ticketFreischaltung: true, + disabledMessage: 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.', + orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.', + unavailableMessage: 'Der Webshop ist noch nicht verfügbar.', + }, } export const config = { diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index fc14d4e..fd1d31c 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 { shopFuerKunde } from './shop/schalter.js' -// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; 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() +// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop; Daten in server/config.js). +// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahmen: +// termine/schalter.js und shop/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,21 @@ async function termineBelehrung() { return widerrufsbelehrungFunktion() } +/** + * Bestellpruefung des Webshops im Checkout: die Anfrage des Projekts muss vom Team angenommen sein (shop/anfrage.js, + * Block B). Geladen werden die Dienste erst beim Aufruf. + */ +async function shopBestellpruefung({ customerId, projectId }) { + const { anfrageOffenePunkte } = await import('./shop/anfrage.js') + return anfrageOffenePunkte({ customerId, projectId }) +} + +/** Vertragsunterlagen des Webshops abrufbar? (Stufe 5b des Checkouts; legalDocs.featureLegalReady, fail-closed, wirft nie.) */ +async function shopUnterlagenBereit() { + const { featureLegalReady } = await import('./legalDocs.js') + return featureLegalReady('shop') +} + /** * Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit * (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht @@ -73,6 +89,13 @@ const STANDARD_HAKEN = { belehrung: termineBelehrung, freischaltbar: termineFreischaltbar, }, + // Webshop: kein freischaltbar (ein geschlossenes Ticket genuegt); Belehrung aus derselben Quelle wie beim Terminplaner + shop: { + verfuegbar: (email) => shopFuerKunde(email), + bestellpruefung: shopBestellpruefung, + unterlagenBereit: shopUnterlagenBereit, + belehrung: termineBelehrung, + }, } /** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */ diff --git a/server/services/legalDocs.js b/server/services/legalDocs.js index dbf82f6..f7b1ede 100644 --- a/server/services/legalDocs.js +++ b/server/services/legalDocs.js @@ -1,6 +1,6 @@ // Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/): -// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und -// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum. +// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse), 'termine' und 'shop' (Leistungsbeschreibung, AVV und +// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners bzw. Webshops). analyseLegal.js ist ein duenner Wrapper darum. // Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben. import { access, open, readFile, stat } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -32,13 +32,22 @@ const SAETZE = { avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' }, widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' }, }, + shop: { + lb: { slug: 'leistungsbeschreibung-webshop', pdf: 'Leistungsbeschreibung-Webshop' }, + avv: { slug: 'avv-webshop', pdf: 'AVV-Webshop' }, + widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' }, + }, } /** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */ -const FUNKTION_SATZ = { news: null, termine: 'termine' } +const FUNKTION_SATZ = { news: null, termine: 'termine', shop: 'shop' } /** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */ -const aktuelleFassung = (satz) => (satz === 'analyse' ? config.analyse.avvVersion : config.termine.avvVersion) +const aktuelleFassung = (satz) => { + if (satz === 'analyse') return config.analyse.avvVersion + if (satz === 'shop') return config.shop.fassung + return config.termine.avvVersion +} function satzDokumente(satz) { if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz') diff --git a/server/services/shop/anfrage.js b/server/services/shop/anfrage.js new file mode 100644 index 0000000..6ebabf2 --- /dev/null +++ b/server/services/shop/anfrage.js @@ -0,0 +1,29 @@ +import { config } from '../../config.js' +import { getDocument } from '../appwriteAdmin.js' +import { getFeatureDoc } from '../siteFeatures.js' + +/** + * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Dieser Stand ist der Rumpf aus + * Task A3: nur die Bestellpruefung; Block B (B1, B2) baut Anfrage stellen, zurueckziehen und die Team-Entscheidung aus. + * Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des portalFeatures-Dokuments des Projekts ist. + */ + +export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigegeben.' + +/** + * Offene Punkte der Bestellpruefung (featureHaken('shop').bestellpruefung): [] wenn die Anfrage des Projekts vom Team angenommen + * ist (anfrageStatus 'angenommen'), sonst ein Punkt. Ohne portalFeatures-Dokument, ohne shopConfig-Dokument oder bei jedem anderen + * Status ist die Anfrage nicht freigegeben. Lesefehler werden weitergereicht (der Checkout bestellt dann nicht). + */ +export async function anfrageOffenePunkte({ customerId, projectId }) { + const doc = await getFeatureDoc(customerId, projectId, 'shop') + if (!doc) return [ANFRAGE_OFFEN_TEXT] + let shop + try { + shop = await getDocument(config.collections.shopConfig, doc.$id) + } catch (err) { + if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return [ANFRAGE_OFFEN_TEXT] + throw err + } + return shop?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT] +} diff --git a/server/services/shop/ticketDetails.js b/server/services/shop/ticketDetails.js new file mode 100644 index 0000000..d5a5d76 --- /dev/null +++ b/server/services/shop/ticketDetails.js @@ -0,0 +1,69 @@ +import { config } from '../../config.js' +import { getDocument } from '../appwriteAdmin.js' +import { TICKET_DETAILS_MAX, anleitungsLink, kuerze, minimaleTicketDetails } from '../featureRegistry.js' + +/** + * Details des Einrichtungs-Tickets fuer den Webshop (config.features.shop.ticket.details, Spezifikation Abschnitt 6). + * ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt (doc = portalFeatures-Dokument, project = websiteProjects- + * Dokument, customer = Kunde oder null). Der Text ist deterministisch: Kopfzeilen, Kurzfassung der Anfrage (best effort aus + * shopConfig, Dokument-ID = ID des portalFeatures-Dokuments; Lesefehler oder unlesbares JSON => ohne), Snippet und Schritte. + * Die Funktion wirft nie (bezahlt ist bezahlt, das Ticket entsteht immer) und enthaelt keine Daten von Kaeufern. + */ + +const WARENART = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst', sonstiges: 'Sonstiges' } +const ARTIKELZAHL = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' } + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** Kurzfassung der Anfrage als Zeile, oder '' (Lesefehler, fehlendes Dokument, leere Anfrage). */ +async function anfrageZeile(doc) { + try { + const shop = await getDocument(config.collections.shopConfig, doc.$id) + const anfrage = typeof shop?.anfrage === 'string' && shop.anfrage ? JSON.parse(shop.anfrage) : null + if (!istObjekt(anfrage)) return '' + const teile = [] + if (Object.hasOwn(WARENART, anfrage.warenart)) teile.push(`Warenart: ${WARENART[anfrage.warenart]}`) + const lieferung = [anfrage.versand === true ? 'Versand' : '', anfrage.abholung === true ? 'Abholung' : ''].filter(Boolean) + if (lieferung.length) teile.push(`Lieferung: ${lieferung.join(' und ')}`) + if (Object.hasOwn(ARTIKELZAHL, anfrage.artikelzahl)) teile.push(`Artikelzahl: ${ARTIKELZAHL[anfrage.artikelzahl]}`) + return teile.length ? `Anfrage: ${teile.join(', ')}` : '' + } catch { + return '' + } +} + +export async function shopTicketDetails(ctx) { + try { + const { doc } = ctx + const siteKey = doc?.siteKey || '' + const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Webshop' } }).split('\n').filter((zeile) => !zeile.startsWith('Verweis: ')) + const anfrage = doc?.$id ? await anfrageZeile(doc) : '' + const zeilen = [ + ...kopf, + ...(anfrage ? [anfrage] : []), + '', + 'Snippet (an der Stelle der Shop-Seite einsetzen):', + `
`, + '', + '', + 'Schritte:', + '1. Shop-Seite und Menüpunkt anlegen, Snippet einsetzen.', + `2. Auf allen Seiten im Footer den Link "Vertrag widerrufen" setzen: https://project.webklar.com/shop/${siteKey}/widerruf`, + '3. Datenschutz-Absatz Webshop einfügen (docs/funktionen/shop-datenschutz-absatz.md).', + '4. Impressum prüfen.', + `5. Vorschau prüfen: https://project.webklar.com/shop/${siteKey}`, + '6. Ticket schließen: das schaltet die Funktion frei.', + '', + 'Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.', + `Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`, + ] + return kuerze(zeilen.join('\n').toWellFormed(), TICKET_DETAILS_MAX, { anZeile: true }) + } catch (err) { + console.error(`[shop-ticket] Details nicht erzeugbar, Notfall-Details: ${err?.message}`) + try { + return kuerze(minimaleTicketDetails({ ...ctx, feature: { key: 'Webshop' } }).toWellFormed(), TICKET_DETAILS_MAX) + } catch { + return 'Funktion: Webshop\nDetails konnten nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).' + } + } +} diff --git a/test/featureConsentTexte.test.js b/test/featureConsentTexte.test.js index f2bf91b..b050abd 100644 --- a/test/featureConsentTexte.test.js +++ b/test/featureConsentTexte.test.js @@ -149,6 +149,44 @@ test('textstand_pin: Hash über die verbundenen Bestelltexte beider Funktionen s assert.equal(sha256(texte), TEXTSTAND.sha256, 'Bestelltexte geändert: neues Fassungsdatum und neuen Hash eintragen') }) +/** + * Pin auf die Bestelltexte des Webshops (Haken und Knopf, durch eine Leerzeile verbunden), gebunden an config.shop.fassung. + * Bei Textänderung neues Fassungsdatum (config.shop.fassung) UND neuen Hash: gespeicherte Nachweise tragen Text und Fassung. + */ +const SHOP_TEXTSTAND = { fassung: '2026-10-05', sha256: '09553de56c794602ca462d980de8548015df7b03d90a5ba1227ce83a58a6608a' } + +test('shop_haken_woertlich: fünf Haken in fester Reihenfolge mit dem Wortlaut aus dem Plan', () => { + const S = config.shop.fassung + const SHOP = config.features.shop + assert.deepEqual(SHOP.consent.items.map((i) => i.id), ['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information']) + assert.deepEqual(SHOP.consent.items.map((i) => i.haken), [true, true, true, true, true]) + assert.deepEqual(SHOP.consent.items.map((i) => i.text), [ + `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${S} zu.`, + 'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.', + 'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.', + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', + 'Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis 299 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 2–3 Wochen, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.', + ]) + assert.equal(SHOP.consent.knopf, 'Zahlungspflichtig bestellen · 299 €') + for (const item of SHOP.consent.items) assert.deepEqual(Object.keys(item).sort(), ['haken', 'id', 'text'], 'ohne url') + assert.equal(SHOP.consent.version, S) +}) + +test('shop_textstand_pin: Hash über die verbundenen Bestelltexte des Webshops steht im Test', () => { + const SHOP = config.features.shop + const texte = [...SHOP.consent.items.map((i) => i.text), SHOP.consent.knopf].join('\n\n') + assert.equal(config.shop.fassung, SHOP_TEXTSTAND.fassung, 'neue Fassung: Pin und Hash hier und in den Rechtstext-Tests nachziehen') + assert.equal(sha256(texte), SHOP_TEXTSTAND.sha256, 'Bestelltexte geändert: neues Fassungsdatum und neuen Hash eintragen') +}) + +test('shop_belehrung_haken: dieselbe Belehrung wie beim Terminplaner, ohne ENTWURF', async () => { + const belehrung = await featureHaken('shop').belehrung() + assert.equal(belehrung, widerrufsbelehrungFunktion()) + assert.equal(belehrung, await featureHaken('termine').belehrung()) + assert.deepEqual(config.features.shop.widerruf, { version: config.shop.fassung, url: legalPaths('shop', config.shop.fassung).widerruf }) + assert.doesNotMatch(JSON.stringify([config.features.shop.consent.items, belehrung]), /ENTWURF/) +}) + // ------------------------------------------------------------------------------------------------ Belehrung im Haken test('belehrung_haken: der Standard-Haken liefert die Widerrufsbelehrung der Funktionen, News hat keinen', async () => { diff --git a/test/featureFreischaltung.test.js b/test/featureFreischaltung.test.js index 9f3dc28..e6d5974 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', 'shop']) NEWS.ticketFreischaltung = false - assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop']) NEWS.ticketFreischaltung = 'ja' - assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine'], 'nur true zaehlt') + assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop'], 'nur true zaehlt') NEWS.ticketFreischaltung = true TERMINE.ticketFreischaltung = false try { - assert.deepEqual(reg.ticketFreischaltungKeys(), ['news']) + assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'shop']) } finally { TERMINE.ticketFreischaltung = true } diff --git a/test/featureRegistry.test.js b/test/featureRegistry.test.js index c38a72b..9f12e09 100644 --- a/test/featureRegistry.test.js +++ b/test/featureRegistry.test.js @@ -4,7 +4,7 @@ import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' -// Register config.features (News + Terminplaner) und featureRegistry.js. Dienste (siteFeatures, ticketAdmin) +// Register config.features (News, Terminplaner, Webshop) und featureRegistry.js. Dienste (siteFeatures, ticketAdmin) // brauchen die Test-Umgebung vor dem ersten Import von server/config.js. const aw = await startAppwriteMock() const st = await startFakeStripe() @@ -121,10 +121,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' }, + shop: { name: 'Webshop', keine: 'keinen Webshop' }, }) - assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine']) + assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop']) assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys()) - assert.deepEqual(reg.featureKeys(), ['news', 'termine']) + assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop']) }) // ---------------------------------------------------------------------------------- Schlüsselschutz --- @@ -295,3 +296,206 @@ test('Bestelltexte ohne ENTWURF', async () => { const belehrung = reg.featureHaken('termine').belehrung if (belehrung) frei(await belehrung(), 'belehrung') }) + +// ---------------------------------------------------------------------------------- Webshop (Block A3) --- +test('Shop: Preis, Struktur und Texte', async () => { + const s = reg.featureConfig('shop') + assert.equal(s, config.features.shop) + assert.equal(s.key, 'shop') + assert.equal(s.product, 'shopSetup') + assert.equal(config.shopSetup.amountCents, 29900) + assert.deepEqual(s.label, { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' }) + assert.equal(s.priceLabel, '299 €') + assert.equal(s.setupEstimate, 'meist innerhalb von 2–3 Wochen') + assert.equal(s.consent.knopf, 'Zahlungspflichtig bestellen · 299 €') + assert.deepEqual( + s.consent.items.map((i) => i.id), + ['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information'] + ) + assert.deepEqual( + s.consent.items.map((i) => i.haken), + [true, true, true, true, true] + ) + for (const item of s.consent.items) { + assert.equal(typeof item.text, 'string') + assert.ok(item.text.length > 0) + assert.equal(item.url, undefined, 'ohne url') + } + const F = config.shop.fassung + assert.equal(s.consent.version, F) + assert.equal(s.consent.items[0].text, `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${F} zu.`) + assert.match(s.consent.items[3].text, /Einrichtung des Webshops/) + assert.ok(s.consent.items[4].text.includes(`Gesamtpreis 299 € einmalig (${config.ustHinweis})`)) + assert.ok(s.consent.items[4].text.includes('meist innerhalb von 2–3 Wochen')) + + // Fassung und Adressen folgen config.shop.fassung; die Widerrufsbelehrung ist die der Funktionen + assert.deepEqual(s.leistungsbeschreibung, { version: F, url: `/rechtliches/leistungsbeschreibung-webshop-${F}.html` }) + assert.deepEqual(s.avv, { version: F, url: `/rechtliches/avv-webshop-${F}.html` }) + assert.deepEqual(s.widerruf, { version: F, url: `/rechtliches/widerrufsbelehrung-funktionen-${F}.html` }) + + assert.equal(s.returnHash, 'webshop') + assert.equal(s.returnProjekt, true) + assert.equal(s.requiresWartungsAbo, false) + assert.equal(s.ticketFreischaltung, true) + assert.equal(s.disabledMessage, 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.') + assert.equal(s.orderedConflictMessage, 'Der Webshop ist für dieses Projekt schon bestellt.') + assert.equal(s.unavailableMessage, 'Der Webshop ist noch nicht verfügbar.') + + assert.equal(s.mails.funktion, 'Webshop') + assert.deepEqual(s.mails.formen, { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' }) + assert.equal(s.mails.knopf, 'Webshop öffnen') + assert.equal(s.mails.anleitung, false) + assert.equal(s.mails.preisHinweis, `einmalig, ${config.ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`) + assert.equal(s.mails.danach, 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.') + assert.equal(s.mails.bereit, 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.') + + assert.equal(s.ticket.topic({ projectName: 'Laden' }), 'Webshop einbauen – Laden') + assert.equal(s.ticket.topic({ subdomain: 'laden' }), 'Webshop einbauen – laden') + assert.equal(typeof s.ticket.details, 'function') + + const ansicht = reg.oeffentlicheAnsicht('shop') + assert.equal(ansicht.name, 'Webshop') + assert.equal(ansicht.kurz, 'Shop') + assert.equal(ansicht.priceLabel, '299 €') + assert.equal(ansicht.requiresWartungsAbo, false) + assert.equal(ansicht.returnHash, 'webshop') + assert.deepEqual(ansicht.consent.items.map((i) => i.id), ['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information']) + assert.equal(ansicht.consent.version, F) + assert.deepEqual(ansicht.avv, { version: F, url: `/rechtliches/avv-webshop-${F}.html` }) + assert.doesNotMatch(JSON.stringify(ansicht), /ENTWURF/) + + // Termine bleibt unberührt + assert.equal(config.features.termine.consent.knopf, 'Zahlungspflichtig bestellen · 149 €') +}) + +test('Shop: Fassung folgt config.shop.fassung bei jedem Zugriff', () => { + const s = config.features.shop + const vorher = config.shop.fassung + try { + config.shop.fassung = '2099-01-02' + assert.equal(s.consent.version, '2099-01-02') + assert.ok(s.consent.items[0].text.includes('in der Fassung 2099-01-02 zu.')) + assert.deepEqual(s.avv, { version: '2099-01-02', url: '/rechtliches/avv-webshop-2099-01-02.html' }) + assert.equal(s.leistungsbeschreibung.url, '/rechtliches/leistungsbeschreibung-webshop-2099-01-02.html') + // null beim Widerruf heißt "keine Unterlage" (Tests setzen so eine Bestellung ohne sie), undefined stellt den Standard her + s.widerruf = null + assert.equal(s.widerruf, null) + s.widerruf = undefined + assert.equal(s.widerruf.version, '2099-01-02') + } finally { + config.shop.fassung = vorher + s.widerruf = undefined + } + assert.equal(s.consent.version, vorher) + assert.ok(s.consent.items[0].text.includes(`in der Fassung ${vorher} zu.`)) +}) + +test('Shop-Haken: verfügbar nach Schalter, Belehrung, kein freischaltbar', async () => { + const haken = reg.featureHaken('shop') + assert.deepEqual(Object.keys(haken).sort(), ['belehrung', 'bestellpruefung', 'unterlagenBereit', 'verfuegbar']) + assert.equal(haken.freischaltbar, undefined) + const vorher = { ...config.shop } + try { + config.shop.mode = 'off' + assert.equal(haken.verfuegbar('a@b.de'), false) + assert.equal(reg.featureSperre('shop', 'a@b.de'), 'Der Webshop ist noch nicht verfügbar.') + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['a@b.de'] + assert.equal(haken.verfuegbar('A@B.de'), true) + assert.equal(haken.verfuegbar('x@y.de'), false) + assert.equal(reg.featureSperre('shop', 'a@b.de'), null) + config.shop.mode = 'on' + assert.equal(haken.verfuegbar('x@y.de'), true) + } finally { + Object.assign(config.shop, vorher) + } + // dieselbe Belehrung wie beim Terminplaner (gleiche Quelle) + assert.equal(await haken.belehrung(), await reg.featureHaken('termine').belehrung()) + assert.equal(typeof (await haken.belehrung()), 'string') +}) + +test('Shop-Haken: Bestellprüfung verlangt die angenommene Anfrage', async () => { + const { bestellpruefung } = reg.featureHaken('shop') + const OFFEN = ['Deine Webshop-Anfrage ist noch nicht freigegeben.'] + aw.seed('customers', { $id: 'sc1', name: 'Shop Eins' }) + aw.seed('websiteProjects', { $id: 'sp1', customerId: 'sc1', projectName: 'Laden' }) + // kein portalFeatures-Dokument + assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN) + aw.seed('portalFeatures', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', featureKey: 'shop', siteKey: SITE_KEY, state: 'off', enabled: false }) + // portalFeatures-Dokument, aber kein shopConfig + assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN) + for (const status of ['', 'offen', 'abgelehnt', 'zurueckgezogen']) { + aw.seed('shopConfig', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', siteKey: SITE_KEY, anfrageStatus: status }) + assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN, `Status "${status}"`) + } + aw.seed('shopConfig', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', siteKey: SITE_KEY, anfrageStatus: 'angenommen' }) + assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), []) + // ein fremder Kunde sieht die Anfrage nicht + assert.deepEqual(await bestellpruefung({ customerId: 'sc2', projectId: 'sp1' }), OFFEN) +}) + +test('Shop-Haken: Unterlagen sind bereit, sobald die Dateien da sind (bis Block J: nicht)', async () => { + const { unterlagenBereit } = reg.featureHaken('shop') + const { featureLegalReady, legalPaths } = await import('../server/services/legalDocs.js') + const { existsSync } = await import('node:fs') + const F = config.shop.fassung + const dateien = Object.values(legalPaths('shop', F)).flatMap((p) => [p, p.replace(/\.html$/, '.pdf')]) + const alleDa = dateien.every((p) => existsSync(new URL(`../public${p}`, import.meta.url))) + assert.equal(await featureLegalReady('shop'), alleDa) + assert.equal(await unterlagenBereit(), alleDa) +}) + +test('Shop-Ticket: Details deterministisch, mit Snippet und Schritten', async () => { + aw.seed('shopConfig', { + $id: 'f_sp9_shop', + customerId: 'sc9', + projectId: 'sp9', + siteKey: SITE_KEY, + anfrage: JSON.stringify({ warenart: 'kleidung', beschreibung: 'Streng geheim', artikelzahl: 'bis100', versand: true, abholung: true, nachricht: 'Hallo' }), + anfrageStatus: 'angenommen', + }) + const doc = { $id: 'f_sp9_shop', customerId: 'sc9', projectId: 'sp9', siteKey: SITE_KEY, featureKey: 'shop' } + const ctx = { feature: config.features.shop, doc, project: { projectName: 'Laden', liveDomain: 'laden.de' }, customer: { name: 'Frau Muster' } } + const text = await config.features.shop.ticket.details(ctx) + assert.equal(typeof text, 'string') + for (const teil of [ + 'Funktion: Webshop', + 'Projekt: Laden', + 'Domain: laden.de', + 'Kunde: Frau Muster', + `siteKey: ${SITE_KEY}`, + 'Anfrage: Warenart: Kleidung, Lieferung: Versand und Abholung, Artikelzahl: bis 100', + ``, + '', + `https://project.webklar.com/shop/${SITE_KEY}/widerruf`, + `https://project.webklar.com/shop/${SITE_KEY}`, + 'docs/funktionen/shop-datenschutz-absatz.md', + 'Zeit im Arbeitsblatt', + 'Verweis: ', + '/customers/sc9?tab=funktionen&anleitung=sp9', + ]) { + assert.ok(text.includes(teil), `fehlt: ${teil}`) + } + // freier Text der Anfrage gehört nicht ins Ticket; deterministisch + assert.ok(!text.includes('Streng geheim')) + assert.equal(await config.features.shop.ticket.details(ctx), text) + assert.ok(text.length <= reg.TICKET_DETAILS_MAX) + + // ohne Anfrage oder bei Lesefehler: dieselben Teile, nur ohne Anfrage-Zeile; wirft nie + const ohne = await config.features.shop.ticket.details({ ...ctx, doc: { ...doc, $id: 'f_unbekannt_shop' } }) + assert.ok(!ohne.includes('Anfrage:')) + assert.ok(ohne.includes(`data-webklar-shop="${SITE_KEY}"`)) + aw.seed('shopConfig', { $id: 'f_sp8_shop', customerId: 'sc9', projectId: 'sp8', siteKey: SITE_KEY, anfrage: '{kaputt' }) + const kaputt = await config.features.shop.ticket.details({ ...ctx, doc: { ...doc, $id: 'f_sp8_shop' } }) + assert.ok(!kaputt.includes('Anfrage:')) + const notfall = await config.features.shop.ticket.details(null) + assert.equal(typeof notfall, 'string') + assert.ok(notfall.length > 0) +}) + +test('Shop-Ticket über ticketTexte: Betreff und Details', async () => { + const doc = { $id: 'f_sp7_shop', customerId: 'sc7', projectId: 'sp7', siteKey: SITE_KEY, featureKey: 'shop' } + const { topic, details } = await reg.ticketTexte({ feature: config.features.shop, doc, project: { projectName: 'Laden' }, customer: null }) + assert.equal(topic, 'Webshop einbauen – Laden') + assert.ok(details.includes(`data-webklar-shop="${SITE_KEY}"`)) +}) diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index f91c131..835b876 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -69,13 +69,14 @@ 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', 'shop', 'termine']) + assert.equal(body.features.shop.priceLabel, '299 €') assert.equal(body.features.news.priceLabel, '30 €') assert.equal(body.features.termine.priceLabel, '149 €') assert.equal(body.ustHinweis, config.ustHinweis) assert.ok(typeof body.ustHinweis === 'string' && body.ustHinweis.length > 0) // Items vollstaendig: Kennung, Wortlaut und Haken jedes Registerpunkts, dazu Fassung und Knopf - for (const key of ['news', 'termine']) { + for (const key of ['news', 'termine', 'shop']) { const eintrag = config.features[key] const ansicht = body.features[key] assert.equal(ansicht.key, key) @@ -111,7 +112,7 @@ test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Be const { legalPaths } = await import('../server/services/legalDocs.js') const V = config.termine.avvVersion const body = await get('/api/funktionen', { cookie }) - assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'termine']) + assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'shop', 'termine']) assert.equal(body.legal.news, null) const termine = body.legal.termine assert.deepEqual(Object.keys(termine).sort(), ['belehrung', 'links', 'version']) @@ -130,6 +131,20 @@ test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Be assert.deepEqual(body.features.termine.avv, { version: V, url: adressen.avv }) assert.deepEqual(body.features.termine.leistungsbeschreibung, { version: V, url: adressen.lb }) assert.deepEqual(body.features.news.widerruf, null) + + // Webshop: eigene Fassung (config.shop.fassung), eigene Slugs, dieselbe Belehrung der Funktionen + const S = config.shop.fassung + const shop = body.legal.shop + assert.deepEqual(Object.keys(shop).sort(), ['belehrung', 'links', 'version']) + assert.equal(shop.version, S) + assert.deepEqual(Object.keys(shop.links), ['lb', 'avv', 'widerruf']) + const shopAdressen = legalPaths('shop', S) + for (const [doc, link] of Object.entries(shop.links)) assert.equal(link, `${config.publicBaseUrl}${shopAdressen[doc]}`) + assert.ok(shop.links.lb.includes('leistungsbeschreibung-webshop-')) + assert.ok(shop.links.avv.includes('avv-webshop-')) + assert.ok(shop.links.widerruf.includes('widerrufsbelehrung-funktionen-')) + assert.equal(shop.belehrung, widerrufsbelehrungFunktion()) + assert.deepEqual(body.features.shop.avv, { version: S, url: shopAdressen.avv }) }) test('liste_register_ohne_freigabe: features.termine steht auch bei Schalter off und Pilot ohne Teilnahme', async () => { diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index f12d12d..bf92c39 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -295,7 +295,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', 'shop']) // 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/legalDocs.test.js b/test/legalDocs.test.js index 84a63b7..01c19f9 100644 --- a/test/legalDocs.test.js +++ b/test/legalDocs.test.js @@ -1,6 +1,6 @@ import { test, after, mock } from 'node:test' import assert from 'node:assert/strict' -import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs' +import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs' import fsp from 'node:fs/promises' import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' @@ -291,6 +291,59 @@ test('News: Leistungsbeschreibung folgt der Fassung