Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-05-webshop.md
T
2026-10-05 08:26:40 +00:00

45 KiB
Raw Blame History

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/<datei>.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 || <cwd>/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<schluessel, menge> (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 – <Projekt>', 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, <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.'. server/services/shop/ticketDetails.js (neu): Kopf (Funktion, Projekt, Domain, Kunde, siteKey), Anfrage-Kurzfassung (Warenart, Versand/Abholung, Artikelzahl; aus shopConfig, Lesefehler ⇒ ohne), Snippet <div data-webklar-shop="<siteKey>"></div> + <script src="https://project.webklar.com/embed/shop.js" defer></script>, Schritte (Shop-Seite + Menüpunkt anlegen, Snippet einsetzen, auf allen Seiten im Footer Link „Vertrag widerrufen“ → https://project.webklar.com/shop/<siteKey>/widerruf, Datenschutz-Absatz Webshop (docs/funktionen/shop-datenschutz-absatz.md) einfügen, Impressum prüfen, Vorschau https://project.webklar.com/shop/<siteKey> 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=<id> 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/<siteKey>/bild/<imageId>-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/<siteKey>/, 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=<id>, refresh ...?stripe=neu&projekt=<id>. 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 <nr>' }, success_url = geprüfte Rück-URL + wk_bestellung=<id>&wk_t=<token>, 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/<siteKey>. 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:<orderId>; 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, <section class="kb-view" id="view-webshop"> 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/<siteKey>, 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/<details> 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.