Add Stripe payment processing (cards + PayPal) with a webhook-gated checkout flow
Checkout now branches on payment-methods.provider: Überweisung stays immediate/unchanged, Kreditkarte/PayPal creates a pending_payment order, mounts Stripe's Payment Element, and defers invoice/email to a webhook- verified confirm-payment call once the backend actually confirms payment. Includes a PAYMENT_TEST_MODE mock provider so the whole gated pipeline is exercisable locally without a real Stripe account. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+128
-57
@@ -12,6 +12,7 @@ import { normalizeVatId, isValidVatId } from "../../lib/vatId";
|
||||
import { checkVatIdViaVies } from "../../lib/vies";
|
||||
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
|
||||
import { upsertNewsletterContact } from "../../lib/brevo";
|
||||
import { paymentProvider, isPaymentTestMode } from "../../lib/payments";
|
||||
|
||||
// Plain float arithmetic on money (quantity × unitPrice summed across
|
||||
// lines, a percent discount, subtracting/adding those together) drifts
|
||||
@@ -281,6 +282,35 @@ export async function POST(request: Request) {
|
||||
const finalShippingCost = exemptTotals?.shippingCost ?? shippingCost;
|
||||
const total = roundMoney(Math.max(0, finalSubtotal - discountAmount) + finalShippingCost);
|
||||
|
||||
// Gated-payment branch (Kreditkarte/PayPal today) — see
|
||||
// spicy-leaping-pizza.md §3. The PaymentIntent is created BEFORE the
|
||||
// order so its id can be persisted onto the order at creation time
|
||||
// (providerReference), rather than needing a second authenticated
|
||||
// update call that doesn't otherwise exist from this service. Stripe
|
||||
// generates a PaymentIntent id independent of any order existing yet.
|
||||
const requiresPayment = paymentMethod.provider === "stripe";
|
||||
let providerReference: string | undefined;
|
||||
let clientSecret: string | undefined;
|
||||
if (requiresPayment) {
|
||||
try {
|
||||
const intent = await paymentProvider.createPaymentIntent({
|
||||
amountCents: Math.round(total * 100),
|
||||
currency: "eur",
|
||||
customerEmail: body.email,
|
||||
description: `einfach produktiv Bestellung — ${body.firstName} ${body.lastName}`,
|
||||
});
|
||||
providerReference = intent.providerReference;
|
||||
clientSecret = intent.clientSecret;
|
||||
} catch (err) {
|
||||
sendCriticalAlert("Zahlung konnte nicht vorbereitet werden", {
|
||||
customerEmail: body.email,
|
||||
total,
|
||||
error: String(err),
|
||||
});
|
||||
return NextResponse.json({ ok: false, reason: "Die Zahlung konnte gerade nicht vorbereitet werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const order = await createOrder({
|
||||
customerId: customer.id,
|
||||
customerFirstName: body.firstName,
|
||||
@@ -317,6 +347,9 @@ export async function POST(request: Request) {
|
||||
discountCode: body.discountCode || null,
|
||||
discountAmount,
|
||||
total,
|
||||
...(requiresPayment
|
||||
? { status: "pending_payment" as const, paymentProvider: "stripe" as const, paymentStatus: "pending" as const, providerReference }
|
||||
: {}),
|
||||
});
|
||||
if (!order) {
|
||||
// The worst-case failure in this whole flow: the customer went
|
||||
@@ -334,68 +367,91 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
// Fire-and-forget — a failed confirmation email must never undo an
|
||||
// already-successful order or block the response the customer is
|
||||
// waiting on. Lower severity than the "order lost" alert above (the
|
||||
// order itself is safe either way), but still worth knowing about, since
|
||||
// it's the one thing that would otherwise fail completely silently.
|
||||
sendOrderConfirmationEmail(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
createdAt: order.createdAt,
|
||||
invoiceNumber: order.invoiceNumber,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
imageUrl: i.imageUrl,
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
customerEmail: body.email,
|
||||
error: String(err),
|
||||
if (requiresPayment && providerReference) {
|
||||
// Best-effort — see stripeProvider.attachOrderMetadata's own comment.
|
||||
// Not fatal: the order's own `providerReference` field (already
|
||||
// persisted above) remains the source of truth for the
|
||||
// expirePendingPayments cleanup job either way; this only speeds up
|
||||
// the webhook's fast path.
|
||||
await paymentProvider.attachOrderMetadata(providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch((err) => {
|
||||
sendCriticalAlert("Zahlungsmetadaten konnten nicht verknüpft werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
providerReference,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Deferred for gated payment methods (Kreditkarte/PayPal) until the
|
||||
// webhook confirms payment — see spicy-leaping-pizza.md §3/§4. Sent
|
||||
// from the backend's confirm-payment endpoint instead, at that point.
|
||||
// Unchanged for Überweisung: fires immediately, exactly as before.
|
||||
if (!requiresPayment) {
|
||||
// Fire-and-forget — a failed confirmation email must never undo an
|
||||
// already-successful order or block the response the customer is
|
||||
// waiting on. Lower severity than the "order lost" alert above (the
|
||||
// order itself is safe either way), but still worth knowing about, since
|
||||
// it's the one thing that would otherwise fail completely silently.
|
||||
sendOrderConfirmationEmail(
|
||||
{
|
||||
orderNumber: order.orderNumber,
|
||||
createdAt: order.createdAt,
|
||||
invoiceNumber: order.invoiceNumber as string,
|
||||
invoiceIssuedAt: order.invoiceIssuedAt as string,
|
||||
customerFirstName: body.firstName,
|
||||
customerLastName: body.lastName,
|
||||
companyName: body.companyName || undefined,
|
||||
vatId: normalizedVatId,
|
||||
vatExempt,
|
||||
kleinunternehmer,
|
||||
deliveryMethod: body.deliveryMethod,
|
||||
street: body.street,
|
||||
packstationNumber: body.packstationNumber,
|
||||
postNumber: body.postNumber,
|
||||
zip: body.zip,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
|
||||
shippingFirstName: body.shippingFirstName,
|
||||
shippingLastName: body.shippingLastName,
|
||||
shippingDeliveryMethod: body.shippingDeliveryMethod,
|
||||
shippingStreet: body.shippingStreet,
|
||||
shippingPackstationNumber: body.shippingPackstationNumber,
|
||||
shippingPostNumber: body.shippingPostNumber,
|
||||
shippingZip: body.shippingZip,
|
||||
shippingCity: body.shippingCity,
|
||||
shippingCountry: body.shippingCountry,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
items: items.map((i) => ({
|
||||
productName: i.productName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
imageUrl: i.imageUrl,
|
||||
taxRatePercent: i.taxRatePercent,
|
||||
bundleContents: i.bundleContents,
|
||||
variantName: i.variantName,
|
||||
})),
|
||||
subtotal: finalSubtotal,
|
||||
shippingCost: finalShippingCost,
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
sendCriticalAlert("Bestätigungs-Mail konnte nicht gesendet werden", {
|
||||
orderNumber: order.orderNumber,
|
||||
customerEmail: body.email,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget, same reasoning as the confirmation email above — a
|
||||
// failed marketing sync is not worth failing checkout over, and doesn't
|
||||
// even need a critical alert (nothing customer-facing depends on it).
|
||||
// Not gated on payment confirmation — a newsletter signup intent isn't
|
||||
// an order-fulfillment concern, unlike the confirmation email/invoice.
|
||||
if (body.newsletterOptIn) {
|
||||
upsertNewsletterContact(body.email, "checkout").catch(() => {});
|
||||
}
|
||||
@@ -403,7 +459,22 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orderNumber: order.orderNumber,
|
||||
orderId: order.id,
|
||||
orderDateIso: order.createdAt,
|
||||
...(requiresPayment
|
||||
? {
|
||||
requiresPayment: true as const,
|
||||
clientSecret,
|
||||
testMode: isPaymentTestMode,
|
||||
// Only surfaced in test mode — PaymentStep's "Testzahlung"
|
||||
// buttons need it to call the test-confirm route directly,
|
||||
// since there's no real Stripe redirect to carry it back
|
||||
// through. A real PaymentIntent id isn't secret (only its
|
||||
// client_secret is), but there's no reason to expose it to the
|
||||
// client outside test mode either.
|
||||
...(isPaymentTestMode ? { providerReference } : {}),
|
||||
}
|
||||
: {}),
|
||||
shippingCost: finalShippingCost,
|
||||
paymentMethodTitle: paymentMethod.title,
|
||||
discountCode: body.discountCode || null,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerOrderDetail } from "../../../lib/customerAuth";
|
||||
|
||||
// Polled by /checkout/verarbeitung after a Payment Element redirect
|
||||
// returns — see spicy-leaping-pizza.md §3. Requires the customer's own
|
||||
// session (checkout is "Konto Pflicht", so one always exists by the time
|
||||
// this page is reachable) rather than accepting a bare orderNumber, so a
|
||||
// guessed/leaked order number can't be used to probe another customer's
|
||||
// payment status.
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const orderNumber = new URL(request.url).searchParams.get("orderNumber");
|
||||
if (!orderNumber) return NextResponse.json({ ok: false, reason: "orderNumber fehlt." }, { status: 400 });
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, orderNumber);
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ ok: true, status: order.status, paymentStatus: order.paymentStatus });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { verifyStripeWebhookSignature } from "../../../lib/payments/stripeProvider";
|
||||
import { sendCriticalAlert } from "../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Real Stripe webhook — see spicy-leaping-pizza.md §4. Never reachable in
|
||||
// PAYMENT_TEST_MODE in practice (no real Stripe account sends events
|
||||
// here then), but left unconditional rather than gated on the env var —
|
||||
// an invalid/missing signature already fails closed on its own.
|
||||
export async function POST(request: Request) {
|
||||
// Raw body only — request.json() would consume/reparse the stream and
|
||||
// Stripe's signature is computed over the exact original bytes.
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!signature) return NextResponse.json({ ok: false }, { status: 400 });
|
||||
|
||||
const event = verifyStripeWebhookSignature(rawBody, signature);
|
||||
if (!event) return NextResponse.json({ ok: false, reason: "invalid signature" }, { status: 400 });
|
||||
|
||||
if (event.type !== "payment_intent.succeeded" && event.type !== "payment_intent.payment_failed") {
|
||||
// Stripe sends many event types we don't act on (e.g.
|
||||
// payment_intent.created, charge.*) — ack them so Stripe stops
|
||||
// retrying something we were never going to process.
|
||||
return NextResponse.json({ ok: true, ignored: event.type });
|
||||
}
|
||||
|
||||
const intent = event.data.object as Stripe.PaymentIntent;
|
||||
const providerReference = intent.id;
|
||||
const orderId = intent.metadata?.orderId;
|
||||
const paymentStatus = event.type === "payment_intent.succeeded" ? "paid" : "failed";
|
||||
|
||||
if (!orderId) {
|
||||
// stripeProvider.attachOrderMetadata (called right after order
|
||||
// creation in /api/checkout) failed to complete for this
|
||||
// PaymentIntent — the order's own `providerReference` field is still
|
||||
// the source of truth and expirePendingPayments will reconcile it
|
||||
// eventually, but that's a multi-hour fallback, not instant. Alert
|
||||
// now rather than silently relying on the cleanup job.
|
||||
sendCriticalAlert("Stripe-Webhook ohne orderId-Metadaten", { providerReference, paymentStatus, eventType: event.type });
|
||||
// Non-2xx so Stripe retries — a later retry might land after the
|
||||
// metadata attach (which races the checkout response) has caught up.
|
||||
return NextResponse.json({ ok: false, reason: "orderId metadata missing" }, { status: 409 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paymentStatus,
|
||||
providerReference,
|
||||
paidAt: new Date().toISOString(),
|
||||
}),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("confirm-payment-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
// Non-2xx on purpose — lets Stripe's own retry schedule (~3 days)
|
||||
// provide resilience instead of building an internal retry queue.
|
||||
return NextResponse.json({ ok: false }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isPaymentTestMode } from "../../../../lib/payments";
|
||||
import { sendCriticalAlert } from "../../../../lib/alertAdmin";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
|
||||
|
||||
// Test-mode stand-in for the real Stripe webhook — see
|
||||
// spicy-leaping-pizza.md §7. Drives the exact same backend confirm-payment
|
||||
// endpoint the real webhook calls, just without a real Stripe event/
|
||||
// signature (there is none to verify in test mode). Hard-gated: must
|
||||
// 404 whenever PAYMENT_TEST_MODE isn't explicitly on, so this can never
|
||||
// become an unauthenticated "mark any order paid" endpoint in production.
|
||||
export async function POST(request: Request) {
|
||||
if (!isPaymentTestMode) {
|
||||
return NextResponse.json({ ok: false }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const orderId = body?.orderId;
|
||||
const providerReference = body?.providerReference;
|
||||
const paymentStatus = body?.paymentStatus === "failed" ? "failed" : "paid";
|
||||
if (!orderId || !providerReference) {
|
||||
return NextResponse.json({ ok: false, reason: "orderId und providerReference erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}/confirm-payment`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ paymentStatus, providerReference, paidAt: new Date().toISOString() }),
|
||||
}).catch((err) => {
|
||||
sendCriticalAlert("Test-confirm-Aufruf ans Backend fehlgeschlagen", { orderId, providerReference, error: String(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) {
|
||||
return NextResponse.json({ ok: false, reason: "Backend hat die Testzahlung nicht bestätigt." }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user