From b168b104857f9cde102fd2ffc105c509a792f2e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:26:40 +0000 Subject: [PATCH] Webshop: Design und Umsetzungsplan Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-05-webshop.md | 340 +++++++++++++++++ .../specs/2026-10-05-webshop-design.md | 346 ++++++++++++++++++ 2 files changed, 686 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-webshop.md create mode 100644 docs/superpowers/specs/2026-10-05-webshop-design.md diff --git a/docs/superpowers/plans/2026-10-05-webshop.md b/docs/superpowers/plans/2026-10-05-webshop.md new file mode 100644 index 0000000..858d2aa --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-webshop.md @@ -0,0 +1,340 @@ +# Webshop (Funktion `shop`) – Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Kunden fragen im Portal einen Webshop an, das Team gibt frei, der Kunde bestellt die Einrichtung (299 € einmalig), +verbindet sein eigenes Stripe-Konto (Connect), pflegt Artikel/Versand/Rechtstexte; Käufer bestellen im Widget auf der +Website und zahlen über Stripe Checkout direkt an den Kunden; Bestellungen, Versand, Erstattung, Widerrufe im Portal. + +**Spec:** `docs/superpowers/specs/2026-10-05-webshop-design.md` (Entscheidungen W1–W12). **Fakten:** +`/root/reports/webshop-2026-10-05/fakten-server.md`, `fakten-frontend.md`, `fakten-woms.md`, `recherche-recht-stripe.md` +(jeder Implementer liest die Abschnitte, die sein Task nennt). + +**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-webshop` (Branch `feature/webshop` von +`feature/terminplaner-tp2` af63b69; `node_modules` ist ein Symlink, nie `git add -A`). WOMS: neuer Worktree +`/home/webklar/apps/ticket.webklar.com/dev-webshop`, Branch `feature/webshop` von `feature/terminplaner-tp2` (Block I). + +## Global Constraints + +- Nichts pushen, nichts in Produktion schreiben, keine Live-Stripe-Aufrufe, keine echten Mails. Dateien explizit stagen. +- **Speicher:** jeder Node-Lauf mit `node --max-old-space-size=1024` und `timeout` (Einzeltests `timeout 300`); nie die ganze + Suite parallel mehrfach; vor schweren Läufen `free -m`. Ganze Suite nur der Controller. +- Node-20-tauglich. `config.*` bei jedem Aufruf lesen (Tests ändern Werte). +- Tests: `node --test test/.test.js`; Mocks aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `mailSink`, + `useTestEnv` vor dem ersten Import von config, `startTestApp`, `customerCookie`); nie echte Dienste. +- Pitfall Shell: `grep` ist ein ugrep-Wrapper, der Dateien still überspringt → `command grep -a`. +- Kundentexte (Portal) Du-Form; Texte für Käufer (Widget, Käufer-Mails, Muster-Rechtstexte) **Sie-Form**; Rechtstexte für + WEBklar-Kunden (LB/AVV) Sie-Form wie die vorhandenen. Fremdtext nur per `textContent`; `href` nur geprüfte https-URLs. +- Neue Server-Dienste unter `server/services/shop/`, Front-End-Dateien `public/shop*.js`, `public/shop.css` (nie `termine*`). +- Geld immer in ganzen Cent (`Number.isInteger`), nie Gleitkomma-Summen; Preise aus der Datenbank, nie aus dem Browser. +- Appwrite-Felder: kein Feld namens `data`; Dokument-IDs ≤ 36 Zeichen `[A-Za-z0-9][A-Za-z0-9._-]*`. +- Käufer-PII nie in Logs; Fehlertexte an Browser nur eigene deutsche Texte. + +## Schnittstellen über Blockgrenzen (verbindlich) + +- `config.shop` (A1): `{ mode: SHOP_ENABLED ('off'|'pilot'|'on', Standard 'off'), pilotEmails: SHOP_PILOT_EMAILS (Liste, '*' = alle), + fassung: '2026-10-05', uploadDir: SHOP_UPLOAD_DIR || /upload-data/shop, connectWebhookSecret: STRIPE_CONNECT_WEBHOOK_SECRET || '', + bestellAblaufMinuten: 30, maxArtikel: 500, maxBilder: 4, maxVarianten: 30, anonymisierenJahre: 3, loeschTageNachAbschalten: 90, + unbezahltLoeschTage: 30 }`. `config.shopSetup = { key:'shopSetup', name:'Webshop – Einrichtung', amountCents:29900, + lookupKey:'webklar_shop_einrichtung_29900', oneTime:true, priceNickname:'Webshop-Einrichtung 299 €' }`. + `config.collections.shopConfig|shopProducts|shopOrders|shopWiderrufe` (Env-Override `APPWRITE_COLLECTION_SHOP_*`). +- `server/services/shop/schalter.js` (A1): `shopModus()`, `shopFuerKunde(email) ⇒ boolean` (Muster `termine/schalter.js`). +- `server/services/shop/schema.js` (A2): `SHOP_COLLECTIONS` (reine Daten, kein config-Import; Muster `termine/schema.js`), `SHOP_DATETIMES`. +- Register `config.features.shop` (A3) und `STANDARD_HAKEN.shop` (A3). +- `server/services/shop/geld.js` (C1, rein): `euro(cent) ⇒ '12,50 €'` (Tausenderpunkt, geschütztes Leerzeichen vor €), + `parseEuro(text|number) ⇒ cent|null`, `grundpreisText({ preisCent, menge, einheit }) ⇒ string|''`. +- `server/services/shop/einstellungen.js` (C2): `STANDARD_EINSTELLUNGEN`, `normalisiereEinstellungen(eingabe) ⇒ { einstellungen } | { fehler: {feld: text} }`. +- `server/services/shop/produkte.js` (C3): `normalisiereProdukt(eingabe, { einstellungen }) ⇒ { produkt } | { fehler }`, + `produktOeffentlich(doc, { einstellungen, reserviert }) ⇒ object`, CRUD `listeProdukte(shopId)`, `holeProdukt(shopId, id)`, + `speichereProdukt(shop, id|null, produkt)`, `loescheProdukt(shop, id)`, `sortiere(shop, ids)`. +- `server/services/shop/preise.js` (C4, rein): `berechne({ produkte, positionen, lieferart, land, einstellungen, reserviert }) ⇒ { ok:true, ... } | { ok:false, fehler }` (Felder unten in C4). +- `server/services/shop/rechtstexte.js` (C5): `musterWiderrufsbelehrung(einstellungen)`, `musterWiderrufsformular(einstellungen)`, + `musterAgb(einstellungen)`, `versandUndZahlung(einstellungen)`, `normalisiereRechtstexte(eingabe) ⇒ { rechtstexte } | { fehler }`, + `rechtstexteHash(rechtstexte, einstellungen)`. +- `server/services/shop/kontext.js` (C6): `ladeShopFuerProjekt(customerId, projectId) ⇒ { doc /*portalFeatures*/, shop /*shopConfig, geparst*/ } | null`, + `shopPerSiteKey(siteKey) ⇒ ctx | null` (30-s-Cache, `leereCache(siteKey)`), `bereitschaft(ctx) ⇒ { bereit, punkte: [{ id, ok, text }] }`, + `istGeoeffnet(ctx) ⇒ boolean`. ctx = `{ doc, shop: { id, customerId, projectId, siteKey, einstellungen, rechtstexte, rechtstexteBestaetigt, stripeAccountId, stripeStatus, geoeffnet, anfrage... }, project }`. +- `server/services/shop/connect.js` (D1): `kontoAnlegen({ customer, project }) ⇒ accountId`, `onboardingLink(accountId, { returnUrl, refreshUrl }) ⇒ url`, + `kontoStatus(accountId) ⇒ stripeStatus`, `checkoutAnlegen(accountId, params) ⇒ { id, url }`, `sessionHolen(accountId, id)`, + `erstatten(accountId, { paymentIntent, betragCent, grund }) ⇒ refund`, `connectEreignis(rawBody, signatur) ⇒ event` (wirft 400/503). + Alle Aufrufe mit `{ stripeAccount: accountId }` als Request-Option über `getStripe()`. +- `server/services/shop/bestellungen.js` (E1–E3): `legeBestellungAn(ctx, eingabe, { origin, now })`, `schliesseAb({ accountId, session, now }) ⇒ { order, neu }` + (idempotent, einzige Stelle für Status `bezahlt`), `verwirf(orderId, grund)`, `setzeStatus(shop, orderId, aktion, extra)`, + `erstatte(shop, orderId, { betragCent, grund })`, `listeBestellungen(shopId, filter)`, `bestellungKunde(order)`, `bestellungOeffentlich(order)`, `csv(shopId, von, bis)`, + `reserviert(shopId, now) ⇒ Map` (Schlüssel `produktId` oder `produktId:variantId`). +- Bestellstatus: `offen` (wartet auf Zahlung) → `bezahlt` → `versendet` | `abholbereit` → `abgeschlossen`; `storniert` (nicht bezahlt / abgelaufen / Zahlung fehlgeschlagen); + Zusatzfelder in `daten.erstattungen[]` (Teil-/Vollerstattung, Status `erstattet` wenn voll erstattet). +- Mails (E4): Käufer **G09** Bestellbestätigung, **G10** Versand, **G11** Abholbereit, **G12** Eingang Widerruf, **G13** Erstattung, + **G14** Zahlung fehlgeschlagen; Händler **K25** Neue Bestellung, **K26** Widerruf eingegangen (beide `bccTeam:false`), + **K24** Anfrage-Entscheidung (Blöcke `angenommen`, `abgelehnt`); Team **T21** Neue Webshop-Anfrage (Betreff `[Webshop] …`). + Käufer-Mails über die Terminplaner-Warteschlange (`termine/outbox.js einreihen`, `featureId` = portalFeatures-ID des Shops, `bookingId` = Bestell-ID). +- Öffentliche API (F1–F3) unter `/api/public/shop/v1/:siteKey`: `GET /` (Konfiguration + Artikel), `GET /bild/:datei`, `POST /bestellung`, + `GET /bestellung/:id?t=`, `POST /widerruf`. Kundenroute `/api/shop/*` (B, C7, D2, E5). Team `/api/team/shop/*` (B2). Connect-Webhook + `POST /api/shop/stripe-webhook` (D3). Seiten `GET /shop/:siteKey`, `GET /shop/:siteKey/widerruf` (H2). + +--- + +## Vorab (Controller) + +- [ ] V1 Ledger `.superpowers/sdd/2026-10-05-webshop/progress.md` anlegen; Basislauf der betroffenen Testdateien (nicht die ganze Suite): + `featureRegistry, funktionenCheckout, siteFeatures, mailTemplates, legalDocs, buildLegalHtml, stripeBilling, stripeProductsScript, funktionenTiles, termineView`. + +--- + +## Block A: Grundlagen + +### Task A1: Konfiguration und Schalter +**Dateien:** `server/config.js`, `server/services/shop/schalter.js` (neu), `test/shopSchalter.test.js` (neu). +- `shopSetup` (Katalog) und `config.shop` wie oben; `config.collections.shop*`; Export auf `config`. +- `schalter.js`: `shopModus()` liest `config.shop.mode` (unbekannt ⇒ 'off'); `shopFuerKunde(email)`: 'on' ⇒ true, 'pilot' ⇒ E-Mail (klein) in `pilotEmails` oder `'*'` enthalten, sonst false. +- `stripeBilling.js`: `PRODUCT_METADATA.shopSetup = 'shop-einrichtung'`, `catalogFor('shopSetup')`; `scripts/stripe-products.mjs` KEYS/catalogEntry um `shopSetup`; + Tests `stripeProductsScript.test.js`, `stripeBilling.test.js` gezielt erweitern (Muster termineSetup). +- Tests: Modi, Pilotliste, Katalogwerte. + +### Task A2: Schema und Setup-Skript +**Dateien:** `server/services/shop/schema.js` (neu), `scripts/shop-setup.mjs` (neu, Muster `scripts/termine-setup.mjs`, `--check`), `test/support/schema.js` (`schemaKeys` für die vier Collections), `test/shopSetup.test.js` (neu, Muster `test/termineSetup.test.js`). +Collections (alle ohne Client-Rechte, Felder `required:false`): +- `shopConfig`: customerId 36, projectId 36, siteKey 16, anfrage str 4000, anfrageStatus 16, anfrageAt dt, anfrageEntschiedenAt dt, anfrageEntschiedenVon 255, + anfrageBegruendung 1000, stripeAccountId 64, stripeStatus 4000, einstellungen 20000, rechtstexte 100000, rechtstexteBestaetigtAt dt, + rechtstexteBestaetigtVon 255, rechtstexteHash 64, geoeffnet bool, geoeffnetAt dt, version int. Indexe: idx_customer(customerId), idx_sitekey(siteKey), idx_anfrage(anfrageStatus), idx_account(stripeAccountId). +- `shopProducts`: shopId 36, customerId 36, aktiv bool, sort int, name 120, produkt 30000. Indexe idx_shop(shopId, sort). +- `shopOrders`: shopId 36, customerId 36, bestellnummer 16, status 16, tokenHash 64, stripeSessionId 128, paymentIntentId 64, summeCent int, + erstelltAt dt, ablaufAt dt, bezahltAt dt, kaeuferEmail 320, bestellung 60000, anonymisiertAt dt. Indexe idx_shop_status(shopId,status), idx_nummer(shopId,bestellnummer), idx_session(stripeSessionId), idx_erstellt(shopId, erstelltAt). +- `shopWiderrufe`: shopId 36, customerId 36, bestellnummer 32, name 200, email 320, nachricht 2000, eingangAt dt, bestaetigungAt dt, bearbeitetAt dt, orderId 36. Index idx_shop(shopId, eingangAt). +- Test: Skript legt im Mock an, `--check` meldet Lücken, zweiter Lauf idempotent. + +### Task A3: Register-Eintrag `shop` und Haken +**Dateien:** `server/config.js` (`features.shop`), `server/services/featureRegistry.js` (`STANDARD_HAKEN.shop`), `server/services/legalDocs.js` +(`SAETZE.shop = { lb:{slug:'leistungsbeschreibung-webshop',pdf:'Leistungsbeschreibung-Webshop'}, avv:{slug:'avv-webshop',pdf:'AVV-Webshop'}, widerruf:{ wie termine } }`, +`FUNKTION_SATZ.shop='shop'`, `aktuelleFassung('shop') = config.shop.fassung`), Tests `featureRegistry.test.js`, `legalDocs.test.js`, `siteFeatures.test.js`, +`funktionenRoutes.test.js`, `featureFreischaltung.test.js`, `funktionenTermine.test.js` (Stellen mit festem `['news','termine']` laut fakten-woms §3 → `['news','termine','shop']`). +Registereintrag (Muster `termine`; Rechtsunterlagen-Getter wie `termineRechtsunterlage`, aber mit `config.shop.fassung`; config importiert keine Dienste): +`key:'shop'`, `product:'shopSetup'`, `label:{ name:'Webshop', kurz:'Shop', keine:'keinen Webshop' }`, `priceLabel:'299 €'`, `setupEstimate:'meist innerhalb von 2–3 Wochen'`, +`consent` (Fassung = `config.shop.fassung`; `knopf:'Zahlungspflichtig bestellen · 299 €'`; Items in dieser Reihenfolge, alle `haken:true`): +1. `unterlagen`: „Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung zu.“ +2. `verantwortung`: „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.“ +3. `stripe`: „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.“ +4. `beginn`: Wortlaut wie Terminplaner `beginn`, mit „Einrichtung des Webshops“. +5. `information`: „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 (), 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.“ +`returnHash:'webshop'`, `returnProjekt:true`, `ticket: { topic: (p) => 'Webshop einbauen – ', details: async (ctx) => (await import('./services/shop/ticketDetails.js')).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, ; 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.'`. +`server/services/shop/ticketDetails.js` (neu): Kopf (Funktion, Projekt, Domain, Kunde, siteKey), Anfrage-Kurzfassung (Warenart, Versand/Abholung, Artikelzahl; aus shopConfig, Lesefehler ⇒ ohne), Snippet +`
` + ``, Schritte (Shop-Seite + Menüpunkt anlegen, Snippet einsetzen, auf **allen** Seiten im Footer Link „Vertrag widerrufen“ → `https://project.webklar.com/shop//widerruf`, Datenschutz-Absatz Webshop (`docs/funktionen/shop-datenschutz-absatz.md`) einfügen, Impressum prüfen, Vorschau `https://project.webklar.com/shop/` prüfen, Ticket schließen ⇒ Freischaltung), „Zeit im Arbeitsblatt, nicht auf Abo-Stunden“, Verweis WOMS (`anleitungsLink`). +Haken `STANDARD_HAKEN.shop`: `verfuegbar: (email) => shopFuerKunde(email)`, `bestellpruefung` (dynamisch import `./shop/anfrage.js` `anfrageOffenePunkte({customerId, projectId})` ⇒ `[]` wenn Anfrage `angenommen`, sonst `['Deine Webshop-Anfrage ist noch nicht freigegeben.']`), `unterlagenBereit: () => featureLegalReady('shop')`, `belehrung: termine-Belehrung (gleiche Quelle)`, kein `freischaltbar` (geschlossenes Ticket genügt). +Für A3 genügt in `shop/anfrage.js` ein Stub mit `anfrageOffenePunkte`, den Block B ausbaut. `featureConsentTexte.test.js`: Hash-Pin für shop ergänzen (Muster termine). +Tests: Registerwerte, `oeffentlicheAnsicht('shop')`, Haken, legalPaths('shop'), `featureLegalReady('shop')` false solange Dateien fehlen (Block J legt sie an), Ticket-Texte. + +--- + +## Block B: Anfrage („auf Anfrage“) + +### Task B1: Anfrage-Dienst und Kundenroute +**Dateien:** `server/services/shop/anfrage.js`, `server/routes/shop.js` (neu, Mount `/api/shop` in `server/index.js` neben `/api/termine`), `server/middleware/kundenansichtGuard.js`, `test/shopAnfrage.test.js`. +- `shopConfig`-Dokument-ID = portalFeatures-ID (`ensureFeatureDoc(customerId, projectId, 'shop')` liefert sie); `ensureShopConfig(featureDoc)` legt es an (siteKey kopiert). +- `normalisiereAnfrage(eingabe) ⇒ { anfrage } | { fehler }`: `warenart` ∈ `waren|lebensmittel|kleidung|kunst|sonstiges`, `beschreibung` 10–1500 Zeichen, `artikelzahl` ∈ `bis20|bis100|mehr`, + `versand` bool, `abholung` bool (mind. eins), `unternehmer === true`, `keineAusgeschlossenen === true`, `nachricht` ≤ 1000. +- `stelleAnfrage({ customerId, projectId, eingabe, person, now })`: Projekt gehört Kunde (`assertOwnProject(..., {forNewOrder:true})`), Schalter `shopFuerKunde`, nur wenn Status `''|abgelehnt|zurueckgezogen` (sonst 409), + speichert JSON + Status `offen` + `anfrageAt`, schickt **T21** an das Team (`sendAdminNotice`, Fehler nur loggen). +- `zieheZurueck` (nur `offen`), `anfrageAnsicht(shop)`, `anfrageOffenePunkte({customerId, projectId})`. +- Routen (requireSession, viewAs ⇒ 403 für POST): `GET /api/shop/status?projectId=` ⇒ `{ verfuegbar, anfrage: {status, anfrage, at, begruendung} | null, state, ... }` (Grundgerüst, C7 erweitert), + `POST /api/shop/anfrage {projectId, anfrage}`, `POST /api/shop/anfrage/zurueckziehen {projectId}`. Rate-Limit 20/h je Kunde (`createWriteLimiter`). +- Vorlage `server/mail-templates/T21 Neue Webshop-Anfrage.html` (Muster T16-Layout; Betreff `[Webshop] Neue Anfrage: {{kunde_name}}`; Platzhalter kunde_name, email, projekt, warenart, beschreibung, artikelzahl, lieferung, nachricht, woms_url); + `test/mailTemplates.test.js`: EXPECTED, `[Webshop]` in die T-Betreff-Regex, TEAM_FOOTER. + +### Task B2: Team-Entscheidung und K24 +**Dateien:** `server/routes/team/shop.js` (neu, Mount in `server/routes/team/index.js` als `/shop`), `server/services/shop/anfrage.js`, `server/mail-templates/K24 Webshop-Anfrage.html`, Tests. +- `GET /api/team/shop/:customerId` ⇒ je Projekt `{ projectId, projectName, anfrage, stripe: {accountId?, status}, geoeffnet, zahlen: { artikel, offeneBestellungen } }`. +- `POST /api/team/shop/:customerId/anfrage { projectId, entscheidung: 'annehmen'|'ablehnen', begruendung }` (Begründung Pflicht bei ablehnen, ≤ 1000) unter `withLocks`; + nur aus `offen`; speichert Entscheidung, Person (`req.session.name/email`), Zeit; **K24** an den Kunden (Blöcke `angenommen` mit Link `dashboard.html#webshop?projekt=` und Preis 299 €, `abgelehnt` mit Begründung). +- Tests: Annehmen/Ablehnen, falscher Zustand 409, Mail-Inhalte, Bestell-Haken liefert danach `[]`. + +--- + +## Block C: Shop-Daten und Kundenverwaltung (Server) + +### Task C1: Geld und Grundpreis (rein) +`server/services/shop/geld.js` + `test/shopGeld.test.js`: `euro`, `parseEuro` ('12,5' / '12,50 €' / '1.234,56' / 12.5 ⇒ Cent; negative, > 100000 €, mehr als 2 Nachkommastellen ⇒ null), +`grundpreisText({preisCent, menge, einheit})`: Einheiten `g|kg|ml|l|m|m2`; Bezug 1 kg / 1 l / 1 m / 1 m² (g/ml ⇒ kg/l); Ergebnis „(12,50 € / 1 kg)“; ohne Menge ''; Rundung kaufmännisch auf Cent. + +### Task C2: Einstellungen (rein + Speichern) +`server/services/shop/einstellungen.js` + Tests. Felder: `shopname` (1–80), `anbieter { name, strasse, plz, ort, land:'DE'|'AT', email, telefon, ustId? }` (Pflicht außer ustId/telefon), +`kleinunternehmer` bool, `versand { aktiv, laender: ['DE'] | ['DE','AT'], kostenCent { DE, AT? }, freiAbCent|null }`, `abholung { aktiv, adresse, hinweis ≤ 300 }` +(mind. eins aktiv), `lieferzeit` (Pflicht, 3–80, z. B. „2–4 Werktage“), `zahlungsarten`: Teilmenge von `card|paypal|klarna` (card immer enthalten), +`akzent` (#rrggbb, Standard #111111), `mindestbestellCent|null`, `ruecksendekosten: 'kaeufer'|'haendler'`. +`speichereEinstellungen(shop, einstellungen)` erhöht `version`, leert Kontext-Cache. Fehlerobjekt je Feld (deutsche Texte, Du-Form). + +### Task C3: Produkte (rein + CRUD) +`server/services/shop/produkte.js` + Tests. Produktfelder: `name` 1–120, `kategorie` ≤ 40, `kurz` ≤ 300, `beschreibung` ≤ 5000, `preisCent` (> 0, ≤ 10 000 000), +`steuersatz` ∈ 0|7|19 (bei Kleinunternehmer ignoriert ⇒ 0), `grundpreis {menge, einheit} | null`, `bestand` int ≥ 0 | null (unbegrenzt), `aktiv`, +`varianten { name ≤ 30, werte: [{ id (8 Zeichen, server), name ≤ 40, preisCent, bestand|null }] ≤ 30 } | null` (mit Varianten gilt Preis/Bestand der Variante), +`bilder: [imageId] ≤ 4`, `lieferart: 'beides'|'abholung'`, `widerruf: 'gesetzlich'|'verderblich'|'individuell'|'hygiene'`, +`sicherheit { hersteller: 'selbst' | { name, anschrift, kontakt }, euVerantwortlicher? { name, anschrift, kontakt }, hinweise ≤ 2000 }`, +`lebensmittel { aktiv, pflichtangaben (Pflicht wenn aktiv, ≤ 4000) }`. +CRUD auf `shopProducts` (Feld `produkt` = JSON), Grenze `config.shop.maxArtikel`; `produktOeffentlich` liefert nur Anzeigefelder + `preisText`, `grundpreisText`, +`verfuegbar` (Bestand minus Reservierung > 0 bzw. je Variante), `ausverkauft`, `widerrufText` (z. B. „Vom Widerruf ausgeschlossen: schnell verderbliche Ware“), Bild-URLs +`/api/public/shop/v1//bild/-s.webp` und `-l.webp`. + +### Task C4: Warenkorb-Berechnung (rein) +`server/services/shop/preise.js` + Tests (viele Fälle). `berechne({ produkte /*Map id⇒produkt mit id*/, positionen: [{ produktId, varianteId?, menge }], lieferart: 'versand'|'abholung', land, einstellungen, reserviert })`: +Prüft: 1–50 Positionen, Menge 1–99 ganzzahlig, Produkt aktiv, Variante passt, Bestand (minus `reserviert`) reicht, `abholung` nur wenn aktiv; `versand` nur wenn aktiv, Land erlaubt und **kein** Artikel `lieferart:'abholung'`; +Mindestbestellwert. Versandkosten = `kostenCent[land]`, 0 ab `freiAbCent` (Zwischensumme ≥ Schwelle) oder bei Abholung. Steuer: bei Kleinunternehmer keine; sonst je Satz enthaltene USt = round(brutto * satz / (100 + satz)) je Satz über die Summe der Positionen, +Versandkosten anteilig nach Bruttowert der Sätze (Rest-Cent auf den höchsten Satz). Ergebnis `{ ok:true, positionen:[{ produktId, varianteId, name, varianteText, menge, einzelCent, summeCent, steuersatz, widerruf, einzelText, summeText }], zwischensummeCent, versandCent, gesamtCent, steuer:[{ satz, betragCent, text }], texte:{ zwischensumme, versand, gesamt, steuerHinweis } }` +(`steuerHinweis`: „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“ oder „inkl. 19 % USt: 3,19 €; inkl. 7 % USt: …“), Fehler `{ ok:false, fehler: { code, text, produktId? } }` mit Sie-Texten für Käufer. + +### Task C5: Muster-Rechtstexte des Händlers +`server/services/shop/rechtstexte.js` + `docs/funktionen/shop-muster-rechtstexte.md` (Quelle, gleicher Wortlaut, Kopf „Muster – keine Rechtsberatung“) + Tests (Pins). +- `musterWiderrufsbelehrung(e)`: gesetzliches Muster für Waren (Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB) in aktueller Fassung **inkl. Hinweis auf die Widerrufsfunktion** (nach `recherche-recht-stripe.md` B2/B3), + Anbieter-Name/Anschrift/E-Mail eingesetzt, Variante Rücksendekosten (Käufer trägt unmittelbare Kosten / Händler trägt), Fristbeginn für eine Ware bzw. mehrere getrennt gelieferte Waren (ein Text mit beiden Varianten laut Gestaltungshinweis), + Ausschlussgründe als Absatz „Ausschluss des Widerrufsrechts“ (§ 312g Abs. 2 Nr. 1, 2, 3 BGB). +- `musterWiderrufsformular(e)` (Anlage 2). `musterAgb(e)`: kurze Muster-AGB (Geltung, Vertragspartner, Vertragsschluss: Bestellung durch Klick „Zahlungspflichtig bestellen“, Annahme durch Bestellbestätigung per E-Mail nach Zahlungseingang; Preise und Versandkosten; Zahlung über Stripe, Zahlungsarten; Lieferung, Lieferzeit, Liefergebiet, Abholung; Eigentumsvorbehalt; Widerrufsrecht Verweis; Gewährleistung gesetzlich; Vertragstext wird nicht vom Anbieter gespeichert, Bestellbestätigung enthält ihn; Sprache Deutsch; Streitbeilegung: keine Bereitschaft/Verpflichtung zur Teilnahme an Verbraucherschlichtung (§ 36 VSBG)). +- `versandUndZahlung(e)` (erzeugt, nicht frei editierbar). `normalisiereRechtstexte({ agb ≤ 30000, widerrufsbelehrung ≤ 20000, widerrufsformular ≤ 5000, impressumUrl, datenschutzUrl (https) })`, `rechtstexteHash`. + +### Task C6: Kontext und Bereitschaft +`server/services/shop/kontext.js` + Tests: Laden per Projekt und per siteKey (`findActiveBySiteKey(siteKey,'shop')` + shopConfig + Projekt), JSON-Felder sicher parsen (kaputt ⇒ Standard), Cache 30 s. +`bereitschaft(ctx)` Punkte: `einbau` (state active), `stripe` (`stripeStatus.charges_enabled`), `rechtstexte` (bestätigt und Hash = aktueller Hash), `einstellungen` (normalisiert ok), `artikel` (≥ 1 aktiv und lieferbar), jeweils mit Du-Text. +`istGeoeffnet(ctx)` = `shop.geoeffnet && alle Punkte außer rechtstexte-Hash-Gleichheit ok && rechtstexteBestaetigt vorhanden` (nach Spec 4.6: geändert, aber früher bestätigt ⇒ bleibt offen mit der zuletzt bestätigten Fassung; dafür speichert C7 beim Bestätigen eine Kopie `rechtstexteBestaetigt` in `rechtstexte` JSON: `{ entwurf, bestaetigt }`). + +### Task C7: Kundenrouten Verwaltung +`server/routes/shop.js` + Tests (`test/shopRoutes.test.js`). Alle mit requireSession + eigenes Projekt + Funktion `setup|active` (lesend auch `disabled`), Rate-Limit 300/h je Kunde, Kundenansicht: Lesen ja; Schreiben von Artikeln/Einstellungen/Rechtstexten erlaubt (Guard-Liste ergänzen), **nicht** Stripe, Öffnen, Erstatten. +- `GET /api/shop/status?projectId=` (vollständig): Funktion (state, ticket), Anfrage, Bereitschaft, geoeffnet, stripe (Status ohne Geheimnisse), Zahlen. +- `GET|PUT /api/shop/einstellungen`, `GET /api/shop/produkte`, `POST /api/shop/produkte`, `PUT /api/shop/produkte/:id`, `DELETE /api/shop/produkte/:id`, `POST /api/shop/produkte/sortierung {ids}`. +- `GET /api/shop/rechtstexte` (inkl. `muster` zum Zurücksetzen und `versandUndZahlung`), `PUT /api/shop/rechtstexte` (speichert Entwurf), `POST /api/shop/rechtstexte/bestaetigen { hash }` (Haken-Text aus Spec 4.6; speichert Kopie, Zeit, Person, Hash). +- `POST /api/shop/oeffnen { projectId, offen: bool }` (öffnen nur wenn `bereitschaft.bereit`; schließen immer). +- Bilder: `POST /api/shop/bild?projectId=` (octet-stream ≤ 8 MB, Muster news.js) über `server/services/shop/bilder.js` (aus `newsImages.js` verallgemeinert: Wurzel `config.shop.uploadDir//`, 1600/480 px WebP, Sidecar, `claim` beim Produkt-Speichern, `purgeUnclaimed` 24 h, `deleteShopImage`, `deleteShopImages(siteKey)`); Vorschau `GET /api/shop/bild/:projectId/:datei`. + +--- + +## Block D: Stripe Connect + +### Task D1: Connect-Dienst + Fake-Stripe +`server/services/shop/connect.js`, `test/support/fakeStripe.js` (Header `Stripe-Account` aufzeichnen; `POST /v1/accounts`, `GET /v1/accounts/:id`, `POST /v1/account_links`, Checkout-Sessions je Konto, `POST /v1/refunds`; vorhandenes Verhalten unverändert), `test/shopConnect.test.js`. +- `kontoAnlegen`: `accounts.create({ country:'DE', email, business_profile:{ url: liveUrl|undefined, name: projectName }, controller:{ fees:{payer:'account'}, losses:{payments:'stripe'}, stripe_dashboard:{type:'full'}, requirement_collection:'stripe' }, capabilities:{ card_payments:{requested:true}, transfers:{requested:true} }, metadata:{ webklar:'shop', customerId, projectId } })` – genaue Parameter nach `recherche-recht-stripe.md` A1 prüfen. +- `onboardingLink`: `accountLinks.create({ account, type:'account_onboarding', refresh_url, return_url, collection_options:{fields:'eventually_due'} })`. +- `kontoStatus`: `accounts.retrieve` ⇒ `{ charges_enabled, payouts_enabled, details_submitted, currently_due: [...], disabled_reason, geprueftAt }`. +- `checkoutAnlegen(accountId, params)` = `checkout.sessions.create(params, { stripeAccount: accountId, idempotencyKey })`; `sessionHolen`; `erstatten` = `refunds.create({ payment_intent, amount, reason:'requested_by_customer', metadata }, { stripeAccount })`. +- `connectEreignis(raw, sig)`: 503 wenn `config.shop.connectWebhookSecret` leer, sonst `getStripe().webhooks.constructEvent(raw, sig, secret)`. + +### Task D2: Kundenrouten Stripe +`server/routes/shop.js`: `POST /api/shop/stripe/einrichten { projectId }` (kein viewAs; legt Konto an falls keins, speichert `stripeAccountId` unter Sperre, liefert `{ url }` Onboarding), +`POST /api/shop/stripe/link { projectId }` (neuer Onboarding-Link), `POST /api/shop/stripe/aktualisieren { projectId }` (Status neu lesen + speichern). Rücksprung `dashboard.html#webshop?stripe=zurueck&projekt=`, refresh `...?stripe=neu&projekt=`. Tests mit Fake-Stripe. + +### Task D3: Connect-Webhook +`server/routes/shopWebhook.js` (Mount `POST /api/shop/stripe-webhook` in index.js **vor** `/api/shop`-Router, nutzt `req.rawBody` des globalen Parsers), Tests mit signierten Events (eigenes Test-Secret). +Ereignisse: `account.updated` ⇒ `stripeStatus` des Shops mit diesem Konto (Index `idx_account`); `checkout.session.completed` (payment_status `paid`) und `checkout.session.async_payment_succeeded` ⇒ `schliesseAb({ accountId: event.account, session })`; +`checkout.session.async_payment_failed` ⇒ `verwirf(..., 'zahlung_fehlgeschlagen')` + G14; `checkout.session.expired` ⇒ `verwirf(..., 'abgelaufen')`; `charge.refunded` ⇒ Erstattung nachtragen (nur wenn noch nicht über das Portal gebucht, über `refund.id`). +Nur Sessions mit `metadata.wkShop === '1'` werden behandelt; unbekannte ⇒ 200 ohne Wirkung. Fehler ⇒ 500 (Stripe wiederholt), alles idempotent. + +--- + +## Block E: Bestellungen + +### Task E1: Bestellung anlegen (Server-Kern) +`server/services/shop/bestellungen.js` + Tests. `legeBestellungAn(ctx, { positionen, lieferart, land, rueckUrl }, { now })`: +unter `withLocks(['shop-bestand:'+shopId])`: `istGeoeffnet` (sonst 409 `geschlossen`), Produkte laden, `reserviert(shopId, now)` (Summe der `offen`-Bestellungen mit `ablaufAt > now`), `berechne`; Bestellnummer `JJ-XXXXXX` (base32 ohne 0/1/O/I, eindeutig im Shop), Token 32 Byte (nur Hash speichern), +Dokument `offen` mit Schnappschuss `{ positionen, summen, lieferart, land, einstellungenAuszug (Anbieter, Lieferzeit, Zahlungsarten), rechtstexteHash der bestätigten Fassung, verlauf:[{at, was:'angelegt'}] }`, `ablaufAt = now + 30 min`; +dann Checkout auf dem Konto: `mode:'payment'`, `line_items` (je Position `price_data{ currency:'eur', unit_amount, product_data:{ name: 'Name – Variante', images? (öffentliche absolute Bild-URL) } }, quantity`), Versand als `shipping_options:[{ shipping_rate_data:{ type:'fixed_amount', fixed_amount:{amount, currency:'eur'}, display_name:'Versand' | 'Abholung', delivery_estimate? } }]`, +`shipping_address_collection:{ allowed_countries }` nur bei Versand, `billing_address_collection:'required'`, `phone_number_collection:{enabled:true}`, `payment_method_types` aus Einstellungen, `locale:'de'`, `expires_at` (30 min), `client_reference_id`, `metadata:{ wkShop:'1', shopId, orderId, bestellnummer }`, `payment_intent_data:{ metadata: gleich, description:'Bestellung ' }`, +`success_url` = geprüfte Rück-URL + `wk_bestellung=&wk_t=`, `cancel_url` = Rück-URL + `wk_abbruch=1`. Rück-URL nur, wenn Host ∈ erlaubte Hosts (`analyseDomains(project)` + Portal-Host), sonst `https://project.webklar.com/shop/`. +Scheitert Stripe ⇒ Bestellung `storniert` (Reservierung frei) und 503 mit Käufertext; PayPal/Klarna nicht freigeschaltet ⇒ einmal mit nur `card` wiederholen und dem Händler einen Hinweis in `stripeStatus.hinweis` speichern. + +### Task E2: Abschluss, Verwerfen, Ablauf +`schliesseAb({ accountId, session, now })`: Sperre `shop-order:`; Konto muss zum Shop passen; nur aus `offen` (oder `storniert` mit `grund:'abgelaufen'` und bezahlt ⇒ trotzdem bezahlt, Bestand ggf. negativ ⇒ Händler-Hinweis in K25 Block `ueberverkauft`); `amount_total` muss `summeCent` entsprechen (sonst trotzdem bezahlt, Verlauf-Vermerk + Team-Log); +setzt `bezahlt`, `bezahltAt`, `paymentIntentId`, Käuferdaten aus `customer_details` (name, email, phone, address) und `shipping_details`/`collected_information.shipping_details`, zieht Bestand ab (Produkte unter `shop-bestand`-Sperre), schickt G09 (Käufer) und K25 (Händler). Rückgabe `{ order, neu }`. +`verwirf(orderId, grund)` nur aus `offen`. Minutenjob (`server/services/shop/jobs.js` `minutenJob(now)`): `offen` mit `ablaufAt < now - 5 min` ⇒ Session bei Stripe lesen: `complete`+`paid` ⇒ `schliesseAb`, `expired`/`open` ⇒ (open: `checkout.sessions.expire`) `verwirf('abgelaufen')`; Lesefehler ⇒ nächster Lauf. + +### Task E3: Händler-Aktionen, Erstattung, CSV +`setzeStatus(shop, orderId, aktion)`: `versendet { versanddienst?, sendungsnummer? }` (aus bezahlt) ⇒ G10; `abholbereit` (aus bezahlt, Lieferart abholung) ⇒ G11; `abgeschlossen` (aus bezahlt|versendet|abholbereit); `storniert` (nur offen). +`erstatte(shop, orderId, { betragCent, grund })`: nur bezahlt/versendet/abholbereit/abgeschlossen, Betrag ≤ Rest; `erstatten()` mit Idempotenz-Schlüssel; Eintrag in `erstattungen[]`, voll ⇒ Status `erstattet`; G13 an Käufer. Fehler ⇒ 502 mit Du-Text. +`csv(shopId, von, bis)`: `;`-getrennt, UTF-8 mit BOM, Spalten Bestellnummer, Datum, Status, Name, E-Mail, Lieferart, Adresse, Positionen, Versand, Gesamt, Erstattet; Formel-Injektion verhindern (Zellen mit `= + - @` voranstellen `'`). +Kundenrouten: `GET /api/shop/bestellungen?projectId=&status=&seite=`, `GET /api/shop/bestellungen/:id`, `POST /api/shop/bestellungen/:id/aktion { aktion, ... }`, `POST /api/shop/bestellungen/:id/erstatten`, `GET /api/shop/bestellungen.csv?projectId=&von=&bis=`, `GET /api/shop/widerrufe`, `POST /api/shop/widerrufe/:id/erledigt`. + +### Task E4: Mails +Vorlagen G09–G14 (Muster `G03 …`: Gästelayout, `{{farbe}}`, `{{betrieb_name}}`, Sie-Form, kein WEBklar-Logo; Fußzeile mit Anbieter-Impressumsdaten, Link „Vertrag widerrufen“ `{{widerruf_url}}`), K24 (B2), K25, K26 (K-Layout, Du-Form), T21 (B1). +**G09** Bestellbestätigung: Betreff „Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}“; Anbieter (Name, Anschrift, E-Mail), Bestellnummer, Datum, Positionen (vorgerenderter Text/HTML-Tabelle aus dem Server, Platzhalter `positionen_html` wird **nicht** escaped? – Nein: die Vorlage-Engine escaped immer; deshalb Positionen als Textblock `{{positionen}}` mit Zeilenumbrüchen), Zwischensumme, Versand, Gesamt, Steuerhinweis, Lieferart + Adresse/Abholort, Lieferzeit, Zahlungsart, Widerrufsausschlüsse (Block), **vollständige AGB, Widerrufsbelehrung, Muster-Widerrufsformular** (Blöcke mit Text), Link Widerrufsfunktion. +`server/services/shop/mails.js`: `anKaeufer(art, ctx, order, extra, now)` ⇒ `einreihen({ featureId: ctx.doc.$id, bookingId: order.$id, art, an, fromName: shopname, replyTo: anbieter.email, template: art, values, blocks })` (Outbox `art` ≤ 16; die Outbox prüft Budget; Grenze 3 Mails/h je Empfänger über `limits.darfEmpfaenger` beachten – für G09 nicht anwenden, G09 ist Vertragspflicht); +`anHaendler(template, ctx, values, blocks)` ⇒ `sendCustomerMail(kundenEmail, {..., bccTeam:false})`. Outbox-Erweiterung: `GAST_ARTEN`/Budgetklassen so ändern, dass G09–G14 als Gästemails zählen (G09, G12 Vorrang); der Mail-Job läuft auch, wenn `TERMINE_ENABLED=off` aber `SHOP_ENABLED≠off` (in `termine/jobs.js` die Bedingung für den Mailteil erweitern, sonst unverändert). `test/mailTemplates.test.js` (EXPECTED, GUEST-Liste + Platzhaltertabellen, K-Liste). + +### Task E5: Widerrufe (§ 356a BGB) +`server/services/shop/widerrufe.js` + Tests: `erfasseWiderruf(ctx, { name, email, bestellnummer, nachricht }, { now, ip })`: Pflicht Name (2–200), E-Mail, Bestellnummer (≤ 32; muss nicht existieren); passende Bestellung (Nummer + E-Mail gleich) ⇒ `orderId` + Verlauf; speichert, schickt sofort **G12** Eingangsbestätigung (Inhalt der Erklärung, Datum und Uhrzeit Berlin, Bestellnummer) und **K26** an den Händler; Antwort `{ ok, eingang: 'TT.MM.JJJJ, HH:MM Uhr' }`. Grenzen `darfWiderrufsversuch` (10/IP/h) + 20/Shop/h. + +--- + +## Block F: Öffentliche API + +### Task F1: Lesen +`server/routes/publicShop.js` (Mount `/api/public/shop/v1` **vor** dem globalen Parser, eigenes `express.json({limit:16kb})`, Antwortmuster wie `publicTermine.js schreibe()`), Tests `test/publicShop.test.js`. +`GET /:siteKey` ⇒ `{ aktiv:false }` (unbekannt/inaktiv/aus/nicht verfügbar, kein Orakel) oder `{ aktiv:true, offen: istGeoeffnet, shop:{ name, akzent, anbieter (Name, Anschrift, E-Mail, Telefon), lieferzeit, lieferlaender:[{code,name}], abholung:{aktiv, adresse, hinweis}, zahlungsarten:[Texte], versandText, mindestbestellText, kleinunternehmer, steuerHinweisKurz }, kategorien, produkte:[produktOeffentlich], rechtstexte:{ agb, widerrufsbelehrung, widerrufsformular, versandUndZahlung, impressumUrl, datenschutzUrl } (bestätigte Fassung), widerrufUrl, formToken }`. +Rechtstexte nur, wenn eine bestätigte Fassung existiert. `GET /:siteKey/bild/:datei` (`^[a-z0-9-]{1,64}-(s|l)\.webp$`, Bild muss zu einem Produkt des Shops gehören, Cache 1 Tag, nosniff). Grenzen `darfAnfrage` 120/IP/min, 600/Shop/min. CORS GET `*`. + +### Task F2: Bestellung und Status +`POST /:siteKey/bestellung { positionen, lieferart, land, rueckUrl, formToken, kontakt_url (Honeypot) }`: Herkunftsprüfung wie `erlaubteHerkunft` (Origin-Host ∈ Projektdomains + Portal-Host), Formtoken (HMAC, ≥ 3 s), Honeypot ⇒ scheinbarer Erfolg ohne Wirkung (`{ url: rueckUrl }`), Grenzen 10/IP/10 min + 60/Shop/h, Mindestantwortzeit 300 ms; ⇒ `{ url }` (Stripe) oder Fehler `{ fehler:{ _: text }, code }`. +Vorab-Berechnung ohne Anlegen: `POST /:siteKey/pruefen { positionen, lieferart, land }` ⇒ Ergebnis von `berechne` (Texte), damit die Kasse die Zusammenfassung **vom Server** zeigt. +`GET /:siteKey/bestellung/:id?t=` ⇒ Token prüfen (timingSafeEqual auf Hash) ⇒ bei `offen` einmal `sessionHolen` + ggf. `schliesseAb`; Antwort `{ status, bestellnummer, gesamtText, lieferart, email (maskiert) }`. + +### Task F3: Widerruf und Seiten +`POST /:siteKey/widerruf { name, email, bestellnummer, nachricht, formToken, kontakt_url }` (Origin: Projektdomains + Portal-Host; Formtoken; Grenzen; Mindestantwortzeit) ⇒ `{ ok:true, eingang }`. +Funktioniert auch bei geschlossenem, aber `active`/`disabled`-Shop (Widerrufsrecht läuft weiter); nur `off`/unbekannt ⇒ `{aktiv:false}`. + +--- + +## Block G: Portal-Oberfläche + +### Task G1: Verdrahtung, Kachel, Bestellseite verallgemeinern +`public/dashboard.html` (`/shop.css`, `
` mit Kopf „Webshop“ + `#webshop-body`), `public/app.js` (`FEATURE_LABELS.shop`, `VIEW_HASHES.webshop`, `viewRefreshers.webshop` ⇒ `import('/shop.js').then(m => m.initShop(...))`, `APP_PARENT.webshop = 'funktionen'` am Ende), `public/funktionen.js` (Kachel „Webshop“: Zustände laut Spec-Tabelle 2; Text „Dein eigener Online-Shop auf Deiner Website. Zahlungen direkt auf Dein Stripe-Konto. 299 € einmalig.“; Klick ⇒ `#webshop` mit Wunschprojekt), `public/funktion-bestellen.js` (Checkout-Pfad und Zusammenfassung über Parameter `key`/`zusammenfassung`, Termine-Verhalten unverändert; Tests bleiben grün), Tests (`funktionenTiles.test.js`, neue statische Verdrahtungstests). + +### Task G2: Hülle `#webshop`, Anfrage, Status +`public/shop.js` (Hülle nach `termine.js`: Rückkehr lesen `?bestellt=1`, `?stripe=…`, Projektwahl, Zustände: nicht verfügbar ⇒ Infokarte; keine Anfrage ⇒ Anfrageformular (`public/shop-anfrage.js`); Anfrage offen/abgelehnt ⇒ Karte; freigegeben + `off` ⇒ Bestellseite (`funktion-bestellen.js` mit key `shop`, Belehrung/Unterlagen aus `GET /api/funktionen` `features.shop` + `legal.shop`); `ordered` ⇒ Statuskarte „Zahlung fortsetzen“; `setup|active|disabled` ⇒ Reiter), `public/shop-dom.js` (Helfer: `h`, `request`, Felder – Kopie der termine-dom-Helfer, nicht importieren), `public/shop.css` (Tokens `--kb-*`, Reiterleiste 5 Spalten, mobil scrollbar), Test `test/shopView.test.js` mit `installFakeDom`. + +### Task G3: Reiter Übersicht + Stripe +`public/shop-tab-uebersicht.js`: Checkliste (Bereitschaft vom Server), Schalter „Shop geöffnet“, Stripe-Karte (Einrichten / Angaben ergänzen / Status aktualisieren / Dashboard-Link `https://dashboard.stripe.com/`), Vorschau-Link `https://project.webklar.com/shop/`, Einbau-Snippet nur lesen, Kennzahlen. Test. + +### Task G4: Reiter Artikel +`public/shop-tab-artikel.js`: Liste mit Hoch/Runter, Aktiv-Schalter, Editor (alle Felder aus C3, Varianten-Editor, Bild-Upload mit Vorschau/Entfernen/Reihenfolge, Lebensmittel-/Sicherheits-Abschnitte einklappbar, Grundpreis), Speichern mit Feldfehlern. Test. + +### Task G5: Reiter Bestellungen +`public/shop-tab-bestellungen.js`: Filter, Liste, Detail, Aktionen (versendet mit Sendungsnummer, abholbereit, abgeschlossen, erstatten mit Betrag + Bestätigung), Widerrufe-Liste mit „erledigt“, CSV-Download (Zeitraum). Test. + +### Task G6: Reiter Einstellungen + Rechtliches +`public/shop-tab-einstellungen.js` (alle Felder C2, Hinweis bei PayPal/Klarna „nur einschalten, wenn in Deinem Stripe-Konto aktiviert“), `public/shop-tab-rechtliches.js` (AGB/Widerrufsbelehrung/Formular bearbeiten, „Muster einsetzen“, Versand & Zahlung nur lesen, Impressum-/Datenschutz-URL, Bestätigungshaken + Knopf, Banner wenn Änderungen unbestätigt, Hinweis „Muster ohne Gewähr; lass Deine Texte prüfen, z. B. von einer Kanzlei oder einem Händlerbund“). Test. + +--- + +## Block H: Widget und öffentliche Seiten + +### Task H1: `public/embed/shop.js` +ES5-IIFE nach `embed/termine.js` (Light DOM, `@layer wks`, Präfix `wks-`, `data-webklar-shop`, `data-api`, `--wks-akzent`, `credentials:'omit'`, 20 s Timeout, Ausweichtext bleibt bei Fehlern). Ansichten: Raster (Kategorien-Filter), Detail (Galerie, Beschreibung, Preis + Grundpreis + Steuerhinweis, Varianten, Menge, Lieferhinweis, Widerrufsausschluss, Produktsicherheit, Lebensmittelangaben), Warenkorb (Zustand im Speicher **und** `sessionStorage` mit try/catch, nur IDs/Mengen), Kasse (Lieferart/Land, Zusammenfassung **vom Server** `/pruefen`, Pflichtinformationen, Links AGB/Widerrufsbelehrung/Versand, Hinweis „Name, Adresse und Zahlung geben Sie im nächsten Schritt bei Stripe ein“, Knopf **„Zahlungspflichtig bestellen“**), Rückkehr (`wk_bestellung`/`wk_t` ⇒ Status anzeigen, `wk_abbruch` ⇒ Warenkorb; danach `history.replaceState` bereinigen), Widerruf (Schaltfläche „Vertrag widerrufen“ ⇒ Formular ⇒ Knopf **„Widerruf bestätigen“** ⇒ Eingangsanzeige mit Zeit). Kopfzeile: Lieferländer + Zahlungsarten (§ 312j Abs. 1). Fußzeile: AGB · Widerrufsbelehrung · Versand & Zahlung · Vertrag widerrufen · Impressum · Datenschutz. Geschlossen ⇒ „Der Shop ist gerade geschlossen.“ + Widerruf weiter erreichbar. Rechtstexte als Overlay/`
` mit `textContent` (Absätze per `\n\n`). Keine Zahlen-/Datumsformatierung im Widget (alles vom Server). Statische Tests `test/shopEmbedStatic.test.js` (kein `innerHTML` mit Fremddaten, kein `eval`, Knopftexte vorhanden). + +### Task H2: Seiten und Demo +`public/shop-seite.html` (neutrale Seite mit Widget; Server-Route `GET /shop/:siteKey` und `GET /shop/:siteKey/widerruf` (siteKey-Regex, sonst 404) liefert die Datei; `?ansicht=widerruf` öffnet direkt den Widerruf), `public/embed/shop-demo.html`, Test der Route. + +--- + +## Block I: WOMS (eigener Worktree) + +### Task I1: Funktionen-Karte + Anfrage +WOMS `src/lib/funktionenView.js` (`FUNKTIONEN` + `shop` mit Bestätigungstexten; `aktionen` für shop wie termine: setup ⇒ abnahme/ticket/disable), `src/lib/teamApi.js` (`getShop`, `entscheideShopAnfrage`), `src/components/customer/CustomerFunktionen.jsx` (Webshop-Panel je Projekt: Anfrage lesen, „Anfrage annehmen“/„Ablehnen“ mit Begründung, Stripe-Status, Kennzahlen), `scripts/setup-legal.mjs` (`DOCUMENTS` + `leistungsbeschreibung-webshop`, `avv-webshop`; Test-Anzahl anpassen), Tests `npm test` + `nice npx vite build`. + +--- + +## Block J: Rechtstexte, Jobs, Aufräumen + +### Task J1: Leistungsbeschreibung + AVV Webshop +`docs/rechtliches/leistungsbeschreibung-webshop.md`, `docs/rechtliches/avv-webshop.md` (Inhalt Spec 10; Muster LB/AVV Terminplaner; Fassung `config.shop.fassung`), `scripts/build-legal-html.mjs` und `scripts/build-legal-pdfs.sh` um Satz/Modus `shop` erweitern, HTML + PDF + `.sha256` erzeugen (Chrome headless wie vorhanden, `timeout 120`), Tests `legalDocs`, `buildLegalHtml` erweitern; danach `featureLegalReady('shop') === true`. + +### Task J2: Datenschutz, Widerruf der Funktion, Doku +`public/datenschutz.html` Abschnitt „Webshop“ (Stand-Datum, PDF neu über `build-legal-pdfs.sh datenschutz`), `server/routes/abo.js` (`ORDERS`, `FEATURE_OF_ORDER`, `orderLabel` um `shop`) + `public/widerrufen.html` (Option „Webshop (Einrichtung)“), `docs/funktionen/shop-einbau.md`, `docs/funktionen/shop-datenschutz-absatz.md` (Muster für die Händler-Website), `docs/rechtliches/webshop-kanzlei-paket.md` (Fragen + Fundstellen), Tests. + +### Task J3: Jobs, Aufbewahrung, Kundenlöschung +`server/services/shop/jobs.js` (`minutenJob` aus E2 + Bilder-Aufräumen stündlich; `taeglicherJob`: Käuferdaten anonymisieren 3 Jahre nach Ende des Bestelljahres, unbezahlte/stornierte nach 30 Tagen; Shops `disabled` seit > 90 Tagen: Produkte, Bestellungen, Widerrufe, Bilder, shopConfig löschen, Dokument bleibt `disabled`; seitenweise, Deckel je Lauf), `server/index.js` (Jobs registrieren, nur wenn `shopModus() !== 'off'` oder es Shop-Dokumente gibt), `server/services/customerAdmin.js` (Kundenlöschung: `disableCustomerFeatures(customerId,'shop',…)`, offenen Shop schließen), `retention.js` nur falls nötig. Tests. + +--- + +## Block K: Abschluss (Controller) +- [ ] K1 ganze Suite (Host, `--max-old-space-size`), Node-20-Lauf der Shop-Tests im Container. +- [ ] K2 Gesamt-Review (2 sonnet-Reviewer: Geld/Stripe/Bestellungen; Recht/UI/Widget), Fixrunde. +- [ ] K3 End-to-End mit Mocks: Server mit Appwrite-Mock + Fake-Stripe + SMTP-Senke, Playwright: Anfrage → Freigabe → Bestellung → Artikel → Öffnen → Kauf im Widget → Webhook → Mails → Versand → Erstattung → Widerruf; Screenshots nach `/root/reports/webshop-2026-10-05/ui/`. +- [ ] K4 Livegang-Anleitung `docs/funktionen/shop-livegang.md` (Spec 11) und Memory/Ledger aktualisieren. diff --git a/docs/superpowers/specs/2026-10-05-webshop-design.md b/docs/superpowers/specs/2026-10-05-webshop-design.md new file mode 100644 index 0000000..0010595 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-webshop-design.md @@ -0,0 +1,346 @@ +# Webshop (Funktion `shop`): Design + +Stand 2026-10-05. Basis: Portal-Zweig `feature/terminplaner-tp2` af63b69 (Register `config.features`, generischer Checkout, +Zustimmungsnachweis `featureConsents`, versionierte Rechtsunterlagen). Arbeitszweig `feature/webshop`, Worktree `dev-webshop`. +Pfade: **P** = Portal-Repo (`kundenbereich.webklar.com`), **W** = WOMS-Repo (`ticket.webklar.com`). +Fakten aus dem Code: `/root/reports/webshop-2026-10-05/fakten-*.md`; Recht und Stripe: `recherche-recht-stripe.md`. + +Auftrag des Inhabers (2026-10-05, `/goal`): „Online-Webshop-Integration, auf Anfrage, einmalige Kosten, der Kunde +konfiguriert alle Artikel, Stripe-Anbindung (PayPal optional, nur Stripe ist ok), keine Rückfragen, Recht beachten.“ +Alle Entscheidungen unten (W1–W30) hat Claude ohne Rückfrage getroffen; sie stehen gesammelt in Abschnitt 12. + +--- + +## 1. Ziel und Abgrenzung + +**Der Webshop liefert:** Ein Kunde fragt im Portal einen Webshop für eine seiner Websites an. Das Team prüft die Anfrage +(was wird verkauft, ist es erlaubt, passt es) und gibt sie frei. Danach bestellt der Kunde die Einrichtung (einmalig +299 €, Stripe-Checkout wie bei den anderen Funktionen). Es entsteht ein Einrichtungs-Ticket, das Team baut den Shop in +die Website ein. Der Kunde verbindet sein **eigenes** Stripe-Konto (Stripe Connect), pflegt Artikel, Versand, Abholung +und seine Rechtstexte im Portal und öffnet den Shop. Endkunden kaufen im Widget auf seiner Website und bezahlen über +Stripe Checkout direkt an den Kunden. Bestellungen, Versand, Erstattung und Widerrufe verwaltet der Kunde im Portal. + +**Nicht Teil (V1):** digitale Produkte/Downloads, Abos/wiederkehrende Zahlungen, Gutscheine/Rabattcodes, Versand +außerhalb Deutschlands und Österreichs, Lagerverwaltung über mehrere Lager, Rechnungserstellung durch WEBklar (die +Stripe-Quittung und die Bestellbestätigung gehen an den Käufer, Rechnungen schreibt der Händler selbst), Anbindung +eines schon bestehenden Stripe-Kontos per OAuth (später), alkoholische Getränke, Tabak, Arzneimittel, Waffen, +Erotik, Lebende Tiere und alles auf Stripes Liste verbotener Geschäfte (Bestellgate + Leistungsbeschreibung). + +**Warum nur Stripe (W1):** Stripe läuft schon für Abo und Funktionen. Stripe Checkout bietet PayPal, Klarna, Apple Pay +und Google Pay als Zahlungsarten **innerhalb** desselben Checkouts an, wenn der Händler sie in seinem Stripe-Konto +einschaltet. Eine eigene PayPal-Anbindung hätte eine zweite Integration, einen zweiten Webhook und eine zweite +Erstattungslogik bedeutet, ohne dem Käufer eine Zahlungsart mehr zu geben. + +**Warum Stripe Connect mit eigenem Konto des Kunden (W2):** Das Geld fließt direkt vom Käufer an den Händler (Direct +Charges auf dem verbundenen Konto). WEBklar nimmt nie fremdes Geld an, behält keine Gebühr ein und haftet nicht für +Rückbuchungen. Damit ist WEBklar kein Zahlungsdienst im Sinne des ZAG (Stripe ist das lizenzierte Institut), und der +Händler hat sein volles Stripe-Dashboard (Auszahlungen, Erstattungen, Belege). Konten werden mit den +`controller`-Eigenschaften angelegt: Stripe-Dashboard voll, Gebühren zahlt das Konto, Verluste trägt Stripe/Konto, +Anforderungen sammelt Stripe (Gegenstück zum früheren `type: 'standard'`). + +**Pilot und Schalter (W3):** `SHOP_ENABLED=off|pilot|on` wie `TERMINE_ENABLED`; `pilot` nur für `SHOP_PILOT_EMAILS`. +Pilotkunde ist das Testkonto (kenso.gri@gmail.com, Projekt `test-website`). + +--- + +## 2. Ablauf aus Kundensicht + +| Zustand | Kachel (Funktionen-Tab) | Ansicht `#webshop` | Mail | +|---|---|---|---| +| nichts angefragt | „Webshop anfragen“ | Anfrageformular | – | +| Anfrage offen | „Anfrage wird geprüft“ | Anfrage lesen, zurückziehen | T21 an Team | +| Anfrage abgelehnt | „Anfrage abgelehnt“ + Begründung, neue Anfrage möglich | Begründung | K24 | +| freigegeben (`off`) | „Webshop bestellen · 299 €“ | Bestellseite (generisch) | K24 | +| `ordered` | „Zahlung offen“ | Statuskarte, „Zahlung fortsetzen“ | – | +| `setup` | „In Einrichtung“ | Statuskarte + **alle Reiter nutzbar** (Artikel, Stripe, Rechtliches) | K15, T16 | +| `active` | „Webshop öffnen“ | Verwaltung | K16 | +| `disabled` | „Webshop ansehen“ | lesen, CSV-Export | – | + +**Auf Anfrage (W4):** Der Inhaber will die Funktion „auf Anfrage“. Umsetzung als vorgeschaltete Freigabe: Die Kachel ist +für jeden Kunden mit Website-Projekt sichtbar (wenn der Schalter es erlaubt), bestellbar wird sie erst nach Freigabe +durch das Team. Die Anfrage fragt: Was wird verkauft (Freitext + Warenart: Waren/Lebensmittel/Kleidung/Kunst/Sonstiges), +ungefähre Artikelzahl, Versand und/oder Abholung, Händler ist Unternehmer (Pflicht-Haken), keine der ausgeschlossenen +Waren (Pflicht-Haken), Nachricht ans Team. Das Team sieht die Anfrage im WOMS (Kunde → Funktionen) und nimmt sie an oder +lehnt sie mit Begründung ab. Der Preis bleibt fest (299 €); Sonderfälle (sehr viele Artikel, Sonderdesign) bespricht das +Team im Chat und rechnet Mehraufwand nach Stunden ab, wie beim Terminplaner (Leistungsbeschreibung). + +**Einmalige Kosten (W5):** 299 € einmalig (Kleinunternehmer, ohne USt gemäß § 19 UStG), kein Wartungs-Abo nötig, keine +laufende Gebühr von WEBklar, keine Umsatzbeteiligung. Der Shop läuft, solange WEBklar die Website betreibt (Hosting oder +Wartung). Stripe-Gebühren zahlt der Händler direkt an Stripe. + +**Ab `setup` nutzbar (W6):** Anders als beim Terminplaner (Einstellung ab Bestellung gesperrt) pflegt der Kunde ab +`setup` Artikel, Stripe-Konto und Rechtstexte, damit der Shop zum Einbau fertig ist. Die Website zeigt den Shop erst, +wenn alles erfüllt ist (Abschnitt 4.6, „Shop geöffnet“). + +--- + +## 3. Datenmodell (Appwrite, `scripts/shop-setup.mjs`, nur Server-Key) + +Alle Collections ohne Client-Rechte (wie `termineConfig`); alles läuft über den Portal-Server. + +- **`shopConfig`** (Dokument-ID = ID des `portalFeatures`-Dokuments; eins je Projekt): + `customerId`, `projectId`, `siteKey` (Kopie aus portalFeatures, Index), `anfrage` (JSON-Text ≤ 4000), + `anfrageStatus` (`''|offen|angenommen|abgelehnt|zurueckgezogen`), `anfrageAt`, `anfrageEntschiedenAt`, + `anfrageEntschiedenVon`, `anfrageBegruendung` (≤ 1000), `stripeAccountId`, `stripeStatus` (JSON ≤ 4000: + charges_enabled, payouts_enabled, details_submitted, requirements.currently_due, disabled_reason, geprüft am), + `einstellungen` (JSON ≤ 20000), `rechtstexte` (JSON ≤ 100000), `rechtstexteBestaetigtAt`, `rechtstexteBestaetigtVon`, + `rechtstexteFassung` (Hash), `geoeffnet` (bool), `geoeffnetAt`. +- **`shopProducts`**: `shopId` (= shopConfig-ID, Index), `customerId`, `aktiv` (bool), `sort` (int), `name` (≤ 120), + `daten` (JSON ≤ 30000: Kurz-/Langbeschreibung, Preis in Cent, Grundpreis-Menge+Einheit, Steuersatz, Bestand, + Varianten, Bilder, Lieferart, Widerrufsausschluss, Produktsicherheit, Lebensmittelangaben, Kategorie). +- **`shopOrders`**: `shopId` (Index), `customerId`, `bestellnummer` (eindeutig je Shop, Index), `status` (Index), + `tokenHash`, `stripeSessionId` (Index), `paymentIntentId`, `summeCent`, `erstelltAt`, `ablaufAt`, `bezahltAt`, + `kaeuferEmail` (≤ 320), `daten` (JSON ≤ 60000: Positionen als Schnappschuss, Lieferart, Adressen, Versandkosten, + Steuerausweis, Fassung der Rechtstexte, Verlauf), `anonymisiertAt`. +- **`shopWiderrufe`**: `shopId` (Index), `customerId`, `bestellnummer`, `name`, `email`, `nachricht` (≤ 2000), + `eingangAt`, `bestaetigungGesendetAt`, `bearbeitetAt`. +- **Bilder** auf der Platte unter `upload-data/shop//.` (gitignored, wie News-Fotos), ausgeliefert über + die öffentliche API. + +Bestellnummer (W7): `-<6 Zeichen base32>` (z. B. `26-K7QM2X`), zufällig, bei Kollision im Shop neu ziehen. Keine +fortlaufende Zählung nötig (kein Rechnungsnummernkreis, Rechnungen schreibt der Händler). + +--- + +## 4. Shop-Verwaltung im Portal (`#webshop`) + +Reiter: **Übersicht · Artikel · Bestellungen · Einstellungen · Rechtliches**. Projektwahl wie beim Terminplaner (Shop je +Website-Projekt). + +### 4.1 Übersicht +Checkliste „Bereit zum Öffnen“ mit Haken: Einbau fertig (`active`), Stripe verbunden und Zahlungen freigeschaltet +(`charges_enabled`), Rechtstexte vollständig und bestätigt, mindestens ein aktiver Artikel, Versand oder Abholung +eingerichtet. Schalter „Shop geöffnet“ (nur wenn alle Haken grün; serverseitig erneut geprüft). Kennzahlen: offene +Bestellungen, Bestellungen 30 Tage, Umsatz 30 Tage (nur Anzeige). Knopf „Shop-Vorschau“ (Standalone-Seite, Abschnitt 5.4). + +### 4.2 Stripe-Konto +Knopf „Stripe-Konto einrichten“: Server legt das verbundene Konto an (Land DE, E-Mail und Website des Kunden +vorbefüllt) und holt einen Onboarding-Link (Account Link), Weiterleitung zu Stripe; Rücksprung `#webshop?stripe=zurueck` +liest den Status neu. Danach: Status (Zahlungen aktiv / Auszahlungen aktiv / fehlende Angaben) und Knöpfe +„Angaben ergänzen“ (neuer Account Link) und „Stripe-Dashboard öffnen“ (dashboard.stripe.com). Status wird zusätzlich +per Connect-Webhook `account.updated` aktuell gehalten. + +### 4.3 Artikel +Liste (sortierbar mit Hoch/Runter, aktiv/inaktiv, Bestand) und Editor: +- Name, Kategorie (frei, gruppiert im Shop), Kurzbeschreibung (≤ 300), Beschreibung (≤ 5000, Absätze, kein HTML). +- Preis brutto in € (PAngV: Gesamtpreis), Steuersatz (19 / 7 / 0 % – bei Kleinunternehmer ausgeblendet). +- Grundpreis (PAngV § 4): Menge + Einheit (g, kg, ml, l, m, m², Stück-los) → Anzeige „x € / 1 kg“ bzw. „/ 100 g“ nach + § 5 PAngV (bei ≤ 250 g/ml darf 100 g/ml verwendet werden; V1 rechnet immer auf 1 kg / 1 l / 1 m / 1 m²). +- Bestand (leer = unbegrenzt) und „ausverkauft anzeigen“ statt ausblenden. +- Varianten (optional, eine Dimension, z. B. „Größe“): je Wert eigener Preis und Bestand (max 30). +- Bilder: bis 4, JPEG/PNG/WebP ≤ 8 MB, serverseitig geprüft (Signatur), erstes = Titelbild. +- Lieferart: Versand und Abholung / nur Abholung. +- Widerruf (§ 312g Abs. 2 BGB): „gesetzliches Widerrufsrecht“ (Standard) oder Ausschluss mit Grund: schnell + verderblich, nach Kundenwunsch angefertigt, versiegelt aus Hygienegründen. Der Shop zeigt den Hinweis am Artikel und in + der Bestellübersicht, die Bestellbestätigung nennt ihn. +- Produktsicherheit (GPSR Art. 19): Hersteller (Name, Anschrift, E-Mail/Website; Vorgabe „ich selbst“ = Anbieterdaten), + bei Herstellern außerhalb der EU die verantwortliche Person in der EU, Warn- und Sicherheitshinweise (Freitext). +- Lebensmittel (LMIV Art. 14): Schalter „ist ein Lebensmittel“ → Pflichtfeld „Pflichtangaben“ (Bezeichnung, Zutaten, + Allergene hervorgehoben, Menge, ggf. Hersteller), wird vor dem Kauf am Artikel gezeigt. + +### 4.4 Bestellungen +Liste (Status-Filter, Suche nach Nummer/Name), Detail mit Positionen, Lieferart, Adresse, Zahlung, Verlauf. Aktionen: +„Versendet“ (optional Sendungsnummer + Versanddienst → Mail G10), „Abholbereit“ (→ G11), „Abgeschlossen“, „Erstatten“ +(voll oder Teilbetrag, Grund; Stripe-Refund auf dem verbundenen Konto → G13), „Stornieren“ (nur unbezahlt). Widerrufe +erscheinen an der Bestellung und als eigene Liste. CSV-Export (Bestellungen eines Zeitraums). + +### 4.5 Einstellungen +Shopname, Anbieter (Firma/Name, Anschrift, E-Mail, Telefon, optional USt-IdNr.), Steuer (Kleinunternehmer ja/nein), +Versand (an/aus, Länder DE / DE+AT, Versandkosten je Land, versandkostenfrei ab), Abholung (an/aus, Adresse, Hinweis z. B. +Öffnungszeiten), Lieferzeit (Pflicht, z. B. „2–4 Werktage“), Zahlungsarten (Karte inkl. Apple Pay/Google Pay immer; +PayPal, Klarna wählbar – nur einschalten, wenn im eigenen Stripe-Konto aktiviert; der Shop zeigt genau diese Liste), +Akzentfarbe, Mindestbestellwert (optional). + +### 4.6 Rechtliches (Händler-Rechtstexte) und „Shop geöffnet“ +Der Händler ist Verkäufer und verantwortlich. WEBklar liefert Muster, keine Rechtsberatung (W8, RDG): +- **Widerrufsbelehrung**: vorbefüllt mit dem gesetzlichen Muster (Anlage 1 zu Art. 246a § 1 Abs. 2 EGBGB) inkl. der + Hinweise auf die elektronische Widerrufsfunktion, Name/Anschrift/E-Mail eingesetzt; Rücksendekosten trägt der Käufer + (Standard, umschaltbar); Muster-Widerrufsformular (Anlage 2). Bearbeitbar; „Auf Muster zurücksetzen“. +- **AGB**: Muster-AGB für kleine Shops (Vertragsschluss, Preise, Versand, Zahlung über Stripe, Eigentumsvorbehalt, + Gewährleistung gesetzlich, Streitbeilegung VSBG-Hinweis), deutlich als Muster gekennzeichnet; bearbeitbar. +- **Versand- und Zahlungsinformationen**: aus den Einstellungen erzeugt (nicht frei editierbar, damit sie stimmen). +- **Impressum-URL** und **Datenschutz-URL** (Pflicht, https). +- Bestätigung: Haken „Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise + verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.“ (Zeit, Person, Hash der Texte gespeichert; + jede Änderung der Texte hebt die Bestätigung auf und schließt den Shop nicht, verlangt aber neue Bestätigung vor der + nächsten Öffnung — Festlegung: geöffnet bleibt geöffnet, Banner „bitte neu bestätigen“; neue Bestellungen werden mit + der zuletzt bestätigten Fassung geschlossen, siehe 5.2). + +**Shop geöffnet** (serverseitig bei jeder Bestellung geprüft): Funktion `active` · `geoeffnet` · Stripe `charges_enabled` · +bestätigte Rechtstexte vorhanden · Artikel lieferbar. Fällt ein Punkt weg, zeigt das Widget „Der Shop ist gerade +geschlossen“ und nimmt keine Bestellungen an. + +--- + +## 5. Shop auf der Website (Widget `P/public/embed/shop.js`) + +### 5.1 Einbau +`
` + `` +(Muster News/Termine). Eigene Styles mit Präfix/Shadow-DOM nach Vorbild Termine, Akzentfarbe aus den Einstellungen. +Ansichten: Kategorien + Artikelraster, Artikeldetail (Bilder, Beschreibung, Preis, Grundpreis, Varianten, Lieferhinweis, +Widerrufsausschluss, Produktsicherheit, Lebensmittelangaben), Warenkorb (im `localStorage` des Besuchers, nur Artikel-IDs +und Mengen; Begründung: technisch notwendig, § 25 Abs. 2 Nr. 2 TDDDG), Kasse, Danke-Seite, Widerrufsformular. Ganz oben +im Widget (Beginn des Bestellvorgangs, § 312j Abs. 1 BGB): Lieferländer und akzeptierte Zahlungsarten. Fußzeile immer: +AGB · Widerrufsbelehrung · Versand & Zahlung · **Vertrag widerrufen** · Impressum · Datenschutz. + +### 5.2 Kasse und Vertragsschluss (W9) +Die verbindliche Bestellung geschieht **im Widget** mit dem Knopf **„Zahlungspflichtig bestellen“** (§ 312j Abs. 3 BGB); +Stripe Checkout ist danach nur noch die Zahlung. Unmittelbar über dem Knopf (§ 312j Abs. 2, Art. 246a § 1 EGBGB): +Positionen mit wesentlichen Merkmalen, Einzel- und Gesamtpreis inkl. USt (bzw. Kleinunternehmer-Hinweis), Versandkosten, +Gesamtsumme, Lieferart, Lieferzeit, Zahlungsarten, Hinweis auf Widerrufsrecht bzw. dessen Ausschluss je Artikel, Link +AGB und Widerrufsbelehrung, Anbieter-Identität. Lieferart wählt der Käufer im Widget; Name, E-Mail, Telefon (optional) +und Lieferadresse sammelt Stripe Checkout (`shipping_address_collection` nur bei Versand, erlaubte Länder aus den +Einstellungen). Korrektur von Eingabefehlern (§ 312i Abs. 1 Nr. 1): Warenkorb und Lieferart sind bis zum Knopf änderbar, +Adresse in Stripe bis zur Zahlung. + +Server (`POST /api/public/shop/:siteKey/bestellung`): prüft „Shop geöffnet“, rechnet alle Preise aus der Datenbank neu +(nie aus dem Browser), prüft und **reserviert** Bestand (offene Bestellung zählt bis `ablaufAt`), legt `shopOrders` mit +Status `offen` an (Schnappschuss der Positionen und der bestätigten Rechtstexte-Fassung), erstellt die Checkout-Session +**auf dem verbundenen Konto** (`stripeAccount`): `mode: payment`, `price_data` je Position + Versandkosten als +`shipping_options` (fester Betrag aus dem Server), `payment_method_types` aus den Einstellungen, `locale: 'de'`, +`expires_at` = jetzt + 30 min, `client_reference_id` = Bestell-ID, `metadata` {shopId, orderId, bestellnummer}, +`customer_creation: 'if_required'`, `phone_number_collection`. `success_url` = Seite des Kunden mit +`?wk_bestellung=&wk_t=` (nur erlaubte Hosts des Projekts, sonst Standalone-Seite), `cancel_url` = Warenkorb. +Unter einer Sperre je Shop (`withLocks`), damit zwei gleichzeitige Käufe das letzte Stück nicht doppelt reservieren. + +### 5.3 Zahlung, Bestätigung, Verlauf +- **Connect-Webhook** `POST /api/shop/stripe-webhook` (eigenes Signatur-Geheimnis `STRIPE_CONNECT_WEBHOOK_SECRET`, + Endpunkt mit `connect: true`): `checkout.session.completed` (bei `payment_status: paid`), + `checkout.session.async_payment_succeeded` → `bezahlt`, Bestand endgültig abziehen, Käuferdaten aus der Session + speichern, **G09 Bestellbestätigung** an den Käufer, **K25 Neue Bestellung** an den Händler. + `async_payment_failed` / `expired` → `storniert`, Reservierung frei (G14 nur bei fehlgeschlagener Zahlung). + `account.updated` → `stripeStatus`. `charge.refunded` → Erstattung aus dem Stripe-Dashboard nachtragen. + Alles idempotent (Status-Übergänge nur vorwärts, Sperre je Bestellung). +- **Danke-Seite** fragt `GET /api/public/shop/:siteKey/bestellung/:id?t=`; ist die Bestellung noch `offen`, holt der + Server die Session bei Stripe und schließt selbst ab (gleicher Code wie der Webhook) — fällt der Webhook aus, geht + trotzdem nichts verloren. +- **G09 Bestellbestätigung** (dauerhafter Datenträger, § 312f Abs. 2 BGB): Anbieter, Bestellnummer, Positionen, + Preise, Versand, Gesamt, Lieferart/-zeit, Lieferadresse, Zahlungsart, Hinweis auf Widerrufsausschlüsse, vollständige + AGB, vollständige Widerrufsbelehrung und Muster-Widerrufsformular (im Mailtext), Link „Vertrag widerrufen“. + Absender: WEBklar-Versandadresse mit Anzeigename „“, Reply-To = E-Mail des Händlers. + +### 5.4 Standalone-Seite und Widerrufsfunktion (W10, § 356a BGB) +- `https://project.webklar.com/shop/` zeigt das Widget auf einer neutralen Seite (Vorschau, Rücksprung-Ersatz). +- `https://project.webklar.com/shop//widerruf` und die Widget-Ansicht „Vertrag widerrufen“: Schaltfläche + „Vertrag widerrufen“ führt zu einem Formular (Name, E-Mail, Bestellnummer, optional Erklärung/Artikel), danach + Bestätigungsschaltfläche **„Widerruf bestätigen“**. Eingang wird gespeichert, **G12 Eingangsbestätigung** mit Inhalt, + Datum und Uhrzeit sofort an den Käufer (dauerhafter Datenträger), **K26** an den Händler. Erreichbar ohne Login, auch + ohne passende Bestellung (der Händler klärt), Missbrauchsschutz per Rate-Limit. + +--- + +## 6. Bestellung der Funktion bei WEBklar (Register-Eintrag `shop`) + +Registereintrag nach Muster `termine`: `product: 'shopSetup'` (29900 Cent, Lookup-Key `webklar_shop_einrichtung_29900`), +`priceLabel '299 €'`, `setupEstimate 'meist innerhalb von 2–3 Wochen'`, `requiresWartungsAbo: false`, +`ticketFreischaltung: true`, `returnHash 'webshop'`, `returnProjekt: true`, Haken: +- `verfuegbar(email)`: Schalter `SHOP_ENABLED` / Pilotliste. +- `bestellpruefung`: Anfrage des Projekts ist `angenommen` (sonst offener Punkt „Anfrage noch nicht freigegeben“). +- `unterlagenBereit`: Leistungsbeschreibung + AVV Webshop der aktuellen Fassung abrufbar (HTML + PDF, sha256). +- `belehrung`: die gemeinsame Widerrufsbelehrung der Funktionen. +- `freischaltbar(doc)`: wie News (Ticket geschlossen genügt). + +Zustimmungen (alle Haken Pflicht, Fassung = `config.shop.fassung`): +1. **unterlagen**: Leistungsbeschreibung Webshop und AVV Webshop in der Fassung X. +2. **verantwortung**: „Ich verkaufe als Unternehmer in eigenem Namen. Für Produkte, Preise, Produktangaben und meine + Rechtstexte bin ich verantwortlich. Ich verkaufe keine ausgeschlossenen Waren (Leistungsbeschreibung Nr. 3).“ +3. **stripe**: „Zahlungen laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält kein Geld + meiner Käufer.“ +4. **beginn**: vorzeitiger Beginn, Erlöschen (§ 356 Abs. 5 Nr. 2 BGB), Wertersatz (§ 357a Abs. 2 BGB) – Wortlaut wie + Terminplaner. +5. **information**: Pflichtinformationen (Leistung, 299 € einmalig ohne USt nach § 19 UStG, Dauer, Laufzeit „solange die + Website bei WEBklar läuft“), Widerrufsbelehrung mit Muster-Formular gelesen. + +Ticket: Betreff „Webshop einbauen – “, Details: siteKey, Snippet, Domain, Anfrage (Warenart, Versand/Abholung, +Artikelzahl), Schritte (Shop-Seite anlegen, Snippet einsetzen, Menüpunkt, Fußzeilen-Link „Vertrag widerrufen“ auf allen +Seiten, Datenschutz-Absatz Webshop einfügen, Impressum prüfen, Testkauf im Stripe-Testmodus entfällt → Vorschau prüfen), +Verweis WOMS. Einbauanleitung `docs/funktionen/shop-einbau.md`. + +--- + +## 7. WOMS + +Funktionen-Panel des Kunden: Webshop-Zeile mit Anfrage (Inhalt, Zeit) und Knöpfen **„Anfrage annehmen“** / +**„Ablehnen“** (Begründung Pflicht) → Portal-Admin-Route; Zustand, Ticket, Stripe-Status (nur lesen), Kennzahlen. +Aktivieren/Abschalten über die vorhandenen generischen Aktionen. „Rechtliches“: Karten Leistungsbeschreibung Webshop und +AVV Webshop. + +--- + +## 8. Mails + +Neue IDs (nächste freie laut Test-Liste; Zählung im Plan geprüft): **K24** „Webshop-Anfrage: Entscheidung“ (Varianten +angenommen/abgelehnt), **K25** „Neue Bestellung im Webshop“ (an Händler), **K26** „Widerruf im Webshop eingegangen“ (an +Händler), **T21** „Neue Webshop-Anfrage“ (Team). Käufer-Mails **G09** Bestellbestätigung, **G10** versendet, **G11** +abholbereit, **G12** Eingangsbestätigung Widerruf, **G13** Erstattung, **G14** Zahlung fehlgeschlagen — eigene Vorlagen +im Stil der G-Vorlagen des Terminplaners (Absender WEBklar, Anzeigename Shop, Reply-To Händler), über die vorhandene +Gast-Mail-Warteschlange, falls wiederverwendbar (Plan), sonst eigene kleine Warteschlange mit Wiederholung. +K15/K16/T16 bekommen ihre Texte aus dem Register (vorhanden). + +--- + +## 9. Datenschutz und Aufbewahrung + +- Rollen: Händler = Verantwortlicher für Käuferdaten; WEBklar = Auftragsverarbeiter (AVV Webshop, Hetzner als + Unterauftragsverarbeiter, Mailversand über Hetzner); Stripe = eigener Vertragspartner des Händlers (nicht + Unterauftragsverarbeiter von WEBklar). Gespeichert werden nur Bestelldaten, die für Abwicklung und Nachweis nötig sind. +- Aufbewahrung (W11): personenbezogene Käuferdaten (Name, E-Mail, Telefon, Adressen) einer Bestellung werden **3 Jahre + nach Ende des Bestelljahres** anonymisiert (Gewährleistung 2 Jahre + Puffer); Beträge, Positionen, Bestellnummer + bleiben für die Buchhaltung des Händlers (dessen Aufbewahrungspflicht deckt der Händler über Stripe-Belege und eigene + Unterlagen; er kann jederzeit CSV exportieren). Unbezahlte/stornierte Bestellungen: Käuferdaten nach 30 Tagen weg. + Widerrufe: wie die Bestellung. Wird der Shop abgeschaltet: 90 Tage Lesen + Export, dann Löschung aller Shopdaten + (Bilder eingeschlossen). Job im bestehenden Löschlauf (`retention.js`). +- Portal-Datenschutzerklärung: neuer Abschnitt „Webshop“ (Stripe Connect: Weitergabe von E-Mail/Website des Kunden an + Stripe zur Kontoeröffnung, Abruf des Kontostatus; Verarbeitung als Auftragsverarbeiter). +- Muster-Datenschutzabsatz für die Website des Händlers (`docs/funktionen/shop-datenschutz-absatz.md`): Bestellabwicklung, + WEBklar als Auftragsverarbeiter, Stripe (inkl. PayPal/Klarna über Stripe), Warenkorb im Browser-Speicher. + +--- + +## 10. Rechtstexte von WEBklar (neu, Fassung `2026-10-05`) + +1. **Leistungsbeschreibung Webshop** (`public/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html` + PDF): + Leistung (Anfrage, Einrichtung, Einbau, Betrieb des Widgets, Verwaltung im Portal), ausgeschlossene Waren, + Voraussetzungen (eigenes Stripe-Konto, Unternehmer, eigene Rechtstexte), keine Rechtsberatung/Muster ohne Gewähr, + Verfügbarkeit (keine Zusage einer bestimmten Verfügbarkeit über das Hosting hinaus), Haftung (Begrenzung wie + bestehende LB; keine Haftung für Inhalte, Preise und Rechtstexte des Händlers), Preis 299 € einmalig, Laufzeit/Ende + (mit dem Website-Betrieb; Abschalten, 90 Tage Export, Löschung), Änderungen. +2. **AVV Webshop** (Geschwister AVV Terminplaner): Gegenstand, Art der Daten (Käufer: Name, E-Mail, Telefon, Liefer- und + Rechnungsadresse, Bestellung, Widerrufe), Betroffene (Käufer), Unterauftragsverarbeiter Hetzner, Löschfristen genau wie + gebaut, TOM-Anlage, Weisungen über das Portal. +3. Gemeinsame **Widerrufsbelehrung Funktionen** bleibt (bestehende Datei der aktuellen Fassung). +4. **Kanzlei-Fragen** `docs/rechtliches/webshop-kanzlei-paket.md` (Muster-AGB, Muster-Widerrufsbelehrung inkl. 356a, + Zustimmungstexte, LB-Haftung, Rolle Plattform/ZAG) – vor `on`, nicht vor `pilot`. + +--- + +## 11. Livegang (je Schritt mit Freigabe des Inhabers; erst nach TP2-Livegang) + +1. Stripe-Dashboard WEBklar: **Connect aktivieren** (Plattformprofil: „Plattform, Verkäufer verkaufen eigene Waren, + Direct Charges, keine Gebühr“), Branding (Name/Icon/Farbe für das Onboarding). +2. `node scripts/shop-setup.mjs` (Collections) und `scripts/stripe-products.mjs --anlegen` (shopSetup). +3. Connect-Webhook anlegen (`scripts/shop-setup.mjs --webhook` oder Dashboard, Ereignisse aus 5.3), Geheimnis als + `STRIPE_CONNECT_WEBHOOK_SECRET` in `/home/webklar/apps/.env`, Compose-Zeilen `SHOP_ENABLED`, `SHOP_PILOT_EMAILS`. +4. Push `feature/webshop` → `main` (nach TP2), WOMS-Zweig → `test`. +5. Pilot mit Testkonto: Anfrage, Freigabe, Bestellung (Test-Coupon), Einbau auf `test-website`, Stripe-Konto im + Testmodus? (Live-Konto des Pilots; Testkauf 1 € mit Erstattung), Widerruf testen. +6. Kanzlei-Paket → Befund einarbeiten → `SHOP_ENABLED=on`. + +--- + +## 12. Entscheidungen (Claude, 2026-10-05, ohne Rückfrage auf Wunsch des Inhabers) + +| Nr. | Entscheidung | Begründung | +|---|---|---| +| W1 | Nur Stripe; PayPal/Klarna/Wallets über Stripe Checkout | eine Integration, gleiche Zahlungsarten | +| W2 | Stripe Connect, eigenes Konto des Händlers, Direct Charges, keine Plattformgebühr | kein Fremdgeld bei WEBklar (ZAG), Händler hat volles Dashboard | +| W3 | Schalter `SHOP_ENABLED` off/pilot/on | wie Terminplaner | +| W4 | „Auf Anfrage“ = Anfrage → Team-Freigabe → Bestellung | Prüfung erlaubter Waren, Wunsch des Inhabers | +| W5 | 299 € einmalig, kein Abo nötig, keine laufende Gebühr | „einmalige Kostensache“ | +| W6 | Verwaltung ab `setup` nutzbar | Shop ist zum Einbau fertig befüllt | +| W7 | Bestellnummer zufällig `JJ-XXXXXX` | keine Zählung/Sperre nötig | +| W8 | Händler-Rechtstexte als Muster + Pflichtbestätigung, keine Rechtsberatung | RDG, Verantwortung beim Verkäufer | +| W9 | Vertragsschluss im Widget mit „Zahlungspflichtig bestellen“, Stripe nur Zahlung | § 312j Abs. 3 BGB sicher erfüllt | +| W10 | Widerrufsfunktion (§ 356a BGB) im Widget + Standalone-Seite | seit 19.06.2026 Pflicht | +| W11 | Käuferdaten 3 Jahre nach Bestelljahr anonymisieren, Shopdaten 90 Tage nach Abschalten löschen | Datensparsamkeit, Gewährleistung | +| W12 | Versand nur DE/AT, keine digitalen Produkte, keine Gutscheine | Umfang V1, Recht (digitale Inhalte, Mehrzweckgutscheine) |