Files
vans-diy-bastelbedarf/functions/_shared/bestellung-erstellen.js
T
qciganoandClaude Sonnet 5 81c175d03c Zahlungsarten überarbeitet: echtes PayPal, Klarna & eigene Kartenmaske raus
Auf ausdrücklichen Wunsch, mit Fokus auf rechtliche Absicherung:

- Klarna komplett entfernt (hätte eigene Händlerprüfung + Bonitätsprüfungs-
  Pflichten nach der neuen EU-Verbraucherkreditrichtlinie vorausgesetzt).
- Eigene Kreditkarten-Eingabemaske komplett entfernt (wäre PCI-DSS-pflichtig
  gewesen — für einen kleinen Shop praktisch nicht stemmbar). Kartenzahlung
  bleibt möglich: PayPals eigener, PCI-zertifizierter Gast-Checkout bietet
  Kredit-/Debitkarte an, ohne dass Kartendaten je unsere Seite berühren.
- PayPal ist jetzt ECHT server-seitig verifiziert statt dem Browser blind zu
  vertrauen: eigener Cloudflare-Function-Flow (functions/_shared/paypal.js +
  functions/api/paypal/) legt die PayPal-Bestellung server-seitig an, zieht
  die Zahlung nach Bestätigung server-seitig ein und prüft den eingezogenen
  Betrag gegen die Bestellsumme — die Bestellung wird ausschließlich bei
  bestätigter, betragsgleicher Zahlung angelegt. /api/orders lehnt direkte
  PayPal-Bestellungen jetzt ausdrücklich ab (verhindert vorgetäuschte
  "bezahlte" Bestellungen ohne echte Zahlung).
- Neuer, ehrlicherer Bestellstatus "zahlungOffen": Überweisungs-Bestellungen
  starten jetzt so (Geld noch nicht da) statt fälschlich sofort "bezahlt" zu
  heißen — Schutz vor Warenversand, bevor das Geld wirklich angekommen ist.
  Eigene Kachel/Filter/Badge-Farbe in der Verwaltung, Umsatz-/Auswertungs-
  Zahlen zählen "zahlungOffen" bewusst nicht mit.
- Rechtstexte (AGB, Datenschutzerklärung, FAQ, Versand & Zahlung) auf allen
  4 Sprachen aktualisiert: nur noch PayPal + Überweisung erwähnt, inkl. DSGVO-
  Hinweis zur internationalen Datenübertragung an PayPal (Data Privacy
  Framework-Zertifizierung).
- D1-Migration 0003: neue Spalte paypal_order_id (Zahlungsbeleg) + erweiterter
  Status-Wertebereich, auf Live-Datenbank angewendet, bestehende Daten intakt.

Mit echten Testbestellungen lokal verifiziert: Überweisung legt korrekt
"zahlungOffen" an, direkter PayPal-Bypass-Versuch an /api/orders wird
abgelehnt, PayPal-Route meldet sauber "noch nicht eingerichtet" ohne
Zugangsdaten. Checkout-UI zeigt nur noch 2 Zahlungsarten, Bestellknopf ist bei
PayPal ausgeblendet (Zahlung läuft exklusiv über den echten PayPal-Button).

Für den echten Zahlungseingang fehlt noch VanVans eigenes PayPal-Business-
Konto (Client-ID + Secret) — Details in der Vault-Dokumentation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 01:06:41 +02:00

192 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
functions/_shared/bestellung-erstellen.js — die eigentliche "Bestellung anlegen"-Logik,
herausgelöst aus functions/api/orders.js, damit sie von ZWEI Stellen sicher wiederverwendet
werden kann:
1. functions/api/orders.js — für Überweisung (Vorkasse), Status "zahlungOffen".
2. functions/api/paypal/capture-order.js — für PayPal, aber NUR nachdem die Zahlung
server-seitig bei PayPal selbst als abgeschlossen bestätigt wurde (siehe
functions/_shared/paypal.js) — Status dann "bezahlt".
Gibt IMMER ein Ergebnisobjekt zurück ({ ok, ... } oder { ok:false, httpStatus, error }), NIE
direkt eine Response — die aufrufende Route entscheidet selbst, wie sie antwortet. ===================================================================== */
import { sendeEmail, escapeHtmlFuerEmail } from "./email.js";
// @ts-ignore
import gutscheineRaw from "../../src/content/gutscheine.json";
const ERLAUBTE_LAENDER = ["de", "at", "ch", "lu"];
const gutscheine = (gutscheineRaw && gutscheineRaw.codes) || [];
// VanVans Wunsch-Adresse für Bestellbenachrichtigungen. Fest hinterlegt statt als Secret, da es
// keine geheime Information ist (nur ein Empfänger).
const BENACHRICHTIGUNGS_EMAIL = "[email protected]";
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
function formatPreis(n) {
return `${Number(n).toFixed(2).replace(".", ",")} €`;
}
function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }) {
const artikelZeilen = artikel
.map((a) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)}${formatPreis(a.preis * a.menge)}</li>`)
.join("");
const statusZeile =
status === "zahlungOffen"
? `<p style="color:#b06a00;"><strong>⏳ Zahlung noch ausstehend</strong> — Überweisung, bitte Zahlungseingang abwarten, bevor die Ware verschickt wird.</p>`
: `<p style="color:#1f9254;"><strong>✅ Zahlung bestätigt</strong> (PayPal)</p>`;
return `
<div style="font-family: sans-serif; color: #1a1a1a; line-height: 1.6;">
<h2 style="margin: 0 0 0.5em;">🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}</h2>
${statusZeile}
<p><strong>Von:</strong> ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})<br />
<strong>Land:</strong> ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}</p>
<p><strong>Artikel:</strong></p>
<ul>${artikelZeilen}</ul>
<p><strong>Summe: ${formatPreis(summe)}</strong></p>
<p><a href="https://vans-diy-bastelbedarf.pages.dev/verwaltung/">→ Zur Bestellverwaltung</a></p>
</div>
`;
}
function findeGutschein(code) {
const normalisiert = String(code || "").trim().toUpperCase();
if (!normalisiert) return null;
return gutscheine.find((g) => String(g.code || "").toUpperCase() === normalisiert) || null;
}
/**
* Validiert die Bestelldaten, legt sie in D1 an und verschickt die Benachrichtigungsmail.
* @param {*} env Cloudflare-Umgebung (env.DB etc.)
* @param {*} body Roh-Payload vom Checkout (gleiches Format wie bisher)
* @param {{status: "zahlungOffen"|"bezahlt", paypalOrderId?: string|null}} optionen
*/
export async function erstelleBestellung(env, body, optionen) {
const { status, paypalOrderId = null } = optionen;
if (!env.DB) {
return { ok: false, httpStatus: 500, error: "Datenbank nicht verbunden." };
}
const land = String(body.land || "").toLowerCase();
if (!ERLAUBTE_LAENDER.includes(land)) {
return { ok: false, httpStatus: 400, error: "Ungültiges Land." };
}
const kunde = body.kunde && typeof body.kunde === "object" ? body.kunde : {};
const name = String(kunde.name || "").trim();
const email = String(kunde.email || "").trim();
if (!name || !email || !email.includes("@")) {
return { ok: false, httpStatus: 400, error: "Ungültige Kundendaten." };
}
const artikel = Array.isArray(body.artikel) ? body.artikel : [];
if (artikel.length === 0) {
return { ok: false, httpStatus: 400, error: "Die Bestellung enthält keine Artikel." };
}
for (const a of artikel) {
if (!a || typeof a.slug !== "string" || !a.slug.trim() || typeof a.name !== "string" || !a.name.trim()) {
return { ok: false, httpStatus: 400, error: "Ungültige Artikeldaten." };
}
if (!Number.isFinite(a.menge) || a.menge <= 0) {
return { ok: false, httpStatus: 400, error: "Ungültige Artikelmenge." };
}
if (!Number.isFinite(a.preis) || a.preis < 0) {
return { ok: false, httpStatus: 400, error: "Ungültiger Artikelpreis." };
}
}
const summe = Number(body.summe);
if (!Number.isFinite(summe) || summe < 0) {
return { ok: false, httpStatus: 400, error: "Ungültige Bestellsumme." };
}
const zwischensumme = Number.isFinite(Number(body.zwischensumme)) ? Number(body.zwischensumme) : 0;
const versandkosten = Number.isFinite(Number(body.versandkosten)) ? Number(body.versandkosten) : 0;
const rabattGesamt = Number.isFinite(Number(body.rabattGesamt)) ? Number(body.rabattGesamt) : 0;
// ── "Nur einmal pro Person" durchsetzen — unabhängig vom Browser/Gerät, da hier gegen die
// echten, gespeicherten Bestellungen geprüft wird. Stornierte Bestellungen zählen NICHT als
// "schon benutzt". ──
const gutscheinCodeRoh = typeof body.gutscheinCode === "string" ? body.gutscheinCode.trim() : "";
let gutscheinCodeNormalisiert = null;
if (gutscheinCodeRoh) {
const gutschein = findeGutschein(gutscheinCodeRoh);
gutscheinCodeNormalisiert = gutscheinCodeRoh.toUpperCase();
if (gutschein && gutschein.einmaligProPerson) {
const { results } = await env.DB.prepare(
`SELECT COUNT(*) as anzahl FROM orders WHERE gutschein_code = ? AND LOWER(kunde_email) = LOWER(?) AND status != 'storniert'`
)
.bind(gutscheinCodeNormalisiert, email)
.all();
if ((results?.[0]?.anzahl ?? 0) > 0) {
return {
ok: false,
httpStatus: 400,
error: "Dieser Gutscheincode wurde bereits mit dieser E-Mail-Adresse eingelöst und gilt nur einmal pro Person.",
};
}
}
}
const now = new Date().toISOString();
try {
const insertOrder = await env.DB.prepare(
`INSERT INTO orders
(created_at, updated_at, status, kunde_name, kunde_email, land, strasse, plz, ort,
zahlungsart, zwischensumme, versandkosten, rabatt_gesamt, summe,
treuebonus_verwendet, abo_rabatt_verwendet, gutschein_code, paypal_order_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.bind(
now,
now,
status,
name,
email,
land,
String(kunde.strasse || ""),
String(kunde.plz || ""),
String(kunde.ort || ""),
String(body.zahlungsart || ""),
zwischensumme,
versandkosten,
rabattGesamt,
summe,
body.treuebonusVerwendet ? 1 : 0,
body.aboRabattVerwendet ? 1 : 0,
gutscheinCodeNormalisiert,
paypalOrderId
)
.run();
const orderId = insertOrder.meta.last_row_id;
// Menschenlesbare Bestellnummer wird ERST NACH dem Insert aus der autoinkrementierten,
// garantiert eindeutigen ID abgeleitet — keine Race Condition bei zwei gleichzeitigen
// Bestellungen.
const jahr = new Date().getFullYear();
const bestellnummer = `VDB-${jahr}-${String(orderId).padStart(4, "0")}`;
await env.DB.prepare(`UPDATE orders SET bestellnummer = ? WHERE id = ?`).bind(bestellnummer, orderId).run();
const itemStmts = artikel.map((a) =>
env.DB.prepare(
`INSERT INTO order_items (order_id, produkt_slug, name, kategorie_slug, menge, preis, gratis)
VALUES (?,?,?,?,?,?,?)`
).bind(orderId, a.slug, a.name, a.kategorie || null, Math.round(a.menge), a.preis, a.gratis ? 1 : 0)
);
await env.DB.batch(itemStmts);
// E-Mail-Benachrichtigung — bewusst abgewartet (siehe functions/_shared/email.js), aber
// NICHT fehlerkritisch: schlägt sie fehl, bleibt die Bestellung trotzdem ganz normal
// gespeichert.
await sendeEmail(env, {
to: BENACHRICHTIGUNGS_EMAIL,
subject: `${status === "zahlungOffen" ? "⏳" : "🩵"} Neue Bestellung ${bestellnummer}`,
html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }),
text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Status: ${status === "zahlungOffen" ? "Zahlung ausstehend (Überweisung)" : "Bezahlt (PayPal)"}. Details: https://vans-diy-bastelbedarf.pages.dev/verwaltung/`,
});
return { ok: true, id: orderId, bestellnummer };
} catch (err) {
return { ok: false, httpStatus: 500, error: "Bestellung konnte nicht gespeichert werden." };
}
}