From ea940ae35155fa160b9c85fc2674ade0f1e1c658 Mon Sep 17 00:00:00 2001 From: firewine Date: Wed, 20 May 2026 15:10:43 +0900 Subject: [PATCH 1/2] add fake payments API --- README.md | 17 ++++++ lib/db/db-client.ts | 111 ++++++++++++++++++++++++++++++++-- lib/db/schema.ts | 28 +++++++++ lib/payments/schemas.ts | 61 +++++++++++++++++++ routes/payments/cancel.ts | 20 ++++++ routes/payments/complete.ts | 20 ++++++ routes/payments/fail.ts | 20 ++++++ routes/payments/get.ts | 16 +++++ routes/payments/list.ts | 23 +++++++ routes/payments/send.ts | 35 +++++++++++ tests/routes/payments.test.ts | 48 +++++++++++++++ 11 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 lib/payments/schemas.ts create mode 100644 routes/payments/cancel.ts create mode 100644 routes/payments/complete.ts create mode 100644 routes/payments/fail.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..0002e61 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,20 @@ This is a template project with best-practice modules: - Winterspec for defining the API - bun testing - Zustand store with zod definition for database state + +## Fake Payments API + +The fake payment API can be used by local tests or demos that need a payment +lifecycle without talking to a real provider. + +- `POST /payments/send` creates a fake payment. Send + `recipient_email`, either `amount_cents` or `amount_usd`, and optional + `currency`, `bounty_issue_url`, `note`, and `idempotency_key`. +- `GET /payments/list` returns payments. Optional filters: + `recipient_email` and `status`. +- `GET /payments/get?payment_id=` returns one payment. +- `POST /payments/complete`, `POST /payments/cancel`, and + `POST /payments/fail` move a payment into a terminal state. + +Repeated `POST /payments/send` calls with the same `idempotency_key` return the +original payment, which makes retrying a fake send safe. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..03bdea0 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,26 @@ -import { createStore, type StoreApi } from "zustand/vanilla" -import { immer } from "zustand/middleware/immer" -import { hoist, type HoistedStoreApi } from "zustand-hoist" +import { hoist } from "zustand-hoist" +import { createStore } from "zustand/vanilla" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" import { combine } from "zustand/middleware" +import { + type Payment, + type PaymentStatus, + type Thing, + databaseSchema, +} from "./schema.ts" + +type CreatePaymentInput = Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" | "sent_at" +> + +const terminalPaymentStatuses = new Set([ + "completed", + "canceled", + "failed", +]) + +const nowIso = () => new Date().toISOString() export const createDatabase = () => { return hoist(createStore(initializer)) @@ -11,7 +28,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 +38,88 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: (input: CreatePaymentInput) => { + let payment!: Payment + set((state) => { + if (input.idempotency_key) { + const existing = state.payments.find( + (item) => item.idempotency_key === input.idempotency_key, + ) + if (existing) { + payment = existing + return state + } + } + + const timestamp = nowIso() + payment = { + ...input, + payment_id: state.paymentIdCounter.toString(), + currency: input.currency.toLowerCase(), + status: "sent", + created_at: timestamp, + updated_at: timestamp, + sent_at: timestamp, + } + + return { + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + } + }) + return payment + }, + listPayments: (filters?: { + recipient_email?: string | null + status?: PaymentStatus | null + }) => { + return get().payments.filter((payment) => { + if ( + filters?.recipient_email && + payment.recipient_email !== filters.recipient_email + ) { + return false + } + if (filters?.status && payment.status !== filters.status) { + return false + } + return true + }) + }, + getPayment: (payment_id: string) => { + return get().payments.find((payment) => payment.payment_id === payment_id) + }, + updatePaymentStatus: ( + payment_id: string, + status: Extract, + ) => { + let payment: Payment | null = null + set((state) => { + const existing = state.payments.find( + (item) => item.payment_id === payment_id, + ) + if (!existing) return state + if (terminalPaymentStatuses.has(existing.status)) { + payment = existing + return state + } + + const timestamp = nowIso() + const updatedPayment: Payment = { + ...existing, + status, + updated_at: timestamp, + ...(status === "completed" ? { completed_at: timestamp } : {}), + ...(status === "canceled" ? { canceled_at: timestamp } : {}), + ...(status === "failed" ? { failed_at: timestamp } : {}), + } + payment = updatedPayment + return { + payments: state.payments.map((item) => + item.payment_id === payment_id ? updatedPayment : item, + ), + } + }) + return payment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..3b7d67d 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,36 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum([ + "sent", + "completed", + "canceled", + "failed", +]) +export type PaymentStatus = z.infer + +export const paymentSchema = z.object({ + payment_id: z.string(), + recipient_email: z.string(), + amount_cents: z.number().int(), + currency: z.string(), + status: paymentStatusSchema, + bounty_issue_url: z.string().optional(), + note: z.string().optional(), + idempotency_key: z.string().optional(), + created_at: z.string(), + updated_at: z.string(), + sent_at: z.string(), + completed_at: z.string().optional(), + canceled_at: z.string().optional(), + failed_at: z.string().optional(), +}) +export type Payment = z.infer + export const databaseSchema = z.object({ idCounter: z.number().default(0), + paymentIdCounter: z.number().default(0), things: z.array(thingSchema).default([]), + payments: z.array(paymentSchema).default([]), }) export type DatabaseSchema = z.infer diff --git a/lib/payments/schemas.ts b/lib/payments/schemas.ts new file mode 100644 index 0000000..128f83a --- /dev/null +++ b/lib/payments/schemas.ts @@ -0,0 +1,61 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { z } from "zod" + +export const sendPaymentRequestSchema = z + .object({ + recipient_email: z.string().email(), + amount_cents: z.number().int().positive().optional(), + amount_usd: z.number().positive().optional(), + currency: z.string().min(1).default("usd"), + bounty_issue_url: z.string().url().optional(), + note: z.string().optional(), + idempotency_key: z.string().min(1).optional(), + }) + .refine( + (body) => body.amount_cents !== undefined || body.amount_usd !== undefined, + "amount_cents or amount_usd is required", + ) + +export const paymentResponseSchema = z.object({ + payment: paymentSchema, +}) + +export const errorResponseSchema = z.object({ + error: z.unknown(), +}) + +export const paymentRouteResponseSchema = z.union([ + paymentResponseSchema, + errorResponseSchema, +]) + +export const paymentListResponseSchema = z.object({ + payments: z.array(paymentSchema), +}) + +export const getPaymentRequestSchema = z.object({ + payment_id: z.string().min(1), +}) + +export const updatePaymentStatusRequestSchema = z.object({ + payment_id: z.string().min(1), +}) + +export const updatePaymentStatusResponseSchema = z.object({ + payment: paymentSchema, +}) + +export const updatePaymentStatusRouteResponseSchema = z.union([ + updatePaymentStatusResponseSchema, + errorResponseSchema, +]) + +export type SendPaymentRequest = z.infer +export type PaymentStatus = z.infer + +export function toAmountCents(body: SendPaymentRequest): number { + if (body.amount_cents !== undefined) return body.amount_cents + return Math.round(body.amount_usd! * 100) +} + +export { paymentStatusSchema } diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..da6a77b --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,20 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + updatePaymentStatusRequestSchema, + updatePaymentStatusRouteResponseSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentStatusRequestSchema, + jsonResponse: updatePaymentStatusRouteResponseSchema, +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.updatePaymentStatus(payment_id, "canceled") + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..1defc22 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,20 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + updatePaymentStatusRequestSchema, + updatePaymentStatusRouteResponseSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentStatusRequestSchema, + jsonResponse: updatePaymentStatusRouteResponseSchema, +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.updatePaymentStatus(payment_id, "completed") + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/fail.ts b/routes/payments/fail.ts new file mode 100644 index 0000000..4858acb --- /dev/null +++ b/routes/payments/fail.ts @@ -0,0 +1,20 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + updatePaymentStatusRequestSchema, + updatePaymentStatusRouteResponseSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentStatusRequestSchema, + jsonResponse: updatePaymentStatusRouteResponseSchema, +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.updatePaymentStatus(payment_id, "failed") + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..0deac10 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,16 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentRouteResponseSchema } from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: paymentRouteResponseSchema, +})((req, ctx) => { + const url = new URL(req.url) + const payment = ctx.db.getPayment(url.searchParams.get("payment_id") ?? "") + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..4309c79 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,23 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentListResponseSchema, + paymentStatusSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: paymentListResponseSchema, +})((req, ctx) => { + const url = new URL(req.url) + const status = paymentStatusSchema + .nullable() + .catch(null) + .parse(url.searchParams.get("status")) + + const payments = ctx.db.listPayments({ + recipient_email: url.searchParams.get("recipient_email"), + status, + }) + + return ctx.json({ payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..8c675c9 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,35 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentRouteResponseSchema, + sendPaymentRequestSchema, + toAmountCents, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentRequestSchema, + jsonResponse: paymentRouteResponseSchema, +})(async (req, ctx) => { + const body = await req.json() + const parsed = sendPaymentRequestSchema.safeParse(body) + + if (!parsed.success) { + return ctx.json( + { error: parsed.error.flatten() }, + { + status: 400, + }, + ) + } + + const payment = ctx.db.sendPayment({ + recipient_email: parsed.data.recipient_email, + amount_cents: toAmountCents(parsed.data), + currency: parsed.data.currency, + bounty_issue_url: parsed.data.bounty_issue_url, + note: parsed.data.note, + idempotency_key: parsed.data.idempotency_key, + }) + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..1df2a35 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send, list, get, and complete a fake payment", async () => { + const { axios } = await getTestServer() + + const sendResponse = await axios.post("/payments/send", { + recipient_email: "solver@example.com", + amount_usd: 10.5, + bounty_issue_url: "https://github.com/tscircuit/fake-algora/issues/1", + idempotency_key: "issue-1-solver@example.com", + }) + + expect(sendResponse.data.payment).toMatchObject({ + payment_id: "0", + recipient_email: "solver@example.com", + amount_cents: 1050, + currency: "usd", + status: "sent", + bounty_issue_url: "https://github.com/tscircuit/fake-algora/issues/1", + }) + + const replayResponse = await axios.post("/payments/send", { + recipient_email: "solver@example.com", + amount_usd: 10.5, + idempotency_key: "issue-1-solver@example.com", + }) + + expect(replayResponse.data.payment.payment_id).toBe("0") + + const listResponse = await axios.get( + "/payments/list?recipient_email=solver@example.com", + ) + expect(listResponse.data.payments).toHaveLength(1) + + const getResponse = await axios.get("/payments/get?payment_id=0") + expect(getResponse.data.payment.recipient_email).toBe("solver@example.com") + + const completeResponse = await axios.post("/payments/complete", { + payment_id: "0", + }) + expect(completeResponse.data.payment.status).toBe("completed") + + const cancelAfterCompleteResponse = await axios.post("/payments/cancel", { + payment_id: "0", + }) + expect(cancelAfterCompleteResponse.data.payment.status).toBe("completed") +}) From e5a39dca4d8a5dd6d8cf2f9f85ab84440e4fb706 Mon Sep 17 00:00:00 2001 From: firewine Date: Wed, 20 May 2026 15:40:20 +0900 Subject: [PATCH 2/2] Reject invalid payment list status filters --- lib/payments/schemas.ts | 5 +++++ routes/payments/list.ts | 27 ++++++++++++++++++++------- tests/routes/payments.test.ts | 11 +++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/lib/payments/schemas.ts b/lib/payments/schemas.ts index 128f83a..b5f9ed4 100644 --- a/lib/payments/schemas.ts +++ b/lib/payments/schemas.ts @@ -33,6 +33,11 @@ export const paymentListResponseSchema = z.object({ payments: z.array(paymentSchema), }) +export const paymentListRouteResponseSchema = z.union([ + paymentListResponseSchema, + errorResponseSchema, +]) + export const getPaymentRequestSchema = z.object({ payment_id: z.string().min(1), }) diff --git a/routes/payments/list.ts b/routes/payments/list.ts index 4309c79..e546cc8 100644 --- a/routes/payments/list.ts +++ b/routes/payments/list.ts @@ -1,22 +1,35 @@ import { withRouteSpec } from "lib/middleware/with-winter-spec" import { - paymentListResponseSchema, + paymentListRouteResponseSchema, paymentStatusSchema, } from "lib/payments/schemas" export default withRouteSpec({ methods: ["GET"], - jsonResponse: paymentListResponseSchema, + jsonResponse: paymentListRouteResponseSchema, })((req, ctx) => { const url = new URL(req.url) - const status = paymentStatusSchema - .nullable() - .catch(null) - .parse(url.searchParams.get("status")) + const statusParam = url.searchParams.get("status") + const parsedStatus = + statusParam === null + ? { success: true as const, data: null } + : paymentStatusSchema.safeParse(statusParam) + + if (!parsedStatus.success) { + return ctx.json( + { + error: { + status: `Invalid payment status: ${statusParam}`, + expected: paymentStatusSchema.options, + }, + }, + { status: 400 }, + ) + } const payments = ctx.db.listPayments({ recipient_email: url.searchParams.get("recipient_email"), - status, + status: parsedStatus.data, }) return ctx.json({ payments }) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts index 1df2a35..7a61b1c 100644 --- a/tests/routes/payments.test.ts +++ b/tests/routes/payments.test.ts @@ -33,6 +33,17 @@ test("send, list, get, and complete a fake payment", async () => { ) expect(listResponse.data.payments).toHaveLength(1) + const invalidStatusResponse = await axios.get( + "/payments/list?status=complete", + { + validateStatus: () => true, + }, + ) + expect(invalidStatusResponse.status).toBe(400) + expect(invalidStatusResponse.data.error.status).toContain( + "Invalid payment status", + ) + const getResponse = await axios.get("/payments/get?payment_id=0") expect(getResponse.data.payment.recipient_email).toBe("solver@example.com")