From ae190fa60de14df1e31320ec9449fde6b72b3a59 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 15 Sep 2026 18:15:42 +0200 Subject: [PATCH] fix: advance orders only after matching paid Stripe events --- app/api/payments/[order_id]/deposit/route.ts | 4 +- app/api/payments/[order_id]/release/route.ts | 5 +- app/api/webhooks/stripe/route.ts | 34 +++++++- docs/architecture/ARCHITECTURE.md | 4 + ...ripe-balance-no-premature-delivery.test.ts | 25 ++++++ tests/api/stripe-deposit-metadata.test.ts | 34 ++++++++ tests/api/stripe-webhook-payment.test.ts | 78 +++++++++++++++++++ 7 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 tests/api/stripe-balance-no-premature-delivery.test.ts create mode 100644 tests/api/stripe-deposit-metadata.test.ts create mode 100644 tests/api/stripe-webhook-payment.test.ts diff --git a/app/api/payments/[order_id]/deposit/route.ts b/app/api/payments/[order_id]/deposit/route.ts index b732790..33715ec 100644 --- a/app/api/payments/[order_id]/deposit/route.ts +++ b/app/api/payments/[order_id]/deposit/route.ts @@ -31,6 +31,8 @@ export async function POST( "line_items[0][price_data][unit_amount]": String(depositOre), "line_items[0][quantity]": "1", mode: "payment", + "metadata[order_id]": order_id, + "metadata[kind]": "deposit", success_url: `${origin}/order/${order_id}?deposit=success`, cancel_url: `${origin}/bestall/forskott?order=${order_id}`, }); @@ -51,4 +53,4 @@ export async function POST( const session = (await stripeRes.json()) as { url: string }; return NextResponse.json({ url: session.url }); -} \ No newline at end of file +} diff --git a/app/api/payments/[order_id]/release/route.ts b/app/api/payments/[order_id]/release/route.ts index 888a2d0..d781e57 100644 --- a/app/api/payments/[order_id]/release/route.ts +++ b/app/api/payments/[order_id]/release/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getStripe } from "@/lib/stripe"; -import { getOrder, setStatus } from "@/lib/orders-store"; +import { getOrder } from "@/lib/orders-store"; // POST /api/payments/{order_id}/release (docs/04) — charge the held 50% balance. // Called on ACCEPT only; escrow balance is never charged before approval. @@ -35,6 +35,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ order_id: cancel_url: `${origin}/order/${order_id}/validera`, metadata: { order_id, kind: "balance" }, }); - await setStatus(order_id, "delivered"); + // Creating a Checkout Session is not payment. Only a verified paid webhook + // may advance the order to delivered. return NextResponse.json({ checkout_url: session.url }); } diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 784f705..6638702 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; +import type Stripe from "stripe"; import { getStripe } from "@/lib/stripe"; -import { setStatus } from "@/lib/orders-store"; +import { getOrder, setStatus } from "@/lib/orders-store"; // POST /api/webhooks/stripe (docs/04) — verify signature, advance order status. export async function POST(req: NextRequest) { @@ -23,10 +24,37 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "bad_signature" }, { status: 400 }); } if (event.type === "checkout.session.completed") { - const s = event.data.object as { metadata?: { order_id?: string; kind?: string } }; + const s = event.data.object as Stripe.Checkout.Session; const oid = s.metadata?.order_id; const kind = s.metadata?.kind; - if (oid) await setStatus(oid, kind === "balance" ? "delivered" : "in_production"); + if (!oid || (kind !== "deposit" && kind !== "balance")) { + return NextResponse.json({ error: "unmatched_session" }, { status: 422 }); + } + if (s.payment_status !== "paid") { + return NextResponse.json({ error: "payment_not_confirmed" }, { status: 503 }); + } + const order = await getOrder(oid); + if (!order) return NextResponse.json({ error: "order_not_found" }, { status: 503 }); + const expectedAmount = kind === "balance" ? order.balance_ore : order.deposit_ore; + if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== "sek") { + return NextResponse.json({ error: "payment_mismatch" }, { status: 422 }); + } + if (kind === "balance") { + if (order.status !== "accepted" && order.status !== "delivered") { + return NextResponse.json({ error: "invalid_order_state" }, { status: 409 }); + } + if (order.status === "accepted" && !(await setStatus(oid, "delivered"))) { + return NextResponse.json({ error: "order_update_failed" }, { status: 503 }); + } + } else { + const alreadyAdvanced = ["in_production", "review", "revision", "accepted", "delivered"].includes(order.status); + if (order.status !== "draft" && order.status !== "deposit_pending" && !alreadyAdvanced) { + return NextResponse.json({ error: "invalid_order_state" }, { status: 409 }); + } + if (!alreadyAdvanced && !(await setStatus(oid, "in_production"))) { + return NextResponse.json({ error: "order_update_failed" }, { status: 503 }); + } + } } return NextResponse.json({ received: true }); } diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 6ff8aeb..f4fe72f 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -345,6 +345,10 @@ Om/när Postgres provisioneras måste detta lösas separat — jfr estate-blocke - **Stripe-webhooken** verifierar signatur mot `STRIPE_WEBHOOK_SECRET`. Om Stripe-konfiguration saknas svarar den nu `503` i stället för att kvittera ett obearbetat event med `200`; saknad signatur ger `400`. + Deposit-checkout bär nu order-id/kind i sessionsmetadata. Webhooken + flyttar bara en order efter verifierad signatur, `payment_status=paid`, + matchande belopp och valuta. En skapad slutbetalnings-Checkout markerar + inte längre ordern `delivered` innan betalt event har verifierats. - `SECURITY.md` finns med rapporteringsväg (`security@siax.io`). ### KÄNDA LUCKOR diff --git a/tests/api/stripe-balance-no-premature-delivery.test.ts b/tests/api/stripe-balance-no-premature-delivery.test.ts new file mode 100644 index 0000000..6ae1bad --- /dev/null +++ b/tests/api/stripe-balance-no-premature-delivery.test.ts @@ -0,0 +1,25 @@ +import { expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ create: vi.fn(), setStatus: vi.fn() })); +vi.mock("@/lib/stripe", () => ({ + getStripe: () => ({ checkout: { sessions: { create: mocks.create } } }), +})); +vi.mock("@/lib/orders-store", () => ({ + getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }), + setStatus: mocks.setStatus, +})); + +import { POST } from "@/app/api/payments/[order_id]/release/route"; + +it("does not mark delivery complete merely by creating a balance Checkout Session", async () => { + mocks.create.mockResolvedValue({ url: "https://checkout.stripe.com/test" }); + const req = new NextRequest("https://0pt.app/api/payments/order-1/release", { method: "POST" }); + + const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) }); + expect(res.status).toBe(200); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ + metadata: { order_id: "order-1", kind: "balance" }, + })); + expect(mocks.setStatus).not.toHaveBeenCalled(); +}); diff --git a/tests/api/stripe-deposit-metadata.test.ts b/tests/api/stripe-deposit-metadata.test.ts new file mode 100644 index 0000000..aa40223 --- /dev/null +++ b/tests/api/stripe-deposit-metadata.test.ts @@ -0,0 +1,34 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/orders-store", () => ({ + getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }), +})); + +import { POST } from "@/app/api/payments/[order_id]/deposit/route"; + +const savedKey = process.env.STRIPE_SECRET_KEY; +const savedFetch = globalThis.fetch; + +afterEach(() => { + if (savedKey === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = savedKey; + globalThis.fetch = savedFetch; +}); + +it("binds a deposit Checkout Session to its order and payment kind", async () => { + process.env.STRIPE_SECRET_KEY = "sk_test_fake_local_regression"; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ url: "https://checkout.stripe.com/test" }), + }); + globalThis.fetch = mockFetch; + + const req = new NextRequest("https://0pt.app/api/payments/order-1/deposit", { method: "POST" }); + const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) }); + expect(res.status).toBe(200); + const init = mockFetch.mock.calls[0]?.[1] as RequestInit; + const body = init.body as URLSearchParams; + expect(body.get("metadata[order_id]")).toBe("order-1"); + expect(body.get("metadata[kind]")).toBe("deposit"); +}); diff --git a/tests/api/stripe-webhook-payment.test.ts b/tests/api/stripe-webhook-payment.test.ts new file mode 100644 index 0000000..146eac2 --- /dev/null +++ b/tests/api/stripe-webhook-payment.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + constructEvent: vi.fn(), + getOrder: vi.fn(), + setStatus: vi.fn(), +})); + +vi.mock("@/lib/stripe", () => ({ + getStripe: () => ({ webhooks: { constructEvent: mocks.constructEvent } }), +})); +vi.mock("@/lib/orders-store", () => ({ + getOrder: mocks.getOrder, + setStatus: mocks.setStatus, +})); + +import { POST } from "@/app/api/webhooks/stripe/route"; + +const savedWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + +beforeEach(() => { + process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake_local_regression"; + mocks.constructEvent.mockReset(); + mocks.getOrder.mockReset(); + mocks.setStatus.mockReset(); + mocks.setStatus.mockResolvedValue({ order_id: "order-1" }); +}); + +afterEach(() => { + if (savedWebhookSecret === undefined) delete process.env.STRIPE_WEBHOOK_SECRET; + else process.env.STRIPE_WEBHOOK_SECRET = savedWebhookSecret; +}); + +function delivery() { + return new NextRequest("https://0pt.app/api/webhooks/stripe", { + method: "POST", + headers: { "stripe-signature": "signed-in-test" }, + body: "{}", + }); +} + +function session(kind: "deposit" | "balance", paymentStatus: string, amount: number) { + mocks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: { + metadata: { order_id: "order-1", kind }, + payment_status: paymentStatus, + amount_total: amount, + currency: "sek", + } }, + }); +} + +it("advances a matching paid deposit only after a verified event", async () => { + session("deposit", "paid", 5000); + mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 }); + + expect((await POST(delivery())).status).toBe(200); + expect(mocks.setStatus).toHaveBeenCalledWith("order-1", "in_production"); +}); + +it("never advances an unpaid or amount-mismatched deposit", async () => { + mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 }); + session("deposit", "unpaid", 5000); + expect((await POST(delivery())).status).toBe(503); + session("deposit", "paid", 4999); + expect((await POST(delivery())).status).toBe(422); + expect(mocks.setStatus).not.toHaveBeenCalled(); +}); + +it("marks a matching paid balance delivered only after acceptance", async () => { + session("balance", "paid", 5000); + mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "accepted", deposit_ore: 5000, balance_ore: 5000 }); + + expect((await POST(delivery())).status).toBe(200); + expect(mocks.setStatus).toHaveBeenCalledWith("order-1", "delivered"); +});