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:
+14
-3
@@ -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 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).',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
@@ -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 => `
|
||||
<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' });
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user