From 77402350d18c6a27c3d7b122d7d700169e18fb5f Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 21 May 2026 01:46:05 +0200 Subject: [PATCH] Add fake payment flow --- README.md | 7 +++ lib/db/db-client.ts | 72 +++++++++++++++++++++- lib/db/schema.ts | 23 +++++++ routes/payments/get.ts | 28 +++++++++ routes/payments/list.ts | 29 +++++++++ routes/payments/send.ts | 22 +++++++ routes/payments/update-status.ts | 27 ++++++++ tests/routes/payments/payment-flow.test.ts | 62 +++++++++++++++++++ types/dist-bundle.d.ts | 4 ++ 9 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 routes/payments/get.ts create mode 100644 routes/payments/list.ts create mode 100644 routes/payments/send.ts create mode 100644 routes/payments/update-status.ts create mode 100644 tests/routes/payments/payment-flow.test.ts create mode 100644 types/dist-bundle.d.ts diff --git a/README.md b/README.md index 824427a..61390e2 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,10 @@ 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 + +- `POST /payments/send` creates a pending fake payment and replays the same record when `idempotency_key` is reused. +- `GET /payments/list` lists payments, with optional `recipient`, `status`, and `bounty_issue` filters. +- `GET /payments/get?payment_id=...` returns one payment. +- `POST /payments/update-status` changes a payment to `pending`, `completed`, `canceled`, or `failed`. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..d8c18c4 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,15 @@ -import { createStore, type StoreApi } from "zustand/vanilla" +import { type HoistedStoreApi, hoist } from "zustand-hoist" import { immer } from "zustand/middleware/immer" -import { hoist, type HoistedStoreApi } from "zustand-hoist" +import { type StoreApi, createStore } from "zustand/vanilla" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" import { combine } from "zustand/middleware" +import { + type DatabaseSchema, + type Payment, + type PaymentStatus, + type Thing, + databaseSchema, +} from "./schema.ts" export const createDatabase = () => { return hoist(createStore(initializer)) @@ -21,4 +27,64 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: ( + payment: Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" + >, + ) => { + let nextPayment: Payment | undefined + set((state) => { + if (payment.idempotency_key) { + const existingPayment = state.payments.find( + (existing) => existing.idempotency_key === payment.idempotency_key, + ) + if (existingPayment) { + nextPayment = existingPayment + return state + } + } + + const now = new Date().toISOString() + nextPayment = { + ...payment, + payment_id: state.paymentIdCounter.toString(), + status: "pending", + created_at: now, + updated_at: now, + } + + return { + payments: [...state.payments, nextPayment], + paymentIdCounter: state.paymentIdCounter + 1, + } + }) + + return nextPayment! + }, + updatePaymentStatus: ({ + payment_id, + status, + }: { + payment_id: string + status: PaymentStatus + }) => { + let updatedPayment: Payment | undefined + set((state) => ({ + payments: state.payments.map((payment) => { + if (payment.payment_id !== payment_id) { + return payment + } + + updatedPayment = { + ...payment, + status, + updated_at: new Date().toISOString(), + } + return updatedPayment + }), + })) + + return updatedPayment ?? null + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..a70870b 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(), + bounty_issue: z.string().optional(), + idempotency_key: z.string().optional(), + status: paymentStatusSchema.default("pending"), + created_at: z.string(), + updated_at: z.string(), +}) +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/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..9763de1 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,28 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: z.object({ + payment_id: z.string().min(1), + }), + jsonResponse: z.union([ + z.object({ payment: paymentSchema }), + z.object({ error: z.string() }), + ]), +})((req, ctx) => { + const paymentId = new URL(req.url).searchParams.get("payment_id") + const payment = ctx.db.payments.find( + (existing) => existing.payment_id === paymentId, + ) + + if (!payment) { + return new Response(JSON.stringify({ error: "Payment not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..62dc546 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,29 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: z.object({ + recipient: z.string().optional(), + status: paymentStatusSchema.optional(), + bounty_issue: z.string().optional(), + }), + jsonResponse: z.object({ + payments: z.array(paymentSchema), + }), +})((req, ctx) => { + const url = new URL(req.url) + const recipient = url.searchParams.get("recipient") + const status = url.searchParams.get("status") + const bountyIssue = url.searchParams.get("bounty_issue") + + const payments = ctx.db.payments.filter((payment) => { + if (recipient && payment.recipient !== recipient) return false + if (status && payment.status !== status) return false + if (bountyIssue && payment.bounty_issue !== bountyIssue) return false + return true + }) + + return ctx.json({ payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..3ed74a5 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,22 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(1).default("USD"), + bounty_issue: z.string().min(1).optional(), + idempotency_key: z.string().min(1).optional(), + }), + jsonResponse: z.object({ + payment: paymentSchema, + }), +})(async (req, ctx) => { + const body = await req.json() + const payment = ctx.db.sendPayment(body) + + return ctx.json({ payment }) +}) diff --git a/routes/payments/update-status.ts b/routes/payments/update-status.ts new file mode 100644 index 0000000..6116592 --- /dev/null +++ b/routes/payments/update-status.ts @@ -0,0 +1,27 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + payment_id: z.string().min(1), + status: paymentStatusSchema, + }), + jsonResponse: z.union([ + z.object({ payment: paymentSchema }), + z.object({ error: z.string() }), + ]), +})(async (req, ctx) => { + const body = await req.json() + const payment = ctx.db.updatePaymentStatus(body) + + if (!payment) { + return new Response(JSON.stringify({ error: "Payment not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }) + } + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments/payment-flow.test.ts b/tests/routes/payments/payment-flow.test.ts new file mode 100644 index 0000000..3c15fda --- /dev/null +++ b/tests/routes/payments/payment-flow.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send, read, list, and update a fake payment", async () => { + const { axios } = await getTestServer() + + const sendResponse = await axios.post("/payments/send", { + recipient: "octocat", + amount: 10, + currency: "USD", + bounty_issue: "tscircuit/fake-algora#1", + idempotency_key: "claim-1-pr-1", + }) + + expect(sendResponse.status).toBe(200) + expect(sendResponse.data.payment).toMatchObject({ + payment_id: "0", + recipient: "octocat", + amount: 10, + currency: "USD", + bounty_issue: "tscircuit/fake-algora#1", + status: "pending", + idempotency_key: "claim-1-pr-1", + }) + + const replayResponse = await axios.post("/payments/send", { + recipient: "octocat", + amount: 10, + currency: "USD", + bounty_issue: "tscircuit/fake-algora#1", + idempotency_key: "claim-1-pr-1", + }) + + expect(replayResponse.data.payment.payment_id).toBe("0") + + const getResponse = await axios.get("/payments/get?payment_id=0") + expect(getResponse.data.payment.status).toBe("pending") + + const completeResponse = await axios.post("/payments/update-status", { + payment_id: "0", + status: "completed", + }) + expect(completeResponse.data.payment).toMatchObject({ + payment_id: "0", + status: "completed", + }) + + const listResponse = await axios.get("/payments/list?status=completed") + expect(listResponse.data.payments).toHaveLength(1) + expect(listResponse.data.payments[0].payment_id).toBe("0") +}) + +test("missing fake payments return a 404", async () => { + const { axios } = await getTestServer() + + const response = await axios.get("/payments/get?payment_id=missing", { + validateStatus: () => true, + }) + + expect(response.status).toBe(404) + expect(response.data).toEqual({ error: "Payment not found" }) +}) diff --git a/types/dist-bundle.d.ts b/types/dist-bundle.d.ts new file mode 100644 index 0000000..2c8832e --- /dev/null +++ b/types/dist-bundle.d.ts @@ -0,0 +1,4 @@ +declare module "dist/bundle" { + const bundle: unknown + export default bundle +}