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.
|
||||
@@ -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/<siteKey>/<id>.<ext>` (gitignored, wie News-Fotos), ausgeliefert über
|
||||
die öffentliche API.
|
||||
|
||||
Bestellnummer (W7): `<JJ>-<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
|
||||
`<div data-webklar-shop="<siteKey>"></div>` + `<script src="https://project.webklar.com/embed/shop.js" defer></script>`
|
||||
(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=<id>&wk_t=<token>` (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=<token>`; 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 „<Shopname>“, Reply-To = E-Mail des Händlers.
|
||||
|
||||
### 5.4 Standalone-Seite und Widerrufsfunktion (W10, § 356a BGB)
|
||||
- `https://project.webklar.com/shop/<siteKey>` zeigt das Widget auf einer neutralen Seite (Vorschau, Rücksprung-Ersatz).
|
||||
- `https://project.webklar.com/shop/<siteKey>/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 – <Projekt>“, 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) |
|
||||
Reference in new issue
Block a user