diff --git a/.env.example b/.env.example index 720bd6a..3b863bd 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,27 @@ STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx # --- MCP-Server (mcp/) --- # HDC_BASE_URL=https://shop.example.com # HDC_API_TOKEN= (dasselbe Token wie oben) + +# --- Zahlung: Provider-Abstraktion (v2.2) --- +# Provider explizit wählen: mollie | stripe | demo. Leer => Auto-Wahl nach vorhandenen Keys. +PAYMENT_PROVIDER= +# Mollie (Default-Anbieter). Test- oder Live-Key. Ohne gültigen Key läuft der Demo-Fallback. +MOLLIE_API_KEY= +# (Stripe-Keys siehe oben — werden weiterhin unterstützt.) + +# --- E-Mail-Versand (v2.2) --- +# Provider: listmonk | smtp | (leer => Log-Fallback in DB-Tabelle email_log) +MAIL_PROVIDER= +# Absenderadresse für ausgehende Mails +MAIL_FROM=shop@example.com +# Listmonk (Transactional-API /api/tx) +LISTMONK_URL=https://listmonk.example.com +LISTMONK_USER= +LISTMONK_PASS= +LISTMONK_TX_TEMPLATE_ID= +# SMTP (alternativ, via nodemailer) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_SECURE=false diff --git a/README.md b/README.md index 7342c82..543d3d7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb - **Visual-Block-Builder**: Vollbild-Editor mit Block-Liste (Drag/▲▼/duplizieren/löschen), Live-Vorschau (Desktop/Mobil) und Block-Einstellungen. Block-Typen: Hero, Rich-Text, Bild, Galerie, Slider, Feature-Grid, Produkt-Grid, CTA-Banner, Abstand, Roh-HTML. - **KI-Editierbarkeit**: token-gesicherte Admin-JSON-API (`/api/admin/*`) plus maschinenlesbares Manifest (`/api/admin`, `/ai-admin.txt`) und ein **MCP-Server** (`mcp/`). - **Gutschein-/Rabatt-Engine** (v2.1): Codes vom Typ `percent` / `fixed` / `freeshipping` mit Zeitplan, Mindestbestellwert, Gesamt- und Pro-Kunde-Limit, „geheim" (nicht öffentlich listbar) und „automatisch" (greift ohne Code, wenn Bedingungen erfüllt). Admin-Bereich **Rabatte** (Owner/Redaktion) mit Status-Badges (Aktiv/Geplant/Abgelaufen/Aufgebraucht/Inaktiv); Storefront-Einlösung im Checkout über `/api/discount`; serverseitige Re-Validierung in `/api/checkout`; Stripe-Coupon-Anbindung. Popups können einen Code anzeigen (+ Kopieren-Button) — auch für gezielt verteilte geheime Codes; Popup-Stile `modal` / `slidein` / `bar`. +- **Verkaufsfertig-Fundament (v2.2):** + - **Payment-Abstraktion** (`src/lib/payments.js`): einheitliche Schnittstelle für **Mollie** (Default, REST), **Stripe** und **Demo**. Provider via Setting `payment_provider` / ENV `PAYMENT_PROVIDER`, sonst Auto-Wahl nach vorhandenen Keys. Mollie-Webhook unter `/api/payments/webhook`; ungültiger Key ⇒ sauberer Demo-Fallback statt Fehler. + - **DACH-Recht**: MwSt-Ausweis pro Produkt (`mwst` 0/7/19), **Grundpreis** (PAngV) über `base_amount`/`base_unit`/`base_price_per`; Warenkorb/Checkout zeigen Zwischensumme, enthaltene MwSt (nach Satz gruppiert) und **Versand vor dem Bezahlen**. + - **Versandzonen** (Tabelle `shipping_zones`, Admin „Versand"): länderbasierte Preise mit Gratis-ab-Schwelle; Helper `shippingFor(country, subtotal)`; Checkout berechnet Versand serverseitig neu. + - **Bestell-/Versandmails** (`src/lib/mailer.js`): Provider **Listmonk** (Transactional-API) / **SMTP** (nodemailer) / **Log-Fallback** (Tabelle `email_log`, Admin „E-Mail-Log"). Gebrandete Bestellbestätigung bei bezahlter Bestellung. + - **Feature-Flags**: Module pro Shop abschaltbar (`feature_newsletter`, `feature_accounts`, `feature_reviews`, `feature_wishlist`, `feature_abandoned_cart`, `feature_search`) über Admin → Einstellungen → Module; Helper `feature(key)`. - **Editierbare, gebrandete 404** (v2.1): `src/pages/404.astro` rendert die System-Seite mit Slug `404` über den Block-Builder. Wird per `ensureSystemPages()` bei jedem Boot idempotent angelegt und ist im Admin unter **Inhalte** editierbar. - **Engine**: synchron via `better-sqlite3` (WAL), automatisches Seeding beim ersten Start. - **First-Party-Analytics**: eigene `events`-Tabelle, kein externer Dienst (Session = täglich rollender Hash). @@ -37,6 +43,12 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb | `HDC_API_TOKEN` | Bearer-Token für `/api/admin/*`. Leer ⇒ API gesperrt | – | | `STRIPE_PUBLIC_KEY` | Stripe Publishable Key (optional) | – | | `STRIPE_SECRET_KEY` | Stripe Secret Key. Ohne echten Key läuft der Demo-Checkout. | – | +| `PAYMENT_PROVIDER` | Zahlungsanbieter erzwingen: `mollie` / `stripe` / `demo`. Leer ⇒ Auto-Wahl | – | +| `MOLLIE_API_KEY` | Mollie API-Key (`test_…`/`live_…`). Ohne gültigen Key Demo-Fallback | – | +| `MAIL_PROVIDER` | `listmonk` / `smtp` / leer (⇒ Log-Fallback in `email_log`) | – | +| `MAIL_FROM` | Absenderadresse ausgehender Mails | – | +| `LISTMONK_URL` / `LISTMONK_USER` / `LISTMONK_PASS` / `LISTMONK_TX_TEMPLATE_ID` | Listmonk Transactional-API | – | +| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_SECURE` | SMTP-Versand (nodemailer) | – | Siehe `.env.example`. @@ -92,7 +104,7 @@ Das `Dockerfile` (node:22-slim) baut `better-sqlite3` nativ, legt `/data` an und ## Datenmodell -`settings`, `products`, `orders` (inkl. `discount_code` / `discount_cents`), `customers`, `slides`, `pages` (inkl. `blocks`; System-Seite `404`), `popups` (inkl. `style` / `discount_id`), `discounts`, `discount_redemptions`, `subscribers`, `events`, `media`, `users`, `audit` — alles seed-bar und im Admin pflegbar. +`settings` (inkl. Feature-Flags & `payment_provider`), `products` (inkl. `mwst` / `base_amount` / `base_unit` / `base_price_per`), `orders` (inkl. `discount_code`/`discount_cents`, `tax_cents`/`shipping_cents`/`country`, `payment_provider`/`payment_id`), `customers`, `slides`, `pages` (inkl. `blocks`; System-Seite `404`), `popups` (inkl. `style` / `discount_id`), `discounts`, `discount_redemptions`, `shipping_zones`, `email_log`, `subscribers`, `events`, `media`, `users`, `audit` — alles seed-bar und im Admin pflegbar. --- diff --git a/mcp/server.js b/mcp/server.js index b9db2cc..44c3f73 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -36,12 +36,15 @@ const TOOLS = [ { name: 'list_discounts', description: 'Alle Rabatte/Gutscheine auflisten.', inputSchema: { type: 'object', properties: {} } }, { name: 'upsert_discount', description: 'Rabatt anlegen/aktualisieren. Mit id oder code => Update, sonst Create. type: percent|fixed|freeshipping; value bei percent 1-100, bei fixed in Cent.', inputSchema: { type: 'object', properties: { discount: { type: 'object', description: 'Felder: code, title, type, value, min_order_cents, starts_at, expires_at, max_uses, max_per_customer, active, secret, auto' } }, required: ['discount'] } }, { name: 'delete_discount', description: 'Rabatt löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } }, + { name: 'list_shipping', description: 'Versandzonen auflisten.', inputSchema: { type: 'object', properties: {} } }, + { name: 'upsert_shipping', description: 'Versandzone anlegen/aktualisieren (mit id => Update). Felder: name, countries (CSV ISO / EU), price_cents, free_over_cents, delivery_days, sort, active.', inputSchema: { type: 'object', properties: { zone: { type: 'object' } }, required: ['zone'] } }, + { name: 'delete_shipping', description: 'Versandzone löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } }, { name: 'get_settings', description: 'Shop-Einstellungen (Key/Value) holen.', inputSchema: { type: 'object', properties: {} } }, { name: 'update_settings', description: 'Shop-Einstellungen aktualisieren (Key/Value-Map, z.B. shop_name, brand_accent).', inputSchema: { type: 'object', properties: { settings: { type: 'object' } }, required: ['settings'] } }, { name: 'get_manifest', description: 'API-Manifest (alle Ressourcen, Felder, Block-Typen).', inputSchema: { type: 'object', properties: {} } }, ]; -const server = new Server({ name: 'hd-commerce', version: '2.1.0' }, { capabilities: { tools: {} } }); +const server = new Server({ name: 'hd-commerce', version: '2.2.0' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); server.setRequestHandler(CallToolRequestSchema, async (req) => { @@ -63,6 +66,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { case 'list_discounts': out = await api('GET', '/api/admin/discounts'); break; case 'upsert_discount': out = await api('POST', '/api/admin/discounts', a.discount); break; case 'delete_discount': out = await api('DELETE', '/api/admin/discounts/' + encodeURIComponent(a.id)); break; + case 'list_shipping': out = await api('GET', '/api/admin/shipping_zones'); break; + case 'upsert_shipping': out = await api('POST', '/api/admin/shipping_zones', a.zone); break; + case 'delete_shipping': out = await api('DELETE', '/api/admin/shipping_zones/' + encodeURIComponent(a.id)); break; case 'get_settings': out = await api('GET', '/api/admin/settings'); break; case 'update_settings': out = await api('POST', '/api/admin/settings', a.settings); break; case 'get_manifest': out = await api('GET', '/api/admin'); break; diff --git a/package-lock.json b/package-lock.json index 5528b4d..9d712af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@fontsource-variable/public-sans": "^5.1.0", "astro": "^5.6.0", "better-sqlite3": "^11.8.1", + "nodemailer": "^6.10.1", "stripe": "^17.5.0" } }, @@ -4178,6 +4179,15 @@ "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index ac17de9..6333c9b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hd-commerce", "type": "module", - "version": "2.1.0", + "version": "2.2.0", "private": true, "description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)", "scripts": { @@ -11,11 +11,12 @@ "prebuild": "node ./scripts/sync-css.mjs" }, "dependencies": { - "astro": "^5.6.0", "@astrojs/node": "^9.1.3", - "better-sqlite3": "^11.8.1", - "stripe": "^17.5.0", "@fontsource-variable/fraunces": "^5.1.0", - "@fontsource-variable/public-sans": "^5.1.0" + "@fontsource-variable/public-sans": "^5.1.0", + "astro": "^5.6.0", + "better-sqlite3": "^11.8.1", + "nodemailer": "^6.10.1", + "stripe": "^17.5.0" } } diff --git a/src/components/BlockRenderer.astro b/src/components/BlockRenderer.astro index 15fe2f5..72a3992 100644 --- a/src/components/BlockRenderer.astro +++ b/src/components/BlockRenderer.astro @@ -1,5 +1,5 @@ --- -import { listFeatured, listProducts, listActiveSlides, formatPrice } from '../lib/store.js'; +import { listFeatured, listProducts, listActiveSlides, formatPrice, basePriceLabel } from '../lib/store.js'; export interface Props { blocks?: any[] } const { blocks = [] } = Astro.props; @@ -84,7 +84,7 @@ const galCols = (b) => Math.max(2, Math.min(4, Number(b.columns) || 3)); {productsFor(b).map((p) => (
{p.cardImage && {p.name}}{p.badge && {p.badge}}
-
{p.category}{p.shortName || p.name}{formatPrice(p.priceCents)}
+
{p.category}{p.shortName || p.name}{formatPrice(p.priceCents)}inkl. MwSt.{basePriceLabel(p.priceCents, p) && ' · ' + basePriceLabel(p.priceCents, p)}
))} diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro index d1dae09..71a6e26 100644 --- a/src/layouts/Admin.astro +++ b/src/layouts/Admin.astro @@ -30,6 +30,7 @@ const allNav = [ { key:'marketing', label:'Marketing', href: base + '/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' }, { key:'rabatte', label:'Rabatte', href: base + '/rabatte', icon:'M9 9h.01M15 15h.01M8 21l13-13a2.83 2.83 0 0 0 0-4 2.83 2.83 0 0 0-4 0L4 17a2 2 0 0 0 0 3 2 2 0 0 0 4 1Z' }, { key:'inhalte', label:'Inhalte', href: base + '/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' }, + { key:'versandzonen', label:'Versand', href: base + '/versand', icon:'M3 7h11v8H3V7Zm11 3h4l3 3v2h-7v-5ZM7 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm10 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z' }, ]; const ownerNav = [ { key:'nutzer', label:'Nutzer & Zugänge', href: base + '/nutzer', icon:'M16 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 2c-3 0-6 1.5-6 4v1h7M16 13c-3.3 0-6 1.7-6 4.2V19h12v-1.8c0-2.5-2.7-4.2-6-4.2Z' }, diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index fc15013..7e647c9 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -2,7 +2,7 @@ import '@fontsource-variable/fraunces'; import '@fontsource-variable/public-sans'; import '../styles/global.css'; -import { getSettings, listLegalPages, listCategories, popupsForPath } from '../lib/store.js'; +import { getSettings, listLegalPages, listCategories, popupsForPath, feature } from '../lib/store.js'; export interface Props { title?: string; description?: string; } const { title, description } = Astro.props; @@ -22,7 +22,10 @@ const pageTitle = title ? `${title} · ${shopName}` : shopName; const desc = description || tagline || `${shopName} — Online-Shop`; const path = new URL(Astro.request.url).pathname; -const popups = popupsForPath(path); +let popups = popupsForPath(path); +// Feature-Flag: Newsletter-Popups nur zeigen, wenn Newsletter aktiv ist. +const newsletterOn = feature('feature_newsletter'); +if (!newsletterOn) popups = popups.filter((pp) => pp.type !== 'newsletter'); --- diff --git a/src/lib/admin-api.js b/src/lib/admin-api.js index baa5c3e..d60aa11 100644 --- a/src/lib/admin-api.js +++ b/src/lib/admin-api.js @@ -16,13 +16,14 @@ export function authOk(request) { // ---- Ressourcen-Definitionen für das Manifest ---- export const RESOURCES = { - products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] }, + products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'mwst(0|7|19)', 'base_amount', 'base_unit', 'base_price_per', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] }, pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] }, slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] }, popups: { rw: true, fields: ['title', 'type(newsletter|discount|announcement|exit)', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort', 'style(modal|slidein|bar)', 'discount_id'] }, discounts: { rw: true, fields: ['code', 'title', 'type(percent|fixed|freeshipping)', 'value', 'min_order_cents', 'starts_at', 'expires_at', 'max_uses', 'used_count', 'max_per_customer', 'active', 'secret', 'auto'] }, + shipping_zones: { rw: true, fields: ['name', 'countries(CSV ISO, EU)', 'price_cents', 'free_over_cents', 'delivery_days', 'sort', 'active'] }, settings: { rw: true, fields: ['key/value-Map (shop_name, brand_accent, currency, free_shipping_cents, …)'] }, - orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'items[]', 'address', 'created_at'] }, + orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'tax_cents', 'shipping_cents', 'country', 'items[]', 'address', 'created_at'] }, customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] }, }; @@ -33,6 +34,7 @@ export function listResource(name) { case 'slides': return store.listSlides(); case 'popups': return store.listPopups(); case 'discounts': return store.listDiscounts(); + case 'shipping_zones': return store.listShippingZones(); case 'orders': return store.listOrders(); case 'customers': return store.listCustomers(); case 'settings': return store.getSettings(); @@ -46,6 +48,7 @@ export function getResource(name, id) { case 'slides': return store.getSlideById(id); case 'popups': return store.getPopupById(id); case 'discounts': return store.getDiscountById(id); + case 'shipping_zones': return store.getShippingZoneById(id); case 'orders': return store.getOrderById(id); case 'customers': return store.getCustomerById(id); default: return null; @@ -77,6 +80,10 @@ export function upsertResource(name, body) { if (body.code) { const ex = store.getDiscountByCode(body.code); if (ex) { store.updateDiscount(ex.id, { ...ex, ...body }); return store.getDiscountById(ex.id); } } const id = store.createDiscount(body); return store.getDiscountById(id); } + if (name === 'shipping_zones') { + if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); } + const id = store.createShippingZone(body); return store.getShippingZoneById(id); + } if (name === 'settings') { const entries = body && typeof body === 'object' ? Object.entries(body) : []; for (const [k, v] of entries) store.setSetting(k, v); @@ -92,6 +99,7 @@ export function deleteResource(name, id) { case 'slides': store.deleteSlide(id); return true; case 'popups': store.deletePopup(id); return true; case 'discounts': store.deleteDiscount(id); return true; + case 'shipping_zones': store.deleteShippingZone(id); return true; default: throw new Error('Ressource nicht löschbar: ' + name); } } @@ -114,7 +122,7 @@ export function manifest(origin) { ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' }); return { name: 'hd-commerce Admin API', - version: '2.1.0', + version: '2.2.0', auth: 'Authorization: Bearer ', base_url: origin || '', resources: RESOURCES, @@ -127,6 +135,9 @@ export function manifest(origin) { 'pages.blocks ist ein Array von Blöcken (siehe block_types) für den Visual-Builder.', 'Block-Objekte sind FLACH: { type, : ... } — NICHT unter einem data-Schlüssel verschachtelt.', 'discounts.value: bei percent 1–100, bei fixed in Cent, bei freeshipping ignoriert. Codes werden case-insensitiv geprüft.', + 'shipping_zones.countries: CSV von ISO-Codes (DE, AT,CH) oder "EU" für alle EU-Länder. free_over_cents nullable.', + 'products.mwst: 0, 7 oder 19. base_amount/base_unit/base_price_per ergeben den Grundpreis (PAngV), z. B. 250 + g + kg.', + 'Feature-Flags & payment_provider sind Settings-Keys (über /api/admin/settings setzbar): feature_newsletter, feature_accounts, …, payment_provider (mollie|stripe|demo).', ], }; } diff --git a/src/lib/auth.js b/src/lib/auth.js index c510fb1..b8c96e1 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -75,8 +75,8 @@ export function currentUser(request) { // --- Rollen-Gate --- // owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen const ROLE_SECTIONS = { - owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'einstellungen', 'nutzer', 'audit'], - redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics'], + owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'einstellungen', 'nutzer', 'audit'], + redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen'], versand: ['bestellungen'], }; export function canAccess(role, section) { diff --git a/src/lib/mailer.js b/src/lib/mailer.js new file mode 100644 index 0000000..48535ac --- /dev/null +++ b/src/lib/mailer.js @@ -0,0 +1,120 @@ +// hd-commerce — Mailversand mit Provider-Abstraktion (Listmonk / SMTP / Log-Fallback). +// Ohne konfigurierte Zugangsdaten läuft alles über den Log-Fallback (email_log + console). +import { getSettings, getSetting, logEmail, formatPrice, taxFromGross } from './store.js'; + +export function mailerStatus() { + const provider = (process.env.MAIL_PROVIDER || '').trim().toLowerCase(); + const from = (process.env.MAIL_FROM || '').trim(); + if (provider === 'listmonk') { + const ok = !!(process.env.LISTMONK_URL && process.env.LISTMONK_USER && process.env.LISTMONK_PASS && process.env.LISTMONK_TX_TEMPLATE_ID); + return { provider: 'listmonk', configured: ok, from: from || '(MAIL_FROM fehlt)' }; + } + if (provider === 'smtp') { + const ok = !!(process.env.SMTP_HOST && process.env.SMTP_PORT); + return { provider: 'smtp', configured: ok, from: from || (process.env.SMTP_USER || '') }; + } + return { provider: 'log', configured: true, from: from || getSetting('shop_email', '') || '(Log-Fallback)' }; +} + +async function sendViaListmonk({ to, subject, html, type }) { + const url = String(process.env.LISTMONK_URL || '').replace(/\/+$/, ''); + const auth = 'Basic ' + Buffer.from(`${process.env.LISTMONK_USER}:${process.env.LISTMONK_PASS}`).toString('base64'); + const res = await fetch(url + '/api/tx', { + method: 'POST', + headers: { 'Authorization': auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscriber_email: to, + template_id: Number(process.env.LISTMONK_TX_TEMPLATE_ID), + from_email: process.env.MAIL_FROM || undefined, + data: { subject, html, type }, + }), + }); + if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error('Listmonk ' + res.status + ': ' + t.slice(0, 200)); } + return true; +} + +async function sendViaSmtp({ to, subject, html }) { + const nodemailer = (await import('nodemailer')).default; + const transport = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT) || 587, + secure: String(process.env.SMTP_SECURE || '').toLowerCase() === 'true' || Number(process.env.SMTP_PORT) === 465, + auth: (process.env.SMTP_USER || process.env.SMTP_PASS) ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined, + }); + await transport.sendMail({ from: process.env.MAIL_FROM || process.env.SMTP_USER, to, subject, html }); + return true; +} + +// Einheitlicher Mailversand. Loggt IMMER in email_log (auch bei echtem Versand) für Nachvollziehbarkeit. +export async function sendMail({ to, subject, html, type = 'general' }) { + const st = mailerStatus(); + let status = 'logged', provider = st.provider; + if (st.provider === 'listmonk' && st.configured) { + try { await sendViaListmonk({ to, subject, html, type }); status = 'sent'; } + catch (e) { status = 'error'; console.error('[mailer:listmonk]', e && e.message || e); } + } else if (st.provider === 'smtp' && st.configured) { + try { await sendViaSmtp({ to, subject, html }); status = 'sent'; } + catch (e) { status = 'error'; console.error('[mailer:smtp]', e && e.message || e); } + } else { + provider = 'log'; + console.log('[mailer:log] →', to, '·', subject, '(' + type + ')'); + } + logEmail({ recipient: to, subject, html, type, provider, status }); + return { ok: status !== 'error', status, provider }; +} + +function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + +// Gebrandete Bestellbestätigung als HTML aus einem Order-Objekt erzeugen. +export function orderConfirmationHtml(order) { + const s = getSettings(); + const shopName = s.shop_name || 'hd-commerce'; + const accent = s.brand_accent || '#b8566a'; + const items = Array.isArray(order.items) ? order.items : []; + const rows = items.map(i => ` + + ${esc(i.name)}${i.size && i.size !== 'One Size' ? ' (' + esc(i.size) + ')' : ''} + ${esc(i.qty)}× + ${esc(formatPrice(i.priceCents * i.qty))} + `).join(''); + const tax = Number(order.tax_cents) || 0; + const ship = Number(order.shipping_cents) || 0; + const disc = Number(order.discount_cents) || 0; + const total = Number(order.total_cents) || 0; + const subtotal = total + disc - ship; + return ` +
+
+
+
${esc(shopName)}
+
Bestellbestätigung
+
+
+

Vielen Dank für deine Bestellung${order.customer_name ? ', ' + esc(order.customer_name.split(' ')[0]) : ''}!

+

Bestellnummer ${esc(order.number)}

+ + ${rows} +
+ + + ${disc > 0 ? `` : ''} + + + ${tax > 0 ? `` : ''} +
Zwischensumme${esc(formatPrice(subtotal))}
Rabatt${order.discount_code ? ' (' + esc(order.discount_code) + ')' : ''}−${esc(formatPrice(disc))}
Versand${ship === 0 ? 'Kostenlos' : esc(formatPrice(ship))}
Gesamt${esc(formatPrice(total))}
inkl. ${esc(formatPrice(tax))} MwSt.
+ ${order.address ? `
Lieferadresse
${esc(order.address)}
` : ''} +

Wir melden uns mit den Versanddetails. Bei Fragen einfach auf diese E-Mail antworten.

+
+
+
© ${new Date().getFullYear()} ${esc(shopName)} · powered by hd-commerce
+
`; +} + +// Bestellbestätigung versenden (idempotenz-Schutz liegt beim Aufrufer via markOrderPaid). +export async function sendOrderConfirmation(order) { + if (!order || !order.email) return { ok: false, status: 'no-recipient' }; + const s = getSettings(); + const subject = `Bestellbestätigung ${order.number} · ${s.shop_name || 'hd-commerce'}`; + const html = orderConfirmationHtml(order); + return sendMail({ to: order.email, subject, html, type: 'order_confirmation' }); +} diff --git a/src/lib/payments.js b/src/lib/payments.js new file mode 100644 index 0000000..62879b6 --- /dev/null +++ b/src/lib/payments.js @@ -0,0 +1,106 @@ +// hd-commerce — Payment-Provider-Abstraktion. +// Einheitliche Schnittstelle für Mollie (Default), Stripe und Demo-Fallback. +// Provider-Wahl via Setting `payment_provider` / ENV `PAYMENT_PROVIDER`, sonst Auto nach vorhandenen Keys. +import { resolvePaymentProvider } from './store.js'; + +const MOLLIE_BASE = 'https://api.mollie.com/v2'; +const mollieKeyReal = (k) => typeof k === 'string' && /^(test|live)_\w{20,}/.test(String(k).trim()); +const stripeKeyReal = (k) => typeof k === 'string' && /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(String(k).trim()); + +// Cent → Mollie-Amount-String "12.90" +function euro(cents) { return ((Math.round(Number(cents) || 0)) / 100).toFixed(2); } + +// ---------- Mollie ---------- +async function mollieCreate({ order, totalCents, returnUrl, webhookUrl, email, description }) { + const key = (process.env.MOLLIE_API_KEY || '').trim(); + const res = await fetch(MOLLIE_BASE + '/payments', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: { currency: 'EUR', value: euro(totalCents) }, + description: description || ('Bestellung ' + (order.number || '')), + redirectUrl: returnUrl, + webhookUrl: webhookUrl || undefined, + metadata: { order: order.number, order_id: order.id }, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error('Mollie ' + res.status + ': ' + (data && data.detail ? data.detail : 'Fehler')); + const url = data && data._links && data._links.checkout && data._links.checkout.href; + if (!url) throw new Error('Mollie: keine Checkout-URL'); + return { redirectUrl: url, paymentId: data.id }; +} +export async function molliePaymentStatus(paymentId) { + const key = (process.env.MOLLIE_API_KEY || '').trim(); + const res = await fetch(MOLLIE_BASE + '/payments/' + encodeURIComponent(paymentId), { + headers: { 'Authorization': 'Bearer ' + key }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error('Mollie ' + res.status); + return { status: data.status, metadata: data.metadata || {}, id: data.id }; +} + +// ---------- Stripe ---------- +async function stripeCreate({ order, lineItems, shippingCents, totalCents, discountCents, discountName, returnUrl, cancelUrl, email }) { + const secret = (process.env.STRIPE_SECRET_KEY || '').trim(); + const Stripe = (await import('stripe')).default; + const stripe = new Stripe(secret); + const sessionCfg = { + mode: 'payment', payment_method_types: ['card'], locale: 'de', + customer_email: email || undefined, + line_items: [ + ...lineItems.map((i) => ({ quantity: i.qty, price_data: { currency: 'eur', unit_amount: i.priceCents, + product_data: { name: `${i.name}${i.size ? ' · ' + i.size : ''}` } } })), + ...(shippingCents > 0 ? [{ quantity: 1, price_data: { currency: 'eur', unit_amount: shippingCents, product_data: { name: 'Versand' } } }] : []), + ], + success_url: returnUrl, + cancel_url: cancelUrl, + metadata: { order_number: order.number }, + }; + if (discountCents > 0) { + try { + const coupon = await stripe.coupons.create({ amount_off: discountCents, currency: 'eur', duration: 'once', name: discountName || 'Rabatt' }); + sessionCfg.discounts = [{ coupon: coupon.id }]; + } catch {} + } + const session = await stripe.checkout.sessions.create(sessionCfg); + return { redirectUrl: session.url, paymentId: session.id }; +} + +// ---------- Einheitliche Schnittstelle ---------- +// createPayment({ order, items, lineItems, totalCents, shippingCents, discountCents, discountName, returnUrl, demoUrl, cancelUrl, webhookUrl, email }) +// → { redirectUrl, provider, paymentId } (bei Demo: redirectUrl = demoUrl) +export async function createPayment(opts) { + const { provider, keyReady } = resolvePaymentProvider(); + const o = opts || {}; + const demoUrl = o.demoUrl || (o.returnUrl ? o.returnUrl + (o.returnUrl.includes('?') ? '&' : '?') + 'demo=1' : '/bestellung-erfolgreich?demo=1'); + + if (provider === 'mollie' && keyReady && mollieKeyReal(process.env.MOLLIE_API_KEY || '')) { + try { + const r = await mollieCreate({ + order: o.order, totalCents: o.totalCents, returnUrl: o.returnUrl, + webhookUrl: o.webhookUrl, email: o.email, description: o.description, + }); + return { redirectUrl: r.redirectUrl, provider: 'mollie', paymentId: r.paymentId }; + } catch (e) { + // Fake-/ungültiger Key oder API-Fehler → sauberer Demo-Fallback statt 500. + return { redirectUrl: demoUrl, provider: 'demo', paymentId: '', demo: true, fallbackReason: String(e && e.message || e) }; + } + } + + if (provider === 'stripe' && keyReady && stripeKeyReal(process.env.STRIPE_SECRET_KEY || '')) { + try { + const r = await stripeCreate({ + order: o.order, lineItems: o.lineItems || [], shippingCents: o.shippingCents || 0, + totalCents: o.totalCents, discountCents: o.discountCents || 0, discountName: o.discountName, + returnUrl: o.returnUrl, cancelUrl: o.cancelUrl || '/warenkorb', email: o.email, + }); + return { redirectUrl: r.redirectUrl, provider: 'stripe', paymentId: r.paymentId }; + } catch (e) { + return { redirectUrl: demoUrl, provider: 'demo', paymentId: '', demo: true, fallbackReason: String(e && e.message || e) }; + } + } + + // Demo-Fallback (kein echter Key / Provider=demo) + return { redirectUrl: demoUrl, provider: 'demo', paymentId: '', demo: true }; +} diff --git a/src/lib/seed.js b/src/lib/seed.js index 59849c5..1f1a07f 100644 --- a/src/lib/seed.js +++ b/src/lib/seed.js @@ -39,7 +39,8 @@ export const SEED_PRODUCTS = [ images: [img('naehgarn'), img('naehgarn2')], cardImage: img('naehgarn'), badge: 'Set', stock: 60, material: '100 % Polyester, je 200 m', features: ['12 aufeinander abgestimmte Farben', 'Reißfest & universell', 'Für Hand- und Maschinennähen'], - featured: true, sort: 3, desc: 'Praktisches Allround-Set mit zwölf Nähgarnen in harmonischen Farbtönen. Geeignet für nahezu alle Stoffe und Projekte.' + featured: true, sort: 3, desc: 'Praktisches Allround-Set mit zwölf Nähgarnen in harmonischen Farbtönen. Geeignet für nahezu alle Stoffe und Projekte.', + mwst: 19, base_amount: 240, base_unit: 'g', base_price_per: 'kg' }, { slug: 'knopf-sortiment-50', name: 'Knopf-Sortiment 50 Stück', shortName: 'Knopf-Sortiment 50', @@ -47,7 +48,8 @@ export const SEED_PRODUCTS = [ images: [img('knoepfe'), img('knoepfe2')], cardImage: img('knoepfe'), badge: '', stock: 45, material: 'Kunststoff & Holz, gemischt', features: ['Verschiedene Größen & Farben', '2- und 4-Loch-Knöpfe', 'Praktische Sortierbox'], - featured: false, sort: 4, desc: 'Bunt gemischtes Knopf-Sortiment mit 50 Stück in unterschiedlichen Größen, Farben und Materialien — für jedes Nähprojekt etwas dabei.' + featured: false, sort: 4, desc: 'Bunt gemischtes Knopf-Sortiment mit 50 Stück in unterschiedlichen Größen, Farben und Materialien — für jedes Nähprojekt etwas dabei.', + mwst: 19, base_amount: 50, base_unit: 'Stk', base_price_per: '10 Stk' }, { slug: 'reissverschluss-set', name: 'Reißverschluss-Set (10 Stück)', shortName: 'Reißverschluss-Set', diff --git a/src/lib/store-sqlite.js b/src/lib/store-sqlite.js index 0f271dc..89ee7bc 100644 --- a/src/lib/store-sqlite.js +++ b/src/lib/store-sqlite.js @@ -92,8 +92,34 @@ ensureColumn('orders', 'discount_cents', "discount_cents INTEGER DEFAULT 0"); ensureColumn('popups', 'style', "style TEXT DEFAULT 'modal'"); ensureColumn('popups', 'discount_id', "discount_id INTEGER"); +// v2.2 — DACH-Recht: MwSt-Satz, Grundpreis (PAngV) +ensureColumn('products', 'mwst', "mwst INTEGER DEFAULT 19"); +ensureColumn('products', 'base_amount', "base_amount REAL"); +ensureColumn('products', 'base_unit', "base_unit TEXT DEFAULT ''"); +ensureColumn('products', 'base_price_per', "base_price_per TEXT DEFAULT ''"); +// v2.2 — Orders: getrennter MwSt-/Versand-Ausweis, Land, Zahl-Provider +ensureColumn('orders', 'tax_cents', "tax_cents INTEGER DEFAULT 0"); +ensureColumn('orders', 'shipping_cents', "shipping_cents INTEGER DEFAULT 0"); +ensureColumn('orders', 'country', "country TEXT DEFAULT 'DE'"); +ensureColumn('orders', 'payment_provider', "payment_provider TEXT DEFAULT ''"); +ensureColumn('orders', 'payment_id', "payment_id TEXT DEFAULT ''"); + +// v2.2 — Versandzonen + E-Mail-Log +db.exec(` +CREATE TABLE IF NOT EXISTS shipping_zones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, countries TEXT DEFAULT '', + price_cents INTEGER DEFAULT 0, free_over_cents INTEGER, delivery_days TEXT DEFAULT '', + sort INTEGER DEFAULT 99, active INTEGER DEFAULT 1, created_at TEXT +); +CREATE TABLE IF NOT EXISTS email_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, recipient TEXT, subject TEXT, html TEXT, + type TEXT DEFAULT 'general', provider TEXT DEFAULT 'log', status TEXT DEFAULT 'logged', + created_at TEXT +); +`); + // ---------- mappers ---------- -const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured }); +const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured, mwst: (r.mwst == null ? 19 : Number(r.mwst)) }); const O = (r) => r && ({ ...r, items: JSON.parse(r.items || '[]') }); const E = (r) => r && ({ ...r, meta: JSON.parse(r.meta || '{}') }); @@ -104,9 +130,9 @@ function seedIfEmpty() { for (const [k, v] of Object.entries(SEED_SETTINGS)) ins.run(k, String(v)); } if (db.prepare('SELECT COUNT(*) c FROM products').get().c === 0) { - const ins = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields) - VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields)`); - const tx = db.transaction(rows => rows.forEach(p => ins.run({ ...p, sizes: JSON.stringify(p.sizes), images: JSON.stringify(p.images), features: JSON.stringify(p.features), featured: p.featured ? 1 : 0, metafields: JSON.stringify(p.metafields || {}) }))); + const ins = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields,mwst,base_amount,base_unit,base_price_per) + VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields,@mwst,@base_amount,@base_unit,@base_price_per)`); + const tx = db.transaction(rows => rows.forEach(p => ins.run({ ...p, sizes: JSON.stringify(p.sizes), images: JSON.stringify(p.images), features: JSON.stringify(p.features), featured: p.featured ? 1 : 0, metafields: JSON.stringify(p.metafields || {}), mwst: (p.mwst == null ? 19 : p.mwst), base_amount: (p.base_amount == null ? null : p.base_amount), base_unit: p.base_unit || '', base_price_per: p.base_price_per || '' }))); tx(SEED_PRODUCTS); } if (db.prepare('SELECT COUNT(*) c FROM customers').get().c === 0) { @@ -133,6 +159,7 @@ function seedIfEmpty() { SEED_POPUPS.forEach(p => ip.run({ ...p, created_at: now })); } if (db.prepare('SELECT COUNT(*) c FROM discounts').get().c === 0) seedDiscounts(); + if (db.prepare('SELECT COUNT(*) c FROM shipping_zones').get().c === 0) seedShippingZones(); // seed some demo analytics events so the analytics dashboard is not empty if (db.prepare('SELECT COUNT(*) c FROM events').get().c === 0) seedEvents(); } @@ -342,15 +369,19 @@ function normProduct(d) { badge: d.badge || '', stock: (d.stock === '' || d.stock == null) ? null : Math.round(Number(d.stock)), material: d.material || '', features: JSON.stringify(d.features || []), featured: d.featured ? 1 : 0, sort: Number(d.sort) || 99, desc: d.desc || '', metafields: JSON.stringify(d.metafields || {}), + mwst: ([7, 19, 0].includes(Math.round(Number(d.mwst)))) ? Math.round(Number(d.mwst)) : 19, + base_amount: (d.base_amount === '' || d.base_amount == null) ? null : Number(d.base_amount), + base_unit: d.base_unit || '', + base_price_per: d.base_price_per || '', }; } export function createProduct(d) { - const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields) - VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields)`).run(normProduct(d)); + const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields,mwst,base_amount,base_unit,base_price_per) + VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields,@mwst,@base_amount,@base_unit,@base_price_per)`).run(normProduct(d)); return r.lastInsertRowid; } export function updateProduct(id, d) { - db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields WHERE id=@id`) + db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields,mwst=@mwst,base_amount=@base_amount,base_unit=@base_unit,base_price_per=@base_price_per WHERE id=@id`) .run({ ...normProduct(d), id: Number(id) }); return id; } @@ -360,19 +391,40 @@ export const deleteProduct = (id) => db.prepare('DELETE FROM products WHERE id=? export const listOrders = () => db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC').all().map(O); export const getOrderById = (id) => O(db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id))); export const getOrderByNumber = (num) => O(db.prepare('SELECT * FROM orders WHERE number=?').get(num)); -export function createOrder({ email, customer_name, items, total_cents, status = 'pending', address = '', discount_code = '', discount_cents = 0 }) { +export function createOrder({ email, customer_name, items, total_cents, status = 'pending', address = '', discount_code = '', discount_cents = 0, tax_cents = 0, shipping_cents = 0, country = 'DE', payment_provider = '', payment_id = '' }) { const m = db.prepare("SELECT MAX(CAST(substr(number,5) AS INTEGER)) m FROM orders").get().m || 1000; const number = 'BNK-' + (m + 1); const now = new Date().toISOString(); - const r = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at,discount_code,discount_cents) VALUES (?,?,?,?,?,?,?,?,?,?)') - .run(number, email || '', customer_name || '', status, total_cents || 0, JSON.stringify(items || []), address || '', now, discount_code || '', Math.round(Number(discount_cents) || 0)); + const r = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at,discount_code,discount_cents,tax_cents,shipping_cents,country,payment_provider,payment_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + .run(number, email || '', customer_name || '', status, total_cents || 0, JSON.stringify(items || []), address || '', now, + discount_code || '', Math.round(Number(discount_cents) || 0), Math.round(Number(tax_cents) || 0), Math.round(Number(shipping_cents) || 0), + country || 'DE', payment_provider || '', payment_id || ''); if (email) { db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (?,?,?,?)').run(customer_name || '', email, '', now); } - recordEvent({ type: 'purchase', path: '/bestellung-erfolgreich', value_cents: total_cents || 0, meta: { number } }); + // Umsatz-Event nur fuer bezahlte/Demo-Abschluesse; pending bleibt unberuecksichtigt bis paid. + if (status === 'paid' || status === 'fulfilled') { + recordEvent({ type: 'purchase', path: '/bestellung-erfolgreich', value_cents: total_cents || 0, meta: { number } }); + } return { id: r.lastInsertRowid, number }; } export const updateOrderStatus = (id, status) => db.prepare('UPDATE orders SET status=? WHERE id=?').run(status, Number(id)); +export const getOrderByPaymentId = (pid) => O(db.prepare('SELECT * FROM orders WHERE payment_id=?').get(String(pid || ''))); +export function setOrderPayment(id, { payment_id = '', payment_provider = '' } = {}) { + db.prepare("UPDATE orders SET payment_id=COALESCE(NULLIF(?,''),payment_id), payment_provider=COALESCE(NULLIF(?,''),payment_provider) WHERE id=?") + .run(String(payment_id || ''), String(payment_provider || ''), Number(id)); + return id; +} +// Idempotent: setzt eine Order auf bezahlt, falls noch nicht. Gibt true zurueck, wenn jetzt gerade umgestellt wurde. +export function markOrderPaid(id, { payment_id = '', payment_provider = '' } = {}) { + const cur = db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id)); + if (!cur) return { ok: false, changed: false }; + if (cur.status === 'paid' || cur.status === 'fulfilled') return { ok: true, changed: false, order: O(cur) }; + db.prepare("UPDATE orders SET status='paid', payment_id=COALESCE(NULLIF(?,''),payment_id), payment_provider=COALESCE(NULLIF(?,''),payment_provider) WHERE id=?") + .run(String(payment_id || ''), String(payment_provider || ''), Number(id)); + recordEvent({ type: 'purchase', path: '/bestellung-erfolgreich', value_cents: cur.total_cents || 0, meta: { number: cur.number } }); + return { ok: true, changed: true, order: O(db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id))) }; +} // ---------- customers ---------- export function listCustomers() { @@ -632,3 +684,169 @@ export function recordAudit({ user = '', action = '', entity = '', entity_id = ' } export const listAudit = (limit = 200) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 200); export const recentAudit = (limit = 8) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 8); + +// ===================================================================== +// v2.2 — Versandzonen, MwSt/Grundpreis, Feature-Flags, E-Mail-Log +// ===================================================================== + +// ---------- shipping zones ---------- +function seedShippingZones() { + const now = new Date().toISOString(); + const rows = [ + { name: 'Deutschland', countries: 'DE', price_cents: 490, free_over_cents: 4900, delivery_days: '2–4', sort: 1, active: 1 }, + { name: 'Österreich & Schweiz', countries: 'AT,CH', price_cents: 990, free_over_cents: null, delivery_days: '4–7', sort: 2, active: 1 }, + { name: 'EU', countries: 'EU', price_cents: 1290, free_over_cents: null, delivery_days: '5–9', sort: 3, active: 1 }, + ]; + const ins = db.prepare(`INSERT INTO shipping_zones (name,countries,price_cents,free_over_cents,delivery_days,sort,active,created_at) + VALUES (@name,@countries,@price_cents,@free_over_cents,@delivery_days,@sort,@active,@created_at)`); + const tx = db.transaction(() => rows.forEach(r => ins.run({ ...r, created_at: now }))); + tx(); +} +if (db.prepare('SELECT COUNT(*) c FROM shipping_zones').get().c === 0) seedShippingZones(); + +const SZ = (r) => r && ({ ...r, active: !!r.active }); +export const listShippingZones = () => db.prepare('SELECT * FROM shipping_zones ORDER BY sort, id').all().map(SZ); +export const listActiveShippingZones = () => db.prepare('SELECT * FROM shipping_zones WHERE active=1 ORDER BY sort, id').all().map(SZ); +export const getShippingZoneById = (id) => SZ(db.prepare('SELECT * FROM shipping_zones WHERE id=?').get(Number(id))); +function normZone(d) { + const numOrNull = (v) => (v === '' || v == null) ? null : Math.round(Number(v)); + return { + name: String(d.name || '').trim() || 'Zone', + countries: String(d.countries || '').toUpperCase().replace(/\s+/g, ''), + price_cents: Math.max(0, Math.round(Number(d.price_cents) || 0)), + free_over_cents: numOrNull(d.free_over_cents), + delivery_days: String(d.delivery_days || ''), + sort: Number(d.sort) || 99, + active: d.active ? 1 : 0, + }; +} +export function createShippingZone(d) { + const n = normZone(d); + const r = db.prepare(`INSERT INTO shipping_zones (name,countries,price_cents,free_over_cents,delivery_days,sort,active,created_at) + VALUES (@name,@countries,@price_cents,@free_over_cents,@delivery_days,@sort,@active,@created_at)`).run({ ...n, created_at: new Date().toISOString() }); + return r.lastInsertRowid; +} +export function updateShippingZone(id, d) { + const cur = db.prepare('SELECT * FROM shipping_zones WHERE id=?').get(Number(id)); + if (!cur) return id; + const n = normZone({ ...cur, ...d }); + db.prepare(`UPDATE shipping_zones SET name=@name,countries=@countries,price_cents=@price_cents,free_over_cents=@free_over_cents, + delivery_days=@delivery_days,sort=@sort,active=@active WHERE id=@id`).run({ ...n, id: Number(id) }); + return id; +} +export const deleteShippingZone = (id) => db.prepare('DELETE FROM shipping_zones WHERE id=?').run(Number(id)); + +// EU-Mitgliedsstaaten (für die generische "EU"-Zone) +const EU_COUNTRIES = new Set(['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES','SE']); +function zoneMatchesCountry(zone, cc) { + const code = String(cc || '').toUpperCase().trim(); + if (!code) return false; + const list = String(zone.countries || '').toUpperCase().split(',').map(x => x.trim()).filter(Boolean); + if (list.includes(code)) return true; + if (list.includes('EU') && EU_COUNTRIES.has(code)) return true; + if (list.includes('*')) return true; + return false; +} +// Liefert die passende Zone + berechneten Versandpreis für ein Land bei gegebener Zwischensumme. +export function shippingFor(countryCode, subtotalCents) { + const sub = Math.max(0, Math.round(Number(subtotalCents) || 0)); + const cc = String(countryCode || 'DE').toUpperCase().trim(); + const zones = listActiveShippingZones(); + // exakte Länderzuordnung schlägt generische EU-/Wildcard-Zone + let exact = zones.find(z => { + const list = String(z.countries || '').toUpperCase().split(',').map(x => x.trim()); + return list.includes(cc); + }); + let zone = exact || zones.find(z => zoneMatchesCountry(z, cc)) || null; + if (!zone) return { ok: false, zone: null, price_cents: 0, free: false, delivery_days: '' }; + const free = zone.free_over_cents != null && sub >= zone.free_over_cents; + return { + ok: true, zone_id: zone.id, zone_name: zone.name, + price_cents: free ? 0 : zone.price_cents, base_price_cents: zone.price_cents, + free, free_over_cents: zone.free_over_cents, delivery_days: zone.delivery_days, + }; +} + +// ---------- MwSt / Grundpreis ---------- +// Aus einem Brutto-Betrag die enthaltene MwSt herausrechnen (Satz in Prozent). +export function taxFromGross(grossCents, rate) { + const r = Number(rate) || 0; + if (r <= 0) return 0; + return Math.round((Number(grossCents) || 0) * r / (100 + r)); +} +// Grundpreis (PAngV) als String, z.B. "8,90 €/kg". base: { base_amount, base_unit, base_price_per } +export function basePriceLabel(priceCents, p) { + const amount = Number(p && p.base_amount); + const unit = String((p && p.base_unit) || '').trim(); + const per = String((p && p.base_price_per) || '').trim(); + if (!amount || !unit || !per) return ''; + // per kann "kg", "l", "100 g" etc. sein → Zielmenge in derselben Einheit interpretieren. + const m = per.match(/^([\d.,]+)?\s*(.+)$/); + if (!m) return ''; + const perQty = m[1] ? parseFloat(m[1].replace(',', '.')) : 1; + const perUnit = m[2].trim(); + // base_amount ist in base_unit; Umrechnungsfaktor g↔kg, ml↔l unterstützt. + const factors = { g: { kg: 1000, g: 1 }, kg: { kg: 1, g: 0.001 }, ml: { l: 1000, ml: 1 }, l: { l: 1, ml: 0.001 } }; + let perPriceCents; + const fu = factors[unit.toLowerCase()]; + if (fu && fu[perUnit.toLowerCase()] != null) { + const unitsOfBasePerTarget = fu[perUnit.toLowerCase()] * perQty; + perPriceCents = (Number(priceCents) || 0) / amount * unitsOfBasePerTarget; + } else { + // gleiche Einheit / keine Umrechnung: Preis pro perQty Einheiten + perPriceCents = (Number(priceCents) || 0) / amount * perQty; + } + const label = formatPrice(Math.round(perPriceCents)); + return `${label}/${per}`; +} + +// ---------- Feature-Flags ---------- +const FEATURE_DEFAULTS = { + feature_newsletter: '1', + feature_accounts: '0', + feature_reviews: '0', + feature_wishlist: '0', + feature_abandoned_cart: '0', + feature_search: '0', +}; +export const FEATURE_KEYS = Object.keys(FEATURE_DEFAULTS); +export function feature(key) { + if (!(key in FEATURE_DEFAULTS)) return false; + const v = getSetting(key, FEATURE_DEFAULTS[key]); + return v === '1' || v === 1 || v === true || v === 'true'; +} +export function allFeatures() { + const o = {}; + for (const k of FEATURE_KEYS) o[k] = feature(k); + return o; +} + +// ---------- payment provider (Setting/ENV-Auflösung) ---------- +const mollieKeyReal = (k) => typeof k === 'string' && /^(test|live)_\w{20,}/.test(String(k).trim()); +const stripeKeyReal = (k) => typeof k === 'string' && /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(String(k).trim()); +// Welcher Provider ist aktiv? Setting payment_provider > ENV PAYMENT_PROVIDER > Auto-Wahl nach Keys. +export function resolvePaymentProvider() { + const setting = (getSetting('payment_provider', '') || '').trim().toLowerCase(); + const envProv = (process.env.PAYMENT_PROVIDER || '').trim().toLowerCase(); + const chosen = setting || envProv || ''; + const mollie = mollieKeyReal(process.env.MOLLIE_API_KEY || ''); + const stripe = stripeKeyReal(process.env.STRIPE_SECRET_KEY || ''); + if (chosen === 'mollie') return { provider: 'mollie', keyReady: mollie, source: setting ? 'setting' : 'env' }; + if (chosen === 'stripe') return { provider: 'stripe', keyReady: stripe, source: setting ? 'setting' : 'env' }; + if (chosen === 'demo') return { provider: 'demo', keyReady: true, source: setting ? 'setting' : 'env' }; + // Auto-Wahl + if (mollie) return { provider: 'mollie', keyReady: true, source: 'auto' }; + if (stripe) return { provider: 'stripe', keyReady: true, source: 'auto' }; + return { provider: 'demo', keyReady: true, source: 'auto' }; +} + +// ---------- E-Mail-Log ---------- +export function logEmail({ recipient = '', subject = '', html = '', type = 'general', provider = 'log', status = 'logged' }) { + try { + const r = db.prepare('INSERT INTO email_log (recipient,subject,html,type,provider,status,created_at) VALUES (?,?,?,?,?,?,?)') + .run(String(recipient || ''), String(subject || ''), String(html || ''), String(type || 'general'), String(provider || 'log'), String(status || 'logged'), new Date().toISOString()); + return r.lastInsertRowid; + } catch { return null; } +} +export const listEmailLog = (limit = 100) => db.prepare('SELECT * FROM email_log ORDER BY id DESC LIMIT ?').all(Number(limit) || 100); +export const getEmailLogById = (id) => db.prepare('SELECT * FROM email_log WHERE id=?').get(Number(id)); diff --git a/src/middleware.js b/src/middleware.js index 8f22150..bf6e2da 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -18,7 +18,7 @@ function sectionOf(adminInner) { const map = { '': 'dashboard', 'bestellungen': 'bestellungen', 'produkte': 'produkte', 'kunden': 'kunden', 'analytics': 'analytics', 'marketing': 'marketing', 'rabatte': 'rabatte', 'inhalte': 'inhalte', 'einstellungen': 'einstellungen', - 'nutzer': 'nutzer', 'audit': 'audit', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout', + 'nutzer': 'nutzer', 'audit': 'audit', 'versand': 'versandzonen', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout', }; return map[seg] || 'dashboard'; } diff --git a/src/pages/admin/bestellungen/[id].astro b/src/pages/admin/bestellungen/[id].astro index 9a0006d..5d22ff3 100644 --- a/src/pages/admin/bestellungen/[id].astro +++ b/src/pages/admin/bestellungen/[id].astro @@ -13,6 +13,7 @@ if (Astro.request.method === 'POST') { } const order = getOrderById(id); if (!order) return Astro.redirect(base + '/bestellungen'); +const subtotal = order.total_cents + (order.discount_cents || 0) - (order.shipping_cents || 0); const statusMap = { fulfilled: ['green', 'Erfüllt'], pending: ['amber', 'Offen'], cancelled: ['gray', 'Storniert'], refunded: ['red', 'Erstattet'] }; const fmtDate = (s) => new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled', 'Storniert'], ['refunded', 'Erstattet']]; @@ -39,7 +40,12 @@ const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled', {(order.discount_cents === 0 && order.discount_code) && (
Gutschein ({order.discount_code})Gratisversand
)} -
Gesamt{formatPrice(order.total_cents)}
+
Zwischensumme (netto + brutto){formatPrice(subtotal)}
+
Versand{order.country ? ` (${order.country})` : ''}{order.shipping_cents === 0 ? 'Kostenlos' : formatPrice(order.shipping_cents)}
+
Gesamt (brutto){formatPrice(order.total_cents)}
+
+ Netto / enthaltene MwSt{formatPrice(order.total_cents - (order.tax_cents||0))} / {formatPrice(order.tax_cents||0)} +
diff --git a/src/pages/admin/einstellungen/email-log.astro b/src/pages/admin/einstellungen/email-log.astro new file mode 100644 index 0000000..7de0d49 --- /dev/null +++ b/src/pages/admin/einstellungen/email-log.astro @@ -0,0 +1,52 @@ +--- +import Admin from '../../../layouts/Admin.astro'; +import { adminBase } from '../../../lib/auth.js'; +const base = adminBase(); +import { listEmailLog } from '../../../lib/store.js'; +const logs = listEmailLog(100); +const fmtDate = (s) => { try { return new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'short', year: '2-digit', hour: '2-digit', minute: '2-digit' }); } catch { return s; } }; +const typeLabel = { order_confirmation: 'Bestellbestätigung', general: 'Allgemein' }; +const statusBadge = { sent: 'green', logged: 'amber', error: 'red' }; +const url = new URL(Astro.request.url); +const previewId = url.searchParams.get('preview'); +const preview = previewId ? logs.find((l) => String(l.id) === String(previewId)) : null; +--- + +
+
+

Letzte gesendete bzw. geloggte Mails. Ohne konfigurierten Provider (Listmonk/SMTP) werden alle Mails hier protokolliert (Status „logged").

+
+ + {preview && ( +
+
Vorschau — {preview.subject}
+

An {preview.recipient} · {fmtDate(preview.created_at)}

+ +

Vorschau schließen

+
+ )} + +
+
Protokoll ({logs.length})
+
+ + + + {logs.length === 0 ? () : + logs.map((l) => ( + + + + + + + +
DatumEmpfängerBetreffTypProviderStatus
Noch keine Mails
{fmtDate(l.created_at)}{l.recipient}{l.subject}{typeLabel[l.type] || l.type}{l.provider}{l.status}
@@ -51,26 +75,62 @@ const currencies = ['EUR', 'CHF', 'USD', 'GBP'];
+
Versandzonen & länderabhängige Preise unter „Versand".
- +
-
Zahlung (Stripe)
-

{stripeMode}

-

{stripeReal ? 'Echter Stripe-Schlüssel erkannt — Checkout nutzt Stripe Hosted Checkout.' : 'Kein echter STRIPE_SECRET_KEY gesetzt. Der Checkout läuft im Demo-Fallback (Bestellung ohne Zahlung).'}

-

Konfiguration über ENV: STRIPE_SECRET_KEY, STRIPE_PUBLIC_KEY.

+
Zahlung
+

Aktiv: {providerLabel} {pp.source === 'auto' ? 'Auto-Wahl' : pp.source === 'setting' ? 'manuell' : 'ENV'}

+
+ +
+
    +
  • Mollie-Key (MOLLIE_API_KEY): {mollieSet ? 'gesetzt' : 'fehlt'}
  • +
  • Stripe-Key (STRIPE_SECRET_KEY): {stripeSet ? 'gesetzt' : 'fehlt'}
  • +
+ {pp.provider === 'demo' &&

Ohne gültigen Key läuft der Checkout im Demo-Fallback (Bestellung ohne echte Zahlung).

}
+
+
System
-

Datenbank: SQLite (DB_PATH). Admin-Zugang über Session-Login; Initial-Owner aus ADMIN_EMAIL / ADMIN_PASS. Admin-Pfad über ADMIN_PATH. Nutzer & Rollen unter „Nutzer & Zugänge".

+

Datenbank: SQLite (DB_PATH). Admin-Zugang über Session-Login; Initial-Owner aus ADMIN_EMAIL / ADMIN_PASS. Admin-Pfad über ADMIN_PATH.

+ +
+ +
+
Module (Feature-Flags)
+

Schalte einzelne Funktionen zentral an oder aus. Abgeschaltete Module verschwinden aus Storefront und Admin.

+
+ +
+ {FEATURE_KEYS.map((k) => ( + + ))} +
+ +
+
diff --git a/src/pages/admin/produkte/[id].astro b/src/pages/admin/produkte/[id].astro index f495534..a2b2d87 100644 --- a/src/pages/admin/produkte/[id].astro +++ b/src/pages/admin/produkte/[id].astro @@ -27,6 +27,10 @@ if (Astro.request.method === 'POST') { featured: f.get('featured') === 'on', sort: parseInt(String(f.get('sort') || '99')) || 99, desc: String(f.get('desc') || ''), + mwst: parseInt(String(f.get('mwst') || '19')) || 19, + base_amount: f.get('base_amount') === '' || f.get('base_amount') == null ? null : parseFloat(String(f.get('base_amount')).replace(',', '.')), + base_unit: String(f.get('base_unit') || ''), + base_price_per: String(f.get('base_price_per') || ''), }; const _me = currentUser(Astro.request); if (isNew) { const newId = createProduct(data); recordAudit({ user: _me?.email, action: 'create', entity: 'product', entity_id: String(newId) }); return Astro.redirect(`${base}/produkte/${newId}?saved=1`); } @@ -37,7 +41,7 @@ const product = isNew ? null : getProductById(id); if (!isNew && !product) return Astro.redirect(base + '/produkte'); if (new URL(Astro.request.url).searchParams.get('saved')) flash = 'Produkt angelegt.'; const cats = listCategories(); -const p = product || { name: '', slug: '', shortName: '', priceCents: 0, category: '', sizes: ['One Size'], images: [], cardImage: '', badge: '', stock: '', material: '', features: [], featured: false, sort: 99, desc: '' }; +const p = product || { name: '', slug: '', shortName: '', priceCents: 0, category: '', sizes: ['One Size'], images: [], cardImage: '', badge: '', stock: '', material: '', features: [], featured: false, sort: 99, desc: '', mwst: 19, base_amount: null, base_unit: '', base_price_per: '' }; const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',') : ''; --- @@ -77,6 +81,21 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',
+
+ +
+
+
+ + + +
+
Beispiel: 250 g · je „kg" → zeigt „xx,xx €/kg". Unterstützt g/kg und ml/l-Umrechnung.
+
{cats.map((c) => ( diff --git a/src/pages/admin/versand/index.astro b/src/pages/admin/versand/index.astro new file mode 100644 index 0000000..d8cad34 --- /dev/null +++ b/src/pages/admin/versand/index.astro @@ -0,0 +1,106 @@ +--- +import Admin from '../../../layouts/Admin.astro'; +import { adminBase, currentUser } from '../../../lib/auth.js'; +const base = adminBase(); +import { listShippingZones, createShippingZone, updateShippingZone, deleteShippingZone, formatPrice, recordAudit } from '../../../lib/store.js'; + +let flash = ''; +const _me = currentUser(Astro.request); +if (Astro.request.method === 'POST') { + const f = await Astro.request.formData(); + const action = String(f.get('_action') || ''); + const editId = f.get('id') ? Number(f.get('id')) : null; + if (action === 'delete') { + deleteShippingZone(editId); recordAudit({ user: _me?.email, action: 'delete', entity: 'shipping_zone', entity_id: String(editId) }); + flash = 'Zone gelöscht.'; + } else if (action === 'zone') { + const data = { + name: String(f.get('name') || ''), + countries: String(f.get('countries') || ''), + price_cents: Math.round(parseFloat(String(f.get('price') || '0').replace(',', '.')) * 100) || 0, + free_over_cents: String(f.get('free_over') || '').trim() === '' ? null : Math.round(parseFloat(String(f.get('free_over')).replace(',', '.')) * 100), + delivery_days: String(f.get('delivery_days') || ''), + sort: parseInt(String(f.get('sort') || '99')) || 99, + active: f.get('active') === 'on', + }; + if (editId) { updateShippingZone(editId, data); recordAudit({ user: _me?.email, action: 'update', entity: 'shipping_zone', entity_id: String(editId) }); flash = 'Zone gespeichert.'; } + else { const nid = createShippingZone(data); recordAudit({ user: _me?.email, action: 'create', entity: 'shipping_zone', entity_id: String(nid) }); flash = 'Zone angelegt.'; } + } +} + +const zones = listShippingZones(); +const euro = (c) => (c == null ? '' : (c / 100).toFixed(2).replace('.', ',')); +--- + +
+ {flash &&
✓ {flash}
} + +
+
Versandzonen
+
+ + + + {zones.length === 0 ? () : + zones.map((z) => ( + + + + + + + + + + ))} + +
ZoneLänderPreisGratis abLieferzeitStatus
Keine Zonen
{z.name}{z.countries}{formatPrice(z.price_cents)}{z.free_over_cents != null ? formatPrice(z.free_over_cents) : '—'}{z.delivery_days || '—'}{z.active ? 'Aktiv' : 'Inaktiv'} + +
+
+
+
+ +
+
Neue Zone
+
+ + +
+
+
+
+
+
+
+
+ + + +
+
+
+ + + +
diff --git a/src/pages/api/checkout.js b/src/pages/api/checkout.js index e44d0a6..2766405 100644 --- a/src/pages/api/checkout.js +++ b/src/pages/api/checkout.js @@ -1,6 +1,11 @@ -import { createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount } from '../../lib/store.js'; +import { + createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount, + shippingFor, taxFromGross, getProductBySlug, markOrderPaid, getOrderById, setOrderPayment, +} from '../../lib/store.js'; +import { createPayment } from '../../lib/payments.js'; +import { sendOrderConfirmation } from '../../lib/mailer.js'; export const prerender = false; -const keyLooksReal = (k) => typeof k === 'string' && /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(k.trim()); + function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); } export async function POST({ request }) { @@ -10,74 +15,92 @@ export async function POST({ request }) { const contact = body.contact || {}; if (!items.length) return json({ error: 'Warenkorb leer' }, 400); - const lineItems = items.map((i) => ({ - name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1), - priceCents: Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0), image: i.image || '', - })); + const lineItems = items.map((i) => { + const prod = i.slug ? getProductBySlug(i.slug) : null; + return { + slug: i.slug || (prod && prod.slug) || '', + name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1), + priceCents: Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0), image: i.image || '', + mwst: prod ? Number(prod.mwst) || 0 : 19, + }; + }); const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0); - const freeShip = Number(getSetting('free_shipping_cents', '4900')) || 4900; - let shipping = subtotal >= freeShip ? 0 : 490; const customer_name = [contact.vorname, contact.nachname].filter(Boolean).join(' ').trim(); const email = contact.email || ''; + // Land → ISO-Code (Storefront liefert Code direkt via country, ältere Forms „Deutschland") + const countryRaw = String(body.country || contact.country || contact.land || 'DE').trim(); + const COUNTRY_MAP = { 'deutschland': 'DE', 'österreich': 'AT', 'oesterreich': 'AT', 'schweiz': 'CH' }; + let country = countryRaw.length === 2 ? countryRaw.toUpperCase() : (COUNTRY_MAP[countryRaw.toLowerCase()] || 'DE'); + // Rabattcode serverseitig erneut validieren (niemals dem Client vertrauen). - let discount = null; // { id, code, amountCents, freeShipping } + let discount = null; const rawCode = String(body.code || '').trim(); if (rawCode) { const v = validateDiscount(rawCode, subtotal, email || undefined); if (v.ok) discount = v; } - // Kein (gültiger) Code? Bestes automatisches Discount anwenden, falls Bedingungen erfüllt. if (!discount) { const auto = bestAutoDiscount(subtotal); if (auto && auto.ok) discount = auto; } let discountCents = 0; + let discountFreeShipping = false; if (discount) { - if (discount.freeShipping) { shipping = 0; } - else { discountCents = Math.min(discount.amountCents, subtotal); } + if (discount.freeShipping) discountFreeShipping = true; + else discountCents = Math.min(discount.amountCents, subtotal); } - const total = Math.max(0, subtotal - discountCents + shipping); + + // Versand serverseitig aus Versandzonen berechnen. + const ship = shippingFor(country, subtotal); + let shippingCents = ship.ok ? ship.price_cents : 0; + if (discountFreeShipping) shippingCents = 0; + + const total = Math.max(0, subtotal - discountCents + shippingCents); + + // Enthaltene MwSt aus Brutto (Positionen + Versand) herausrechnen, nach Satz gruppiert. + // Rabatt mindert anteilig die Nettobasis → vereinfacht: MwSt aus dem rabattierten Brutto. + const taxByRate = {}; + const discountFactor = subtotal > 0 ? (subtotal - discountCents) / subtotal : 1; + for (const i of lineItems) { + const grossLine = Math.round(i.priceCents * i.qty * discountFactor); + const t = taxFromGross(grossLine, i.mwst); + taxByRate[i.mwst] = (taxByRate[i.mwst] || 0) + t; + } + // Versand mit dem höchsten vorkommenden Satz (i. d. R. 19 %) belegen. + const shipRate = lineItems.length ? Math.max(...lineItems.map(i => i.mwst || 0)) : 19; + if (shippingCents > 0) taxByRate[shipRate] = (taxByRate[shipRate] || 0) + taxFromGross(shippingCents, shipRate); + const taxCents = Object.values(taxByRate).reduce((a, b) => a + b, 0); const order = await createOrder({ email, customer_name, items: lineItems, total_cents: total, status: 'pending', - address: [contact.strasse, contact.plz, contact.ort, contact.land].filter(Boolean).join(', '), + address: [contact.strasse, contact.plz, contact.ort, country].filter(Boolean).join(', '), discount_code: discount ? discount.code : '', discount_cents: discountCents, + tax_cents: taxCents, shipping_cents: shippingCents, country, }); - // Einlösung verbuchen (used_count + Redemption). if (discount) { redeemDiscount(discount.id, discount.code, email, order.id, discount.freeShipping ? 0 : discountCents); } - const secret = process.env.STRIPE_SECRET_KEY || ''; const origin = new URL(request.url).origin; + const returnUrl = `${origin}/bestellung-erfolgreich?order=${order.number}`; + const pay = await createPayment({ + order, items: lineItems, lineItems, totalCents: total, shippingCents, + discountCents, discountName: discount ? discount.code : 'Rabatt', + returnUrl, demoUrl: `${returnUrl}&demo=1`, cancelUrl: `${origin}/warenkorb`, + webhookUrl: `${origin}/api/payments/webhook`, email, + description: `Bestellung ${order.number}`, + }); - if (keyLooksReal(secret)) { - try { - const Stripe = (await import('stripe')).default; - const stripe = new Stripe(secret); - const sessionCfg = { - mode: 'payment', payment_method_types: ['card'], locale: 'de', - customer_email: email || undefined, - line_items: [ - ...lineItems.map((i) => ({ quantity: i.qty, price_data: { currency: 'eur', unit_amount: i.priceCents, - product_data: { name: `${i.name}${i.size ? ' · ' + i.size : ''}` } } })), - ...(shipping > 0 ? [{ quantity: 1, price_data: { currency: 'eur', unit_amount: shipping, product_data: { name: 'Versand (DE)' } } }] : []), - ], - success_url: `${origin}/bestellung-erfolgreich?order=${order.number}`, - cancel_url: `${origin}/warenkorb`, metadata: { order_number: order.number }, - }; - // Warenkorb-Rabatt als Stripe-Coupon (amount_off) anhängen. - if (discountCents > 0) { - try { - const coupon = await stripe.coupons.create({ amount_off: discountCents, currency: 'eur', duration: 'once', name: discount.code }); - sessionCfg.discounts = [{ coupon: coupon.id }]; - } catch {} - } - const session = await stripe.checkout.sessions.create(sessionCfg); - return json({ url: session.url }); - } catch (e) { return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` }); } + // Demo-Provider: Order sofort als bezahlt markieren + Bestätigungsmail auslösen. + if (pay.provider === 'demo') { + const res = markOrderPaid(order.id, { payment_provider: 'demo', payment_id: '' }); + if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} } + } else if (pay.paymentId) { + // Payment-ID an der Order vermerken, damit der Webhook sie zuordnen kann. + setOrderPayment(order.id, { payment_id: pay.paymentId, payment_provider: pay.provider }); } - return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` }); + + return json({ url: pay.redirectUrl }); } diff --git a/src/pages/api/payments/webhook.js b/src/pages/api/payments/webhook.js new file mode 100644 index 0000000..11953ce --- /dev/null +++ b/src/pages/api/payments/webhook.js @@ -0,0 +1,42 @@ +import { getOrderByPaymentId, markOrderPaid } from '../../../lib/store.js'; +import { molliePaymentStatus } from '../../../lib/payments.js'; +import { sendOrderConfirmation } from '../../../lib/mailer.js'; +export const prerender = false; + +// Mollie sendet POST mit `id` (Payment-ID). Status via API prüfen → bei paid Order setzen + Mail. +export async function POST({ request }) { + let paymentId = ''; + try { + const ct = request.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + const b = await request.json().catch(() => ({})); + paymentId = b.id || ''; + } else { + const form = await request.formData().catch(() => null); + if (form) paymentId = String(form.get('id') || ''); + } + } catch {} + if (!paymentId) return new Response('OK', { status: 200 }); + + try { + const st = await molliePaymentStatus(paymentId); + if (st.status === 'paid') { + let order = getOrderByPaymentId(paymentId); + if (!order && st.metadata && st.metadata.order_id) { + const { getOrderById } = await import('../../../lib/store.js'); + order = getOrderById(st.metadata.order_id); + } + if (order) { + const res = markOrderPaid(order.id, { payment_id: paymentId, payment_provider: 'mollie' }); + if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} } + } + } + } catch (e) { + // Webhook nie mit 500 quittieren (Mollie würde sonst dauerhaft retrien); 200 mit Log. + console.error('[payments:webhook]', e && e.message || e); + } + return new Response('OK', { status: 200 }); +} + +// GET zur einfachen Erreichbarkeitsprüfung. +export async function GET() { return new Response('payments webhook ready', { status: 200 }); } diff --git a/src/pages/api/shipping-quote.js b/src/pages/api/shipping-quote.js new file mode 100644 index 0000000..f3b0733 --- /dev/null +++ b/src/pages/api/shipping-quote.js @@ -0,0 +1,57 @@ +import { shippingFor, taxFromGross, getProductBySlug, validateDiscount, bestAutoDiscount, listActiveShippingZones } from '../../lib/store.js'; +export const prerender = false; +function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); } + +// Berechnet Versand + enthaltene MwSt serverseitig für eine Länder-/Warenkorb-Kombination. +export async function POST({ request }) { + let body; + try { body = await request.json(); } catch { return json({ error: 'Bad request' }, 400); } + const items = Array.isArray(body.items) ? body.items : []; + const country = String(body.country || 'DE').toUpperCase().trim(); + const lineItems = items.map((i) => { + const prod = i.slug ? getProductBySlug(i.slug) : null; + return { + qty: Math.max(1, parseInt(i.qty) || 1), + priceCents: Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0), + mwst: prod ? Number(prod.mwst) || 0 : 19, + }; + }); + const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0); + + // Rabatt (Code oder Auto) für die Anzeige berücksichtigen. + let discount = null; + const rawCode = String(body.code || '').trim(); + if (rawCode) { const v = validateDiscount(rawCode, subtotal, body.email || undefined); if (v.ok) discount = v; } + if (!discount) { const a = bestAutoDiscount(subtotal); if (a && a.ok) discount = a; } + let discountCents = 0, freeShipping = false; + if (discount) { if (discount.freeShipping) freeShipping = true; else discountCents = Math.min(discount.amountCents, subtotal); } + + const ship = shippingFor(country, subtotal); + let shippingCents = ship.ok ? ship.price_cents : 0; + if (freeShipping) shippingCents = 0; + + const taxByRate = {}; + const discountFactor = subtotal > 0 ? (subtotal - discountCents) / subtotal : 1; + for (const i of lineItems) { + const grossLine = Math.round(i.priceCents * i.qty * discountFactor); + taxByRate[i.mwst] = (taxByRate[i.mwst] || 0) + taxFromGross(grossLine, i.mwst); + } + const shipRate = lineItems.length ? Math.max(...lineItems.map(i => i.mwst || 0)) : 19; + if (shippingCents > 0) taxByRate[shipRate] = (taxByRate[shipRate] || 0) + taxFromGross(shippingCents, shipRate); + const taxCents = Object.values(taxByRate).reduce((a, b) => a + b, 0); + const total = Math.max(0, subtotal - discountCents + shippingCents); + + return json({ + ok: ship.ok, country, subtotal, shippingCents, taxCents, + taxByRate: Object.entries(taxByRate).map(([rate, cents]) => ({ rate: Number(rate), cents })).filter(t => t.cents > 0), + discountCents, freeShipping, total, + zone_name: ship.zone_name || '', delivery_days: ship.delivery_days || '', + free_over_cents: ship.free_over_cents || null, + }); +} + +// Aktive Versandzonen für die Länder-Auswahl im Checkout. +export async function GET() { + const zones = listActiveShippingZones(); + return json({ zones: zones.map(z => ({ id: z.id, name: z.name, countries: z.countries, delivery_days: z.delivery_days })) }); +} diff --git a/src/pages/api/subscribe.js b/src/pages/api/subscribe.js index 03211a3..dba2111 100644 --- a/src/pages/api/subscribe.js +++ b/src/pages/api/subscribe.js @@ -1,8 +1,10 @@ -import { addSubscriber } from '../../lib/store.js'; +import { addSubscriber, feature } from '../../lib/store.js'; export const prerender = false; function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); } export async function POST({ request }) { + // Feature-Flag: Newsletter abschaltbar. + if (!feature('feature_newsletter')) return json({ ok: false, error: 'Newsletter deaktiviert' }, 403); let b; try { b = await request.json(); } catch { return json({ ok: false }, 400); } const email = (b.email || '').trim(); diff --git a/src/pages/bestellung-erfolgreich.astro b/src/pages/bestellung-erfolgreich.astro index 76b4979..8f2ccd3 100644 --- a/src/pages/bestellung-erfolgreich.astro +++ b/src/pages/bestellung-erfolgreich.astro @@ -1,20 +1,37 @@ --- import Base from '../layouts/Base.astro'; +import { getOrderByNumber, formatPrice } from '../lib/store.js'; const url = new URL(Astro.request.url); -const order = url.searchParams.get('order') || ''; +const orderNum = url.searchParams.get('order') || ''; const demo = url.searchParams.get('demo') === '1'; +const order = orderNum ? getOrderByNumber(orderNum) : null; +const subtotal = order ? (order.total_cents + (order.discount_cents || 0) - (order.shipping_cents || 0)) : 0; ---
-
+

Vielen Dank für deine Bestellung!

- {order &&

Deine Bestellnummer: {order}

} + {orderNum &&

Deine Bestellnummer: {orderNum}

}

Wir haben deine Bestellung erhalten und melden uns per E-Mail mit den Versanddetails.

{demo &&

Demo-Hinweis: Diese Bestellung wurde ohne echte Zahlung im Demo-Modus angelegt.

} - Weiter einkaufen
+ + {order && ( +
+ {order.items.map((i) => ( +
{i.qty}× {i.name}{i.size && i.size !== 'One Size' ? ` (${i.size})` : ''}{formatPrice(i.priceCents * i.qty)}
+ ))} +
Zwischensumme{formatPrice(subtotal)}
+ {order.discount_cents > 0 && (
Rabatt{order.discount_code ? ` (${order.discount_code})` : ''}−{formatPrice(order.discount_cents)}
)} +
Versand{order.shipping_cents === 0 ? 'Kostenlos' : formatPrice(order.shipping_cents)}
+
Gesamt{formatPrice(order.total_cents)}
+ {order.tax_cents > 0 && (
inkl. MwSt.{formatPrice(order.tax_cents)}
)} +
+ )} + +
diff --git a/src/pages/checkout.astro b/src/pages/checkout.astro index d1bd82c..089463b 100644 --- a/src/pages/checkout.astro +++ b/src/pages/checkout.astro @@ -1,14 +1,29 @@ --- import Base from '../layouts/Base.astro'; -import { getSetting } from '../lib/store.js'; -const freeShip = Number(getSetting('free_shipping_cents', '4900')) || 4900; +import { getSetting, listActiveShippingZones, resolvePaymentProvider } from '../lib/store.js'; const currency = getSetting('currency', 'EUR'); -const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SECRET_KEY || '').trim()); +const zones = listActiveShippingZones(); +// Länder-Optionen aus aktiven Zonen ableiten (ISO-Codes + Klartext). +const COUNTRY_NAMES = { DE: 'Deutschland', AT: 'Österreich', CH: 'Schweiz', NL: 'Niederlande', BE: 'Belgien', FR: 'Frankreich', LU: 'Luxemburg', IT: 'Italien', ES: 'Spanien', PL: 'Polen', DK: 'Dänemark' }; +const countrySet = []; +const seen = new Set(); +for (const z of zones) { + for (const raw of String(z.countries || '').split(',').map(c => c.trim().toUpperCase()).filter(Boolean)) { + if (raw === 'EU' || raw === '*') continue; + if (!seen.has(raw)) { seen.add(raw); countrySet.push({ code: raw, name: COUNTRY_NAMES[raw] || raw }); } + } +} +// Sicherstellen, dass DE als Standard zuerst steht. +if (!seen.has('DE')) countrySet.unshift({ code: 'DE', name: 'Deutschland' }); +countrySet.sort((a, b) => (a.code === 'DE' ? -1 : b.code === 'DE' ? 1 : a.name.localeCompare(b.name))); +const pp = resolvePaymentProvider(); +const providerLabel = { mollie: 'Mollie', stripe: 'Stripe', demo: 'Demo' }[pp.provider] || pp.provider; +const demoMode = pp.provider === 'demo'; ---

Zur Kasse

- {!hasStripe && (

Demo-Modus: Es ist kein echter Stripe-Schlüssel hinterlegt — die Bestellung wird ohne Zahlung abgeschlossen.

)} + {demoMode && (

Demo-Modus: Kein echter Zahlungsanbieter konfiguriert — die Bestellung wird ohne Zahlung abgeschlossen.

)}

Kontakt & Lieferadresse

@@ -19,9 +34,13 @@ const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
-
+
+ +
- +
@@ -38,46 +57,66 @@ const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
-