Webshop: Design und Umsetzungsplan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af63b69ec1
commit
b168b10485
2 files changed
+686
No files matched your search
@@ -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/<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 <F> 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 (<ustHinweis>), 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.
|
||||
Reference in new issue
Block a user