From 4f49104ab44e9fe5d611081e44e58e5d135f51ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=A3=E5=85=83=E5=BE=81?= Date: Tue, 12 May 2026 21:36:26 +0800 Subject: [PATCH] Add fake payment lifecycle routes with idempotent send support --- README.md | 8 +++++ lib/db/db-client.ts | 50 +++++++++++++++++++++++++++++-- lib/db/schema.ts | 23 +++++++++++++++ routes/payments/cancel.ts | 46 +++++++++++++++++++++++++++++ routes/payments/complete.ts | 46 +++++++++++++++++++++++++++++ routes/payments/get.ts | 45 ++++++++++++++++++++++++++++ routes/payments/list.ts | 26 +++++++++++++++++ routes/payments/send.ts | 55 +++++++++++++++++++++++++++++++++++ tests/routes/payments.test.ts | 49 +++++++++++++++++++++++++++++++ 9 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 routes/payments/cancel.ts create mode 100644 routes/payments/complete.ts create mode 100644 routes/payments/get.ts create mode 100644 routes/payments/list.ts create mode 100644 routes/payments/send.ts create mode 100644 tests/routes/payments.test.ts diff --git a/README.md b/README.md index 824427a..0a2f713 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,11 @@ This is a template project with best-practice modules: - Winterspec for defining the API - bun testing - Zustand store with zod definition for database state + +## Payment Routes + +- `POST /payments/send` - create a fake payment record +- `GET /payments/list` - list fake payment records +- `POST /payments/get` - fetch a payment by id +- `POST /payments/complete` - mark a payment as completed +- `POST /payments/cancel` - mark a payment as canceled diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..ec32a2b 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -2,7 +2,13 @@ import { createStore, type StoreApi } from "zustand/vanilla" import { immer } from "zustand/middleware/immer" import { hoist, type HoistedStoreApi } from "zustand-hoist" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" +import { + databaseSchema, + type DatabaseSchema, + type Payment, + type PaymentStatus, + type Thing, +} from "./schema.ts" import { combine } from "zustand/middleware" export const createDatabase = () => { @@ -11,7 +17,7 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +27,44 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + addPayment: ( + payment: Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" | "metadata" + > & { + metadata?: Record + }, + ) => { + const now = new Date().toISOString() + set((state) => ({ + payments: [ + ...state.payments, + { + ...payment, + payment_id: state.idCounter.toString(), + status: "pending", + metadata: payment.metadata ?? {}, + created_at: now, + updated_at: now, + }, + ], + idCounter: state.idCounter + 1, + })) + }, + findPaymentById: (payment_id: string) => { + return get().payments.find((p) => p.payment_id === payment_id) + }, + findPaymentByIdempotencyKey: (idempotency_key: string) => { + return get().payments.find((p) => p.idempotency_key === idempotency_key) + }, + updatePaymentStatus: (payment_id: string, status: PaymentStatus) => { + const now = new Date().toISOString() + set((state) => ({ + payments: state.payments.map((payment) => + payment.payment_id === payment_id + ? { ...payment, status, updated_at: now } + : payment, + ), + })) + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..78c65bd 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,31 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum([ + "pending", + "completed", + "canceled", + "failed", +]) +export type PaymentStatus = z.infer + +export const paymentSchema = z.object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number().positive(), + currency: z.string().default("USD"), + status: paymentStatusSchema.default("pending"), + idempotency_key: z.string().optional(), + bounty_issue: z.string().optional(), + metadata: z.record(z.string(), z.string()).default({}), + created_at: z.string(), + updated_at: z.string(), +}) +export type Payment = z.infer + export const databaseSchema = z.object({ idCounter: z.number().default(0), things: z.array(thingSchema).default([]), + payments: z.array(paymentSchema).default([]), }) export type DatabaseSchema = z.infer diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..bb8d25d --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,46 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const updatePaymentSchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentSchema, + jsonResponse: z.object({ + ok: z.boolean(), + payment: z + .object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number(), + currency: z.string(), + status: z.enum(["pending", "completed", "canceled", "failed"]), + idempotency_key: z.string().optional(), + bounty_issue: z.string().optional(), + metadata: z.record(z.string(), z.string()), + created_at: z.string(), + updated_at: z.string(), + }) + .nullable(), + error: z.string().optional(), + }), +})(async (req, ctx) => { + const { payment_id } = updatePaymentSchema.parse(await req.json()) + const payment = ctx.db.findPaymentById(payment_id) + if (!payment) { + return ctx.json({ + ok: false, + payment: null, + error: "payment_not_found", + }) + } + + ctx.db.updatePaymentStatus(payment_id, "canceled") + + return ctx.json({ + ok: true, + payment: ctx.db.findPaymentById(payment_id)!, + }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..758c3ea --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,46 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const updatePaymentSchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentSchema, + jsonResponse: z.object({ + ok: z.boolean(), + payment: z + .object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number(), + currency: z.string(), + status: z.enum(["pending", "completed", "canceled", "failed"]), + idempotency_key: z.string().optional(), + bounty_issue: z.string().optional(), + metadata: z.record(z.string(), z.string()), + created_at: z.string(), + updated_at: z.string(), + }) + .nullable(), + error: z.string().optional(), + }), +})(async (req, ctx) => { + const { payment_id } = updatePaymentSchema.parse(await req.json()) + const payment = ctx.db.findPaymentById(payment_id) + if (!payment) { + return ctx.json({ + ok: false, + payment: null, + error: "payment_not_found", + }) + } + + ctx.db.updatePaymentStatus(payment_id, "completed") + + return ctx.json({ + ok: true, + payment: ctx.db.findPaymentById(payment_id)!, + }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..6b67094 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,45 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const getPaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: getPaymentBodySchema, + jsonResponse: z.object({ + ok: z.boolean(), + payment: z + .object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number(), + currency: z.string(), + status: z.enum(["pending", "completed", "canceled", "failed"]), + idempotency_key: z.string().optional(), + bounty_issue: z.string().optional(), + metadata: z.record(z.string(), z.string()), + created_at: z.string(), + updated_at: z.string(), + }) + .nullable(), + error: z.string().optional(), + }), +})(async (req, ctx) => { + const { payment_id } = getPaymentBodySchema.parse(await req.json()) + const payment = ctx.db.findPaymentById(payment_id) + + if (!payment) { + return ctx.json({ + ok: false, + payment: null, + error: "payment_not_found", + }) + } + + return ctx.json({ + ok: true, + payment, + }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..af4848a --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,26 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payments: z.array( + z.object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number(), + currency: z.string(), + status: z.enum(["pending", "completed", "canceled", "failed"]), + idempotency_key: z.string().optional(), + bounty_issue: z.string().optional(), + metadata: z.record(z.string(), z.string()), + created_at: z.string(), + updated_at: z.string(), + }), + ), + }), +})(async (_req, ctx) => { + return ctx.json({ + payments: ctx.db.getState().payments, + }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..1d315ec --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,55 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const sendPaymentBodySchema = z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().default("USD"), + idempotency_key: z.string().optional(), + bounty_issue: z.string().optional(), + metadata: z.record(z.string(), z.string()).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: z.object({ + ok: z.boolean(), + payment: z.object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number(), + currency: z.string(), + status: z.enum(["pending", "completed", "canceled", "failed"]), + idempotency_key: z.string().optional(), + bounty_issue: z.string().optional(), + metadata: z.record(z.string(), z.string()), + created_at: z.string(), + updated_at: z.string(), + }), + idempotent_replay: z.boolean().optional(), + }), +})(async (req, ctx) => { + const body = sendPaymentBodySchema.parse(await req.json()) + + if (body.idempotency_key) { + const existing = ctx.db.findPaymentByIdempotencyKey(body.idempotency_key) + if (existing) { + return ctx.json({ + ok: true, + payment: existing, + idempotent_replay: true, + }) + } + } + + ctx.db.addPayment(body) + const payment = body.idempotency_key + ? ctx.db.findPaymentByIdempotencyKey(body.idempotency_key) + : ctx.db.getState().payments.at(-1) + + return ctx.json({ + ok: true, + payment: payment!, + }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..a2326f0 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("payment lifecycle routes work and support idempotent send", async () => { + const { axios } = await getTestServer() + + const sendBody = { + recipient: "dev@example.com", + amount: 30, + currency: "USD", + idempotency_key: "issue-1-send-001", + bounty_issue: "#1", + metadata: { + source: "algora", + }, + } + + const firstSend = await axios.post("/payments/send", sendBody) + expect(firstSend.data.ok).toBe(true) + expect(firstSend.data.payment.status).toBe("pending") + + const replaySend = await axios.post("/payments/send", sendBody) + expect(replaySend.data.ok).toBe(true) + expect(replaySend.data.idempotent_replay).toBe(true) + expect(replaySend.data.payment.payment_id).toBe( + firstSend.data.payment.payment_id, + ) + + const paymentId = firstSend.data.payment.payment_id + + const listRes = await axios.get("/payments/list") + expect(listRes.data.payments).toHaveLength(1) + + const getRes = await axios.post("/payments/get", { payment_id: paymentId }) + expect(getRes.data.ok).toBe(true) + expect(getRes.data.payment.payment_id).toBe(paymentId) + + const completeRes = await axios.post("/payments/complete", { + payment_id: paymentId, + }) + expect(completeRes.data.ok).toBe(true) + expect(completeRes.data.payment.status).toBe("completed") + + const cancelRes = await axios.post("/payments/cancel", { + payment_id: paymentId, + }) + expect(cancelRes.data.ok).toBe(true) + expect(cancelRes.data.payment.status).toBe("canceled") +})