Webshop A1/A2: Konfiguration, Schalter, Katalog shopSetup, Schema und Setup-Skript
config.shop (SHOP_ENABLED, SHOP_PILOT_EMAILS, SHOP_UPLOAD_DIR, STRIPE_CONNECT_WEBHOOK_SECRET), config.shopSetup (299 € einmalig), Collection-IDs shopConfig/shopProducts/shopOrders/shopWiderrufe, services/shop/schalter.js, Katalogschluessel shopSetup in stripeBilling und stripe-products.mjs, services/shop/schema.js, scripts/shop-setup.mjs (--check), schemaKeys fuer die Test-Mocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b168b10485
commit
e9ab15bdb5
11 files changed
+759
-6
No files matched your search
@@ -30,6 +30,7 @@ function nonNegativeNumber(raw, fallback) {
|
||||
}
|
||||
|
||||
const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
|
||||
const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase()
|
||||
|
||||
// Abo-Produkte (Spec 2026-09-27 "Verträge, Hosting-Abo und E-Mail-Zusatzspeicher"). Preise in Cent,
|
||||
// Endpreise gemäß § 19 UStG. Texte, die Kunden sehen, stimmen mit den Vertragsbedingungen überein.
|
||||
@@ -116,6 +117,16 @@ const termineSetup = {
|
||||
priceNickname: 'Terminplaner-Einrichtung 149 €',
|
||||
}
|
||||
|
||||
// Webshop: einmalige Einrichtung (Einmalpreis, kein Abo); der Shop selbst verkauft ueber das eigene Stripe-Konto des Kunden (Connect)
|
||||
const shopSetup = {
|
||||
key: 'shopSetup',
|
||||
name: 'Webshop – Einrichtung',
|
||||
amountCents: 29900,
|
||||
lookupKey: 'webklar_shop_einrichtung_29900',
|
||||
oneTime: true,
|
||||
priceNickname: 'Webshop-Einrichtung 299 €',
|
||||
}
|
||||
|
||||
// Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut
|
||||
const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '')
|
||||
|
||||
@@ -396,6 +407,11 @@ export const config = {
|
||||
termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings',
|
||||
termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims',
|
||||
termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox',
|
||||
// Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe; scripts/shop-setup.mjs)
|
||||
shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig',
|
||||
shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts',
|
||||
shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders',
|
||||
shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe',
|
||||
},
|
||||
|
||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||
@@ -412,6 +428,7 @@ export const config = {
|
||||
mailStorage,
|
||||
newsSetup,
|
||||
termineSetup,
|
||||
shopSetup,
|
||||
ustHinweis,
|
||||
// Register der kaufbaren Funktionen (siehe oben)
|
||||
features,
|
||||
@@ -641,6 +658,36 @@ export const config = {
|
||||
minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300),
|
||||
},
|
||||
|
||||
// Webshop (Funktionsschluessel 'shop'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur Laufzeit);
|
||||
// Schalter siehe server/services/shop/schalter.js
|
||||
shop: {
|
||||
// off (Vorgabe) | pilot (nur SHOP_PILOT_EMAILS) | on (alle Kunden)
|
||||
mode: ['pilot', 'on'].includes(shopEnabled) ? shopEnabled : 'off',
|
||||
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
|
||||
pilotEmails: (process.env.SHOP_PILOT_EMAILS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
// Fassung der Webshop-Bestellunterlagen (Leistungsbeschreibung, AVV, Bestelltexte): Berliner Kalendertag des
|
||||
// Fassungs-Commits (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben
|
||||
fassung: '2026-10-05',
|
||||
// Artikelbilder der Shops: <uploadDir>/<shopId>/<datei>
|
||||
uploadDir: process.env.SHOP_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'shop'),
|
||||
// Signing-Secret des Stripe-Connect-Webhooks (Ereignisse der verbundenen Konten); leer = Webhook antwortet 503
|
||||
connectWebhookSecret: process.env.STRIPE_CONNECT_WEBHOOK_SECRET || '',
|
||||
// Eine unbezahlte Bestellung haelt den Bestand (und die Stripe-Checkout-Sitzung) so lange
|
||||
bestellAblaufMinuten: 30,
|
||||
maxArtikel: 500,
|
||||
maxBilder: 4,
|
||||
maxVarianten: 30,
|
||||
// Bestellungen werden so viele Jahre nach Abschluss anonymisiert (Aufbewahrungsfrist), siehe Block J
|
||||
anonymisierenJahre: 3,
|
||||
// Tage nach Abschalten der Funktion bis zur Loeschung der Shop-Daten
|
||||
loeschTageNachAbschalten: 90,
|
||||
// Unbezahlte (offene/stornierte) Bestellungen werden nach so vielen Tagen geloescht
|
||||
unbezahltLoeschTage: 30,
|
||||
},
|
||||
|
||||
invoiceNinja: {
|
||||
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
|
||||
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { config } from '../../config.js'
|
||||
|
||||
/**
|
||||
* Webshop-Schalter (Funktionsschluessel 'shop'): SHOP_ENABLED = off (Vorgabe) | pilot | on.
|
||||
* Liest config.shop bei jedem Aufruf. Bei off laufen keine Shop-Jobs und keine Shop-Abfragen.
|
||||
*/
|
||||
|
||||
/** 'off' | 'pilot' | 'on'; alles andere (leer, unbekannt) gilt als off. */
|
||||
export function shopModus() {
|
||||
const mode = config.shop.mode
|
||||
return mode === 'pilot' || mode === 'on' ? mode : 'off'
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf dieser Kunde den Webshop nutzen? pilot = nur Adressen aus SHOP_PILOT_EMAILS ('*' = alle; eine leere
|
||||
* Liste laesst niemanden durch); on = alle Kunden; off = niemand.
|
||||
*/
|
||||
export function shopFuerKunde(email) {
|
||||
const modus = shopModus()
|
||||
if (modus === 'off') return false
|
||||
if (modus === 'on') return true
|
||||
const erlaubt = config.shop.pilotEmails
|
||||
if (erlaubt.includes('*')) return true
|
||||
const adresse = String(email || '').trim().toLowerCase()
|
||||
return adresse !== '' && erlaubt.includes(adresse)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Webshop: Appwrite-Schema der vier Collections (Spezifikation 2026-10-05-webshop-design.md).
|
||||
* Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor useTestEnv(),
|
||||
* ein frueh geladenes config.js wuerde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs stehen in
|
||||
* config.collections.<Name>; scripts/shop-setup.mjs legt alles an. Kein Feld heisst data (appwriteAdmin.formatRequestBody
|
||||
* haelt Bodies mit data fuer fertig verpackt), daher heissen die JSON-Felder einstellungen, rechtstexte, produkt, bestellung.
|
||||
*
|
||||
* Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false }
|
||||
* Index: { key, type: 'key', attributes: [...] }
|
||||
* Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurueck, daher immer mit new Date() lesen.
|
||||
*/
|
||||
|
||||
const str = (key, size) => ({ key, type: 'string', size, required: false })
|
||||
const int = (key) => ({ key, type: 'integer', required: false })
|
||||
const bool = (key) => ({ key, type: 'boolean', required: false })
|
||||
const dt = (key) => ({ key, type: 'datetime', required: false })
|
||||
const index = (key, ...attributes) => ({ key, type: 'key', attributes })
|
||||
|
||||
export const SHOP_COLLECTIONS = {
|
||||
// Ein Shop je Funktion; Dokument-ID = ID des portalFeatures-Dokuments. Anfrage, Stripe-Konto, Einstellungen und Rechtstexte
|
||||
// als JSON-Text; die grossen Spalten werden in Appwrite Textspalten und zaehlen nicht zur Zeilenbreite.
|
||||
shopConfig: {
|
||||
attributes: [
|
||||
str('customerId', 36),
|
||||
str('projectId', 36),
|
||||
str('siteKey', 16),
|
||||
str('anfrage', 4000),
|
||||
str('anfrageStatus', 16),
|
||||
dt('anfrageAt'),
|
||||
dt('anfrageEntschiedenAt'),
|
||||
str('anfrageEntschiedenVon', 255),
|
||||
str('anfrageBegruendung', 1000),
|
||||
str('stripeAccountId', 64),
|
||||
str('stripeStatus', 4000),
|
||||
str('einstellungen', 20000),
|
||||
str('rechtstexte', 100000),
|
||||
dt('rechtstexteBestaetigtAt'),
|
||||
str('rechtstexteBestaetigtVon', 255),
|
||||
str('rechtstexteHash', 64),
|
||||
bool('geoeffnet'),
|
||||
dt('geoeffnetAt'),
|
||||
int('version'),
|
||||
],
|
||||
indexes: [
|
||||
index('idx_customer', 'customerId'),
|
||||
index('idx_sitekey', 'siteKey'),
|
||||
index('idx_anfrage', 'anfrageStatus'),
|
||||
index('idx_account', 'stripeAccountId'),
|
||||
],
|
||||
},
|
||||
|
||||
// Ein Artikel je Dokument (ID randomUUID); Name und Sortierung stehen zusaetzlich als Spalten, der Rest im JSON-Text produkt
|
||||
shopProducts: {
|
||||
attributes: [str('shopId', 36), str('customerId', 36), bool('aktiv'), int('sort'), str('name', 120), str('produkt', 30000)],
|
||||
indexes: [index('idx_shop', 'shopId', 'sort')],
|
||||
},
|
||||
|
||||
// Eine Bestellung je Dokument; enthaelt personenbezogene Daten des Kaeufers (Aufbewahrung und Anonymisierung: Block J)
|
||||
shopOrders: {
|
||||
attributes: [
|
||||
str('shopId', 36),
|
||||
str('customerId', 36),
|
||||
str('bestellnummer', 16),
|
||||
str('status', 16),
|
||||
str('tokenHash', 64),
|
||||
str('stripeSessionId', 128),
|
||||
str('paymentIntentId', 64),
|
||||
int('summeCent'),
|
||||
dt('erstelltAt'),
|
||||
dt('ablaufAt'),
|
||||
dt('bezahltAt'),
|
||||
str('kaeuferEmail', 320),
|
||||
str('bestellung', 60000),
|
||||
dt('anonymisiertAt'),
|
||||
],
|
||||
indexes: [
|
||||
index('idx_shop_status', 'shopId', 'status'),
|
||||
index('idx_nummer', 'shopId', 'bestellnummer'),
|
||||
index('idx_session', 'stripeSessionId'),
|
||||
index('idx_erstellt', 'shopId', 'erstelltAt'),
|
||||
],
|
||||
},
|
||||
|
||||
// Widerruf von Vertraegen (§ 356a BGB) ueber die oeffentliche Widerrufsseite
|
||||
shopWiderrufe: {
|
||||
attributes: [
|
||||
str('shopId', 36),
|
||||
str('customerId', 36),
|
||||
str('bestellnummer', 32),
|
||||
str('name', 200),
|
||||
str('email', 320),
|
||||
str('nachricht', 2000),
|
||||
dt('eingangAt'),
|
||||
dt('bestaetigungAt'),
|
||||
dt('bearbeitetAt'),
|
||||
str('orderId', 36),
|
||||
],
|
||||
indexes: [index('idx_shop', 'shopId', 'eingangAt')],
|
||||
},
|
||||
}
|
||||
|
||||
/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */
|
||||
export const SHOP_DATETIMES = Object.fromEntries(
|
||||
Object.entries(SHOP_COLLECTIONS).map(([name, { attributes }]) => [
|
||||
name,
|
||||
attributes.filter((attribute) => attribute.type === 'datetime').map((attribute) => attribute.key),
|
||||
]),
|
||||
)
|
||||
@@ -58,6 +58,7 @@ const PRODUCT_METADATA = {
|
||||
mailStorage: 'mail-speicher',
|
||||
newsSetup: 'news-einrichtung',
|
||||
termineSetup: 'termine-einrichtung',
|
||||
shopSetup: 'shop-einrichtung',
|
||||
}
|
||||
|
||||
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, Zusatzspeicher und Einrichtungen aus config. */
|
||||
@@ -65,13 +66,14 @@ function catalogFor(key) {
|
||||
if (key === 'mailStorage') return config.mailStorage
|
||||
if (key === 'newsSetup') return config.newsSetup
|
||||
if (key === 'termineSetup') return config.termineSetup
|
||||
if (key === 'shopSetup') return config.shopSetup
|
||||
return getProduct(key)
|
||||
}
|
||||
|
||||
const priceIdCache = new Map()
|
||||
/**
|
||||
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
|
||||
* 'mailStorage' | 'newsSetup' | 'termineSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
|
||||
* 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
|
||||
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
|
||||
*/
|
||||
export async function ensurePrice(key) {
|
||||
|
||||
Reference in new issue
Block a user