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]>
192 lines
8.7 KiB
JavaScript
192 lines
8.7 KiB
JavaScript
/* =====================================================================
|
||
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." };
|
||
}
|
||
}
|