From 7ee3eb967a51dea81af503d9db4ad4d04a7fcdde Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 12 May 2026 05:03:22 -0700 Subject: [PATCH] Add fake payment lifecycle API --- README.md | 25 ++++++++ lib/db/db-client.ts | 106 ++++++++++++++++++++++++++++++++-- lib/db/schema.ts | 22 +++++++ routes/payments/cancel.ts | 33 +++++++++++ routes/payments/complete.ts | 33 +++++++++++ routes/payments/get.ts | 35 +++++++++++ routes/payments/list.ts | 27 +++++++++ routes/payments/send.ts | 45 +++++++++++++++ tests/routes/payments.test.ts | 82 ++++++++++++++++++++++++++ 9 files changed, 403 insertions(+), 5 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..3dfa663 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,28 @@ 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 payment API + +The project includes a small in-memory fake payment lifecycle API: + +- `POST /payments/send` creates a pending payment and supports + `idempotency_key` for retry-safe sends. +- `GET /payments/list` lists payments and can filter by `recipient`, + `repository`, and `status`. +- `GET /payments/get?payment_id=pay_0` fetches one payment. +- `POST /payments/complete` marks a pending payment as completed. +- `POST /payments/cancel` marks a pending payment as canceled. + +Example send body: + +```json +{ + "recipient": "maintainer@example.com", + "amount": 10, + "currency": "USD", + "repository": "tscircuit/fake-algora", + "issue_number": 1, + "idempotency_key": "retry-safe-payment" +} +``` diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..7b30fe5 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,13 @@ -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" export const createDatabase = () => { return hoist(createStore(initializer)) @@ -11,7 +15,21 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +type NewPayment = Pick & + Partial< + Pick< + Payment, + | "currency" + | "bounty_id" + | "issue_number" + | "repository" + | "idempotency_key" + > + > + +const terminalStatuses = new Set(["completed", "canceled"]) + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +39,82 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + addPayment: (paymentInput: NewPayment) => { + const now = new Date().toISOString() + const payment: Payment = { + payment_id: `pay_${get().paymentCounter}`, + recipient: paymentInput.recipient, + amount: paymentInput.amount, + currency: paymentInput.currency ?? "USD", + status: "pending", + bounty_id: paymentInput.bounty_id, + issue_number: paymentInput.issue_number, + repository: paymentInput.repository, + idempotency_key: paymentInput.idempotency_key, + created_at: now, + updated_at: now, + } + + set((state) => ({ + payments: [...state.payments, payment], + paymentCounter: state.paymentCounter + 1, + })) + + return payment + }, + findPaymentById: (paymentId: string) => { + return get().payments.find((payment) => payment.payment_id === paymentId) + }, + findPaymentByIdempotencyKey: (idempotencyKey: string) => { + return get().payments.find( + (payment) => payment.idempotency_key === idempotencyKey, + ) + }, + listPayments: (filters?: { + recipient?: string + repository?: string + status?: PaymentStatus + }) => { + return get().payments.filter((payment) => { + if (filters?.recipient && payment.recipient !== filters.recipient) { + return false + } + if (filters?.repository && payment.repository !== filters.repository) { + return false + } + if (filters?.status && payment.status !== filters.status) { + return false + } + return true + }) + }, + transitionPayment: ( + paymentId: string, + status: Exclude, + ) => { + const payment = get().payments.find( + (existingPayment) => existingPayment.payment_id === paymentId, + ) + if (!payment) return undefined + if (terminalStatuses.has(payment.status)) return payment + + const now = new Date().toISOString() + const updatedPayment: Payment = { + ...payment, + status, + updated_at: now, + completed_at: status === "completed" ? now : payment.completed_at, + canceled_at: status === "canceled" ? now : payment.canceled_at, + } + + set((state) => ({ + payments: state.payments.map((existingPayment) => + existingPayment.payment_id === paymentId + ? updatedPayment + : existingPayment, + ), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..cc0f2a9 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,30 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum(["pending", "completed", "canceled"]) +export type PaymentStatus = z.infer + +export const paymentSchema = z.object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number().positive(), + currency: z.string(), + status: paymentStatusSchema, + bounty_id: z.string().optional(), + issue_number: z.number().int().positive().optional(), + repository: z.string().optional(), + idempotency_key: z.string().optional(), + created_at: z.string(), + updated_at: z.string(), + completed_at: z.string().optional(), + canceled_at: z.string().optional(), +}) +export type Payment = z.infer + export const databaseSchema = z.object({ idCounter: z.number().default(0), things: z.array(thingSchema).default([]), + paymentCounter: z.number().default(0), + 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..e96db15 --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,33 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const transitionPaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +const transitionPaymentResponseSchema = z.union([ + z.object({ + ok: z.literal(true), + payment: paymentSchema, + }), + z.object({ + ok: z.literal(false), + error: z.string(), + }), +]) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: transitionPaymentBodySchema, + jsonResponse: transitionPaymentResponseSchema, +})(async (req, ctx) => { + const { payment_id } = transitionPaymentBodySchema.parse(await req.json()) + const payment = ctx.db.transitionPayment(payment_id, "canceled") + + if (!payment) { + return ctx.json({ ok: false, error: "payment not found" }) + } + + return ctx.json({ ok: true, payment }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..58df119 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,33 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const transitionPaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +const transitionPaymentResponseSchema = z.union([ + z.object({ + ok: z.literal(true), + payment: paymentSchema, + }), + z.object({ + ok: z.literal(false), + error: z.string(), + }), +]) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: transitionPaymentBodySchema, + jsonResponse: transitionPaymentResponseSchema, +})(async (req, ctx) => { + const { payment_id } = transitionPaymentBodySchema.parse(await req.json()) + const payment = ctx.db.transitionPayment(payment_id, "completed") + + if (!payment) { + return ctx.json({ ok: false, error: "payment not found" }) + } + + return ctx.json({ ok: true, payment }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..fcae0c4 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,35 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const getPaymentResponseSchema = z.union([ + z.object({ + ok: z.literal(true), + payment: paymentSchema, + }), + z.object({ + ok: z.literal(false), + error: z.string(), + }), +]) + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: getPaymentResponseSchema, +})((req, ctx) => { + const url = new URL(req.url) + const paymentId = url.searchParams.get("payment_id") + if (!paymentId) { + return ctx.json({ + ok: false, + error: "payment_id query parameter is required", + }) + } + + const payment = ctx.db.findPaymentById(paymentId) + if (!payment) { + return ctx.json({ ok: false, 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..d69a32f --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,27 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const listPaymentsQuerySchema = z.object({ + recipient: z.string().min(1).optional(), + repository: z.string().min(1).optional(), + status: paymentStatusSchema.optional(), +}) + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payments: z.array(paymentSchema), + }), +})((req, ctx) => { + const url = new URL(req.url) + const query = listPaymentsQuerySchema.parse({ + recipient: url.searchParams.get("recipient") ?? undefined, + repository: url.searchParams.get("repository") ?? undefined, + status: url.searchParams.get("status") ?? undefined, + }) + + return ctx.json({ + payments: ctx.db.listPayments(query), + }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..4d18d8a --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,45 @@ +import { paymentSchema } from "lib/db/schema" +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().min(3).max(8).default("USD"), + bounty_id: z.string().min(1).optional(), + issue_number: z.number().int().positive().optional(), + repository: z.string().min(1).optional(), + idempotency_key: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: z.object({ + ok: z.boolean(), + idempotent_replay: z.boolean(), + payment: paymentSchema, + }), +})(async (req, ctx) => { + const body = sendPaymentBodySchema.parse(await req.json()) + + if (body.idempotency_key) { + const existingPayment = ctx.db.findPaymentByIdempotencyKey( + body.idempotency_key, + ) + if (existingPayment) { + return ctx.json({ + ok: true, + idempotent_replay: true, + payment: existingPayment, + }) + } + } + + const payment = ctx.db.addPayment({ + ...body, + currency: body.currency.toUpperCase(), + }) + + return ctx.json({ ok: true, idempotent_replay: false, payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..61ce75f --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send, list, get, and complete a payment", async () => { + const { axios } = await getTestServer() + + const sendResponse = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + currency: "usd", + bounty_id: "fake-algora-1", + issue_number: 1, + repository: "tscircuit/fake-algora", + idempotency_key: "retry-safe-payment", + }) + + expect(sendResponse.data.ok).toBe(true) + expect(sendResponse.data.idempotent_replay).toBe(false) + expect(sendResponse.data.payment.payment_id).toBe("pay_0") + expect(sendResponse.data.payment.currency).toBe("USD") + expect(sendResponse.data.payment.status).toBe("pending") + + const replayResponse = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + idempotency_key: "retry-safe-payment", + }) + + expect(replayResponse.data.idempotent_replay).toBe(true) + expect(replayResponse.data.payment.payment_id).toBe("pay_0") + + const filteredListResponse = await axios.get( + "/payments/list?recipient=maintainer@example.com&status=pending", + ) + expect(filteredListResponse.data.payments).toHaveLength(1) + + const getResponse = await axios.get("/payments/get?payment_id=pay_0") + expect(getResponse.data.ok).toBe(true) + expect(getResponse.data.payment.repository).toBe("tscircuit/fake-algora") + + const completeResponse = await axios.post("/payments/complete", { + payment_id: "pay_0", + }) + + expect(completeResponse.data.ok).toBe(true) + expect(completeResponse.data.payment.status).toBe("completed") + expect(completeResponse.data.payment.completed_at).toBeString() +}) + +test("cancel leaves terminal payments unchanged", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient: "alice", + amount: 5, + }) + await axios.post("/payments/complete", { + payment_id: "pay_0", + }) + + const cancelResponse = await axios.post("/payments/cancel", { + payment_id: "pay_0", + }) + + expect(cancelResponse.data.ok).toBe(true) + expect(cancelResponse.data.payment.status).toBe("completed") + expect(cancelResponse.data.payment.canceled_at).toBeUndefined() +}) + +test("payment lookup reports missing identifiers", async () => { + const { axios } = await getTestServer() + + const missingQueryResponse = await axios.get("/payments/get") + expect(missingQueryResponse.data.ok).toBe(false) + expect(missingQueryResponse.data.error).toContain("payment_id") + + const missingPaymentResponse = await axios.post("/payments/complete", { + payment_id: "missing", + }) + expect(missingPaymentResponse.data.ok).toBe(false) + expect(missingPaymentResponse.data.error).toBe("payment not found") +})