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
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user