fix: advance orders only after matching paid Stripe events
masterplan-lock / check (pull_request) Successful in 5s
masterplan-lock / check (push) Successful in 8s
CI (SIAX Cloud) / security (push) Successful in 18s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / integration (push) Successful in 28s
CI (SIAX Cloud) / quality (push) Successful in 36s
CI (SIAX Cloud) / integration (pull_request) Successful in 48s
CI (SIAX Cloud) / quality (pull_request) Successful in 56s

This commit is contained in:
2026-09-15 18:15:42 +02:00
parent 8911709931
commit ae190fa60d
7 changed files with 178 additions and 6 deletions
+3 -1
View File
@@ -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 });
}
}
+3 -2
View File
@@ -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 });
}
+31 -3
View File
@@ -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 });
}
+4
View File
@@ -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
@@ -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();
});
+34
View File
@@ -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");
});
+78
View File
@@ -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");
});