v2.2: Verkaufsfertig-Fundament — Mollie/Payment-Abstraktion, MwSt/Grundpreis (PAngV), Versandzonen, Bestellmails (Listmonk/SMTP/Log), Feature-Flags

- payments.js: einheitliche createPayment/Webhook-Schnittstelle (Mollie Default, Stripe, Demo); Auto-Provider-Wahl; Mollie-REST + /api/payments/webhook (idempotent); Fake-Key => sauberer Demo-Fallback
- mailer.js: sendMail via Listmonk-Tx / SMTP (nodemailer) / Log-Fallback (email_log); gebrandete Bestellbestaetigung bei paid
- DACH: products.mwst + base_amount/base_unit/base_price_per (Grundpreis); Storefront/Warenkorb/Checkout/Erfolg/Admin mit MwSt-Ausweis + Versand-Transparenz; tax_cents/shipping_cents/country an Orders
- shipping_zones-Tabelle + CRUD + shippingFor(); Admin 'Versand'; serverseitige Versandberechnung in /api/checkout + /api/shipping-quote (Laenderwahl live)
- Feature-Flags (feature_*) + feature()-Helper; Admin Module-Toggles; Newsletter-Gating (Popup/Subscribe)
- Admin-API/Manifest/ai-admin.txt um shipping_zones erweitert; MCP list/upsert/delete_shipping; README/.env.example ergaenzt; Version 2.2.0
This commit is contained in:
2026-06-17 16:37:10 +00:00
parent 430fa718fa
commit e5514dd5da
31 changed files with 1077 additions and 129 deletions
+14 -3
View File
@@ -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 <HDC_API_TOKEN>',
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, <feldname>: ... } — NICHT unter einem data-Schlüssel verschachtelt.',
'discounts.value: bei percent 1100, 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).',
],
};
}
+2 -2
View File
@@ -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) {
+120
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 => `
<tr>
<td style="padding:8px 0;border-bottom:1px solid #eee">${esc(i.name)}${i.size && i.size !== 'One Size' ? ' <span style="color:#888">(' + esc(i.size) + ')</span>' : ''}</td>
<td style="padding:8px 0;border-bottom:1px solid #eee;text-align:center;color:#555">${esc(i.qty)}×</td>
<td style="padding:8px 0;border-bottom:1px solid #eee;text-align:right;white-space:nowrap">${esc(formatPrice(i.priceCents * i.qty))}</td>
</tr>`).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 `<!doctype html><html lang="de"><body style="margin:0;background:#f6f4f1;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#222">
<div style="max-width:560px;margin:0 auto;padding:24px">
<div style="background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.06)">
<div style="background:${esc(accent)};padding:22px 28px;color:#fff">
<div style="font-size:20px;font-weight:700">${esc(shopName)}</div>
<div style="opacity:.9;font-size:14px;margin-top:2px">Bestellbestätigung</div>
</div>
<div style="padding:28px">
<p style="margin:0 0 4px;font-size:16px">Vielen Dank für deine Bestellung${order.customer_name ? ', ' + esc(order.customer_name.split(' ')[0]) : ''}!</p>
<p style="margin:0 0 18px;color:#555">Bestellnummer <b style="color:#222">${esc(order.number)}</b></p>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<tbody>${rows}</tbody>
</table>
<table style="width:100%;border-collapse:collapse;font-size:14px;margin-top:10px">
<tr><td style="padding:4px 0;color:#555">Zwischensumme</td><td style="padding:4px 0;text-align:right">${esc(formatPrice(subtotal))}</td></tr>
${disc > 0 ? `<tr><td style="padding:4px 0;color:${esc(accent)}">Rabatt${order.discount_code ? ' (' + esc(order.discount_code) + ')' : ''}</td><td style="padding:4px 0;text-align:right;color:${esc(accent)}">${esc(formatPrice(disc))}</td></tr>` : ''}
<tr><td style="padding:4px 0;color:#555">Versand</td><td style="padding:4px 0;text-align:right">${ship === 0 ? 'Kostenlos' : esc(formatPrice(ship))}</td></tr>
<tr><td style="padding:10px 0 0;font-weight:700;font-size:16px;border-top:1px solid #eee">Gesamt</td><td style="padding:10px 0 0;text-align:right;font-weight:700;font-size:16px;border-top:1px solid #eee">${esc(formatPrice(total))}</td></tr>
${tax > 0 ? `<tr><td style="padding:2px 0;color:#888;font-size:12px" colspan="2">inkl. ${esc(formatPrice(tax))} MwSt.</td></tr>` : ''}
</table>
${order.address ? `<div style="margin-top:20px;padding-top:16px;border-top:1px solid #eee"><div style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#999;margin-bottom:4px">Lieferadresse</div><div style="font-size:14px;color:#444">${esc(order.address)}</div></div>` : ''}
<p style="margin:22px 0 0;font-size:13px;color:#888">Wir melden uns mit den Versanddetails. Bei Fragen einfach auf diese E-Mail antworten.</p>
</div>
</div>
<div style="text-align:center;color:#aaa;font-size:12px;margin-top:14px">© ${new Date().getFullYear()} ${esc(shopName)} · powered by hd-commerce</div>
</div></body></html>`;
}
// 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' });
}
+106
View File
@@ -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 };
}
+4 -2
View File
@@ -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',
+229 -11
View File
@@ -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: '24', sort: 1, active: 1 },
{ name: 'Österreich & Schweiz', countries: 'AT,CH', price_cents: 990, free_over_cents: null, delivery_days: '47', sort: 2, active: 1 },
{ name: 'EU', countries: 'EU', price_cents: 1290, free_over_cents: null, delivery_days: '59', 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));