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]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
98028df4ea
commit
81c175d03c
@@ -0,0 +1,191 @@
|
||||
/* =====================================================================
|
||||
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." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* =====================================================================
|
||||
functions/_shared/paypal.js — echte, server-seitige PayPal-Anbindung (REST API v2).
|
||||
|
||||
WARUM SERVER-SEITIG UND NICHT NUR IM BROWSER: Die vorherige Lösung ließ den Browser die
|
||||
Zahlung direkt mit PayPal abwickeln (`actions.order.capture()`) und hat dem Ergebnis blind
|
||||
vertraut — wer wollte, hätte den JavaScript-Aufruf einfach überspringen und trotzdem eine
|
||||
"erfolgreiche" Bestellung auslösen können, OHNE je wirklich zu bezahlen. Das wäre ein echtes
|
||||
Betrugsrisiko für VanVan gewesen (Ware raus, kein Geld rein).
|
||||
|
||||
Jetzt läuft die komplette Zahlungsprüfung hier auf dem Server:
|
||||
1. functions/api/paypal/create-order.js legt die PayPal-Bestellung server-seitig an (der
|
||||
Betrag kommt also von UNS, nicht vom Browser).
|
||||
2. functions/api/paypal/capture-order.js fragt NACH der PayPal-Bestätigung direkt bei PayPal
|
||||
selbst nach: ist die Zahlung wirklich abgeschlossen (`status === "COMPLETED"`) und stimmt
|
||||
der tatsächlich eingezogene Betrag mit der Bestellsumme überein? Nur wenn beides zutrifft,
|
||||
wird die Bestellung in der Datenbank als "bezahlt" angelegt.
|
||||
|
||||
Zugangsdaten: env.PAYPAL_CLIENT_ID / env.PAYPAL_CLIENT_SECRET (Cloudflare-Pages-Secrets,
|
||||
niemals im Code). env.PAYPAL_ENV steuert Sandbox ("sandbox", Standard/Test) vs. Live-Betrieb
|
||||
("live") — sobald VanVan ein echtes PayPal-Business-Konto hat, reicht das Setzen der echten
|
||||
Live-Zugangsdaten + PAYPAL_ENV=live, ohne Codeänderung.
|
||||
===================================================================== */
|
||||
|
||||
function paypalBasisUrl(env) {
|
||||
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
}
|
||||
|
||||
/** Prüft, ob PayPal überhaupt eingerichtet ist — solange nicht, sollen alle PayPal-Routen sauber
|
||||
* einen klaren Fehler liefern statt kryptisch gegen "undefined"-Zugangsdaten zu laufen. */
|
||||
export function paypalKonfiguriert(env) {
|
||||
return !!(env.PAYPAL_CLIENT_ID && env.PAYPAL_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
/** Holt ein kurzlebiges OAuth-Zugangstoken von PayPal (Client-Credentials-Flow, Standard-REST-
|
||||
* API-Authentifizierung — kein Nutzer-Login nötig, das ist reine Server-zu-Server-Kommunikation
|
||||
* mit VanVans eigenen App-Zugangsdaten). */
|
||||
async function holeZugangstoken(env) {
|
||||
const res = await fetch(`${paypalBasisUrl(env)}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${env.PAYPAL_CLIENT_ID}:${env.PAYPAL_CLIENT_SECRET}`)}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal-Zugangstoken konnte nicht geholt werden (Status ${res.status}): ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** Legt eine echte PayPal-Bestellung an — der Betrag kommt bewusst von UNS (server-seitig
|
||||
* berechnet aus dem Warenkorb), nicht vom Browser, damit er beim Bezahlen bei PayPal nicht
|
||||
* manipuliert werden kann. Gibt die PayPal-Bestell-ID zurück, die der Browser dann dem
|
||||
* PayPal-Smart-Button übergibt. */
|
||||
export async function erstellePaypalBestellung(env, betragEuro) {
|
||||
const token = await holeZugangstoken(env);
|
||||
const res = await fetch(`${paypalBasisUrl(env)}/v2/checkout/orders`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
intent: "CAPTURE",
|
||||
purchase_units: [{ amount: { currency_code: "EUR", value: Math.max(0.01, betragEuro).toFixed(2) } }],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal-Bestellung konnte nicht angelegt werden (Status ${res.status}): ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** Zieht die Zahlung für eine zuvor angelegte PayPal-Bestellung tatsächlich ein und prüft das
|
||||
* Ergebnis direkt bei PayPal (nicht nur, was der Browser behauptet). Gibt
|
||||
* { ok, captureId, betragEuro } zurück — "ok" ist NUR true, wenn PayPal selbst "COMPLETED"
|
||||
* bestätigt. Wirft nie einen Fehler nach außen (siehe Muster in functions/_shared/email.js),
|
||||
* der Aufrufer prüft einfach `.ok`. */
|
||||
export async function erfassePaypalZahlung(env, paypalOrderId) {
|
||||
try {
|
||||
const token = await holeZugangstoken(env);
|
||||
const res = await fetch(`${paypalBasisUrl(env)}/v2/checkout/orders/${encodeURIComponent(paypalOrderId)}/capture`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data) {
|
||||
return { ok: false, error: `PayPal-Zahlung konnte nicht erfasst werden (Status ${res.status}).` };
|
||||
}
|
||||
// Manche Zahlungen (z.B. bei zusätzlicher Prüfung durch die Bank) landen nicht sofort auf
|
||||
// COMPLETED — das behandeln wir bewusst als "nicht erfolgreich", damit nie eine Bestellung
|
||||
// als bezahlt gilt, obwohl das Geld noch nicht sicher zugesagt ist.
|
||||
const capture = data?.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
if (data.status !== "COMPLETED" || !capture || capture.status !== "COMPLETED") {
|
||||
return { ok: false, error: `PayPal-Zahlung ist nicht abgeschlossen (Status: ${data.status}).` };
|
||||
}
|
||||
return { ok: true, captureId: capture.id, betragEuro: Number(capture.amount?.value ?? 0) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
+25
-183
@@ -1,69 +1,24 @@
|
||||
/* =====================================================================
|
||||
POST /api/orders — legt eine echte Bestellung in D1 an (orders + order_items).
|
||||
POST /api/orders — legt eine Überweisungs-Bestellung (Vorkasse) in D1 an.
|
||||
|
||||
Wird vom Checkout aufgerufen (siehe src/pages/checkout/index.astro), sobald jemand die
|
||||
Bestellung abschickt. BEWUSST nicht dauerhaft hinter dem Zugangscode-Gate (siehe
|
||||
Wird vom Checkout aufgerufen (siehe src/pages/checkout/index.astro), sobald jemand mit
|
||||
"Banküberweisung" bestellt. BEWUSST nicht dauerhaft hinter dem Zugangscode-Gate (siehe
|
||||
functions/_middleware.js) — sobald der Shop öffentlich ist (SITE_PUBLIC=true), müssen auch
|
||||
echte Kund:innen hier bestellen können, genau wie sie den Checkout selbst erreichen können.
|
||||
Vor dem Live-Gang ist ohnehin die ganze Seite inkl. Checkout hinter dem Gate, d.h. nur
|
||||
qciga/VanVan können bis hierhin überhaupt kommen.
|
||||
echte Kund:innen hier bestellen können.
|
||||
|
||||
In Phase 1 gibt es kein echtes Zahlungs-Backend — jede vollständig abgeschickte Bestellung
|
||||
gilt sofort als "bezahlt" (siehe scripts/account.ts, derselbe Grundsatz). ===================================================================== */
|
||||
WICHTIG (seit der echten PayPal-Anbindung): Diese Route legt Bestellungen NUR noch mit dem
|
||||
Status "zahlungOffen" an (Geld ist noch nicht da) und lehnt zahlungsart:"paypal" AUSDRÜCKLICH
|
||||
ab. Grund: PayPal-Bestellungen dürfen NUR über functions/api/paypal/capture-order.js entstehen,
|
||||
nachdem die Zahlung dort server-seitig bei PayPal selbst bestätigt wurde — sonst könnte jemand
|
||||
diese Route einfach direkt mit zahlungsart:"paypal" aufrufen und eine "bezahlte" Bestellung
|
||||
vortäuschen, ohne je wirklich zu bezahlen (echtes Betrugs-/Verlustrisiko für VanVan). ===================================================================== */
|
||||
|
||||
import { json } from "../_shared/http.js";
|
||||
import { sendeEmail, escapeHtmlFuerEmail } from "../_shared/email.js";
|
||||
// Gleiche Gutschein-Daten wie im Frontend (src/data/gutscheine.ts) — hier server-seitig erneut
|
||||
// eingebunden, um das "nur einmal pro Person"-Flag unabhängig vom Browser durchzusetzen (siehe
|
||||
// unten). Bewusst derselbe JSON-Rohdaten-Import, keine Code-Duplizierung der Inhalte.
|
||||
// @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 (siehe Anfrage vom 03.08.2026). 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(".", ",")} €`;
|
||||
}
|
||||
|
||||
/** Baut die interne "neue Bestellung eingegangen"-E-Mail an VanVan/qciga — bewusst schlicht
|
||||
* gehalten (kein Marketing-Layout nötig, nur die wichtigsten Infos auf einen Blick + Link zur
|
||||
* Verwaltung). */
|
||||
function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }) {
|
||||
const artikelZeilen = artikel
|
||||
.map((a) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)} — ${formatPreis(a.preis * a.menge)}</li>`)
|
||||
.join("");
|
||||
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>
|
||||
<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;
|
||||
}
|
||||
import { erstelleBestellung } from "../_shared/bestellung-erstellen.js";
|
||||
|
||||
export async function onRequestPost(context) {
|
||||
const { request, env } = context;
|
||||
|
||||
if (!env.DB) {
|
||||
return json(500, { ok: false, error: "Datenbank nicht verbunden." });
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -71,133 +26,20 @@ export async function onRequestPost(context) {
|
||||
return json(400, { ok: false, error: "Ungültige Anfrage." });
|
||||
}
|
||||
|
||||
// ── Validierung: lieber eine klare Fehlermeldung im Checkout als eine kaputte/leere
|
||||
// Bestellung in der Datenbank. ──
|
||||
const land = String(body.land || "").toLowerCase();
|
||||
if (!ERLAUBTE_LAENDER.includes(land)) {
|
||||
return json(400, { ok: false, 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 json(400, { ok: false, error: "Ungültige Kundendaten." });
|
||||
}
|
||||
|
||||
const artikel = Array.isArray(body.artikel) ? body.artikel : [];
|
||||
if (artikel.length === 0) {
|
||||
return json(400, { ok: false, 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 json(400, { ok: false, error: "Ungültige Artikeldaten." });
|
||||
}
|
||||
if (!Number.isFinite(a.menge) || a.menge <= 0) {
|
||||
return json(400, { ok: false, error: "Ungültige Artikelmenge." });
|
||||
}
|
||||
if (!Number.isFinite(a.preis) || a.preis < 0) {
|
||||
return json(400, { ok: false, error: "Ungültiger Artikelpreis." });
|
||||
}
|
||||
}
|
||||
|
||||
const summe = Number(body.summe);
|
||||
if (!Number.isFinite(summe) || summe < 0) {
|
||||
return json(400, { ok: false, 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 (siehe d1/schema.sql, Spalte
|
||||
// "gutschein_code"). Stornierte Bestellungen zählen NICHT als "schon benutzt" — wurde ja
|
||||
// letztlich nicht wirksam eingelöst. ──
|
||||
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 json(400, {
|
||||
ok: false,
|
||||
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)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.bind(
|
||||
now,
|
||||
now,
|
||||
"bezahlt",
|
||||
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
|
||||
)
|
||||
.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 (anders als z.B. "COUNT(*) + 1" vorher zu berechnen).
|
||||
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 an VanVan/qciga — wird bewusst ABGEWARTET (nicht per
|
||||
// context.waitUntil im Hintergrund verschickt): ein Test am 03.08.2026 hat gezeigt, dass
|
||||
// waitUntil() in dieser Pages-Functions-Umgebung nicht zuverlässig zu Ende lief, bevor die
|
||||
// Anfrage beendet wurde (die Bestellung wurde gespeichert, aber die E-Mail kam nie an). Die
|
||||
// paar hundert Millisekunden zusätzliche Wartezeit beim Abschicken der Bestellung sind ein
|
||||
// kleiner Preis für "die E-Mail kommt garantiert an". Trotzdem weiterhin NICHT
|
||||
// fehlerkritisch: sendeEmail() wirft nie einen Fehler, schlägt der Versand fehl (z.B. weil
|
||||
// kein RESEND_API_KEY hinterlegt ist), bleibt die Bestellung trotzdem ganz normal gespeichert
|
||||
// und in /verwaltung/ sichtbar — nur die Benachrichtigung fehlt dann eben.
|
||||
await sendeEmail(env, {
|
||||
to: BENACHRICHTIGUNGS_EMAIL,
|
||||
subject: `🩵 Neue Bestellung ${bestellnummer}`,
|
||||
html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }),
|
||||
text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Details: https://vans-diy-bastelbedarf.pages.dev/verwaltung/`,
|
||||
const zahlungsart = String(body.zahlungsart || "");
|
||||
if (zahlungsart === "paypal") {
|
||||
return json(400, {
|
||||
ok: false,
|
||||
error: "PayPal-Bestellungen laufen über den PayPal-Button im Checkout, nicht über diese Route.",
|
||||
});
|
||||
|
||||
return json(201, { ok: true, id: orderId, bestellnummer });
|
||||
} catch (err) {
|
||||
return json(500, { ok: false, error: "Bestellung konnte nicht gespeichert werden." });
|
||||
}
|
||||
if (zahlungsart !== "ueberweisung") {
|
||||
return json(400, { ok: false, error: "Ungültige oder nicht unterstützte Zahlungsart." });
|
||||
}
|
||||
|
||||
const ergebnis = await erstelleBestellung(env, body, { status: "zahlungOffen" });
|
||||
if (!ergebnis.ok) {
|
||||
return json(ergebnis.httpStatus || 500, { ok: false, error: ergebnis.error });
|
||||
}
|
||||
return json(201, { ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* =====================================================================
|
||||
POST /api/paypal/capture-order — zieht eine zuvor angelegte PayPal-Zahlung EIN und legt die
|
||||
Bestellung erst danach an.
|
||||
|
||||
Das ist der entscheidende Sicherheits-Schritt der ganzen PayPal-Anbindung: Der Browser meldet
|
||||
hier "die Kundin hat bei PayPal auf Bezahlen geklickt" (`onApprove`-Callback), aber wir
|
||||
glauben das NICHT einfach — stattdessen fragen wir direkt bei PayPal selbst nach (server-zu-
|
||||
server, mit VanVans eigenen App-Zugangsdaten), ob die Zahlung wirklich abgeschlossen ist UND
|
||||
ob der eingezogene Betrag zur Bestellsumme passt. Nur wenn beides stimmt, wird die Bestellung
|
||||
in der Datenbank angelegt — mit Status "bezahlt" und der echten PayPal-Bestell-ID als Beleg.
|
||||
|
||||
Schlägt die Zahlungsprüfung fehl, wird NICHTS gespeichert — die Kundin sieht eine klare
|
||||
Fehlermeldung im Checkout und kann es erneut versuchen, statt dass VanVan eine "Bestellung"
|
||||
ohne echtes Geld dahinter bekommt. ===================================================================== */
|
||||
|
||||
import { json } from "../../_shared/http.js";
|
||||
import { paypalKonfiguriert, erfassePaypalZahlung } from "../../_shared/paypal.js";
|
||||
import { erstelleBestellung } from "../../_shared/bestellung-erstellen.js";
|
||||
|
||||
// Kleine Toleranz für Rundungsdifferenzen (Cent-Rundung zwischen unserer Berechnung und PayPals
|
||||
// eigener Darstellung) — alles darüber gilt als verdächtige Abweichung und wird abgelehnt.
|
||||
const BETRAG_TOLERANZ_EURO = 0.02;
|
||||
|
||||
export async function onRequestPost(context) {
|
||||
const { request, env } = context;
|
||||
|
||||
if (!paypalKonfiguriert(env)) {
|
||||
return json(503, { ok: false, error: "PayPal ist auf dieser Seite noch nicht eingerichtet." });
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json(400, { ok: false, error: "Ungültige Anfrage." });
|
||||
}
|
||||
|
||||
const paypalOrderId = typeof body.paypalOrderId === "string" ? body.paypalOrderId.trim() : "";
|
||||
if (!paypalOrderId) {
|
||||
return json(400, { ok: false, error: "Fehlende PayPal-Bestell-ID." });
|
||||
}
|
||||
|
||||
const summe = Number(body.summe);
|
||||
if (!Number.isFinite(summe) || summe <= 0) {
|
||||
return json(400, { ok: false, error: "Ungültige Bestellsumme." });
|
||||
}
|
||||
|
||||
// ── Schritt 1: Zahlung server-seitig bei PayPal einziehen + verifizieren. ──
|
||||
const zahlung = await erfassePaypalZahlung(env, paypalOrderId);
|
||||
if (!zahlung.ok) {
|
||||
return json(402, { ok: false, error: `Zahlung konnte nicht bestätigt werden: ${zahlung.error}` });
|
||||
}
|
||||
|
||||
// ── Schritt 2: Betrag gegenprüfen — verhindert, dass hier ein anderer (z.B. kleinerer) Betrag
|
||||
// als der tatsächlich fällige "durchgeschmuggelt" wird. ──
|
||||
if (Math.abs(zahlung.betragEuro - summe) > BETRAG_TOLERANZ_EURO) {
|
||||
return json(402, {
|
||||
ok: false,
|
||||
error: `Der bei PayPal gezahlte Betrag (${zahlung.betragEuro.toFixed(2)} €) stimmt nicht mit der Bestellsumme (${summe.toFixed(2)} €) überein.`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Schritt 3: Erst JETZT, mit bestätigter echter Zahlung, die Bestellung anlegen. ──
|
||||
const ergebnis = await erstelleBestellung(env, { ...body, zahlungsart: "paypal" }, {
|
||||
status: "bezahlt",
|
||||
paypalOrderId,
|
||||
});
|
||||
if (!ergebnis.ok) {
|
||||
// Die Zahlung ist zu diesem Zeitpunkt bereits bei PayPal eingezogen, konnte aber nicht
|
||||
// gespeichert werden (z.B. D1 kurzzeitig nicht erreichbar) — das ist ein seltener, aber
|
||||
// ernster Fall: VanVan hat das Geld, aber keine Bestellung in der Verwaltung. Deshalb bekommt
|
||||
// die Kundin eine Meldung, die sie NICHT zum erneuten Bezahlen auffordert, sondern zur
|
||||
// Kontaktaufnahme, um Doppelzahlungen zu vermeiden.
|
||||
return json(500, {
|
||||
ok: false,
|
||||
error: `Deine Zahlung wurde erfolgreich bei PayPal abgebucht (Beleg-ID: ${zahlung.captureId}), aber die Bestellung konnte nicht gespeichert werden. Bitte kontaktiere uns mit dieser Beleg-ID, damit wir das von Hand nachtragen — bitte NICHT erneut bezahlen.`,
|
||||
});
|
||||
}
|
||||
|
||||
return json(201, { ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* =====================================================================
|
||||
POST /api/paypal/create-order — legt eine echte PayPal-Bestellung server-seitig an.
|
||||
|
||||
Wird vom Checkout aufgerufen, bevor der PayPal-Smart-Button überhaupt anzeigt, was zu bezahlen
|
||||
ist (`createOrder`-Callback des PayPal-JS-SDK). Der Betrag kommt bewusst von UNS (aus dem im
|
||||
Checkout berechneten Gesamtbetrag, den der Browser hier mitschickt) und wird 1:1 an PayPal
|
||||
weitergereicht — die eigentliche Sicherheit entsteht erst beim Erfassen der Zahlung (siehe
|
||||
capture-order.js), wo der TATSÄCHLICH bei PayPal eingezogene Betrag serverseitig geprüft wird.
|
||||
===================================================================== */
|
||||
|
||||
import { json } from "../../_shared/http.js";
|
||||
import { paypalKonfiguriert, erstellePaypalBestellung } from "../../_shared/paypal.js";
|
||||
|
||||
export async function onRequestPost(context) {
|
||||
const { request, env } = context;
|
||||
|
||||
if (!paypalKonfiguriert(env)) {
|
||||
return json(503, {
|
||||
ok: false,
|
||||
error: "PayPal ist auf dieser Seite noch nicht eingerichtet (fehlende Zugangsdaten). Bitte eine andere Zahlungsart wählen.",
|
||||
});
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json(400, { ok: false, error: "Ungültige Anfrage." });
|
||||
}
|
||||
|
||||
const summe = Number(body.summe);
|
||||
if (!Number.isFinite(summe) || summe <= 0) {
|
||||
return json(400, { ok: false, error: "Ungültiger Betrag." });
|
||||
}
|
||||
|
||||
try {
|
||||
const paypalOrderId = await erstellePaypalBestellung(env, summe);
|
||||
return json(200, { ok: true, id: paypalOrderId });
|
||||
} catch (err) {
|
||||
return json(502, { ok: false, error: err instanceof Error ? err.message : "PayPal-Bestellung fehlgeschlagen." });
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
import { getGateRole, unauthorizedJson } from "../../../_shared/auth.js";
|
||||
import { json } from "../../../_shared/http.js";
|
||||
|
||||
const ERLAUBTE_STATUS = ["bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"];
|
||||
const ERLAUBTE_STATUS = ["zahlungOffen", "bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"];
|
||||
|
||||
export async function onRequestPatch(context) {
|
||||
const { request, env, params } = context;
|
||||
|
||||
Reference in New Issue
Block a user