From 654390786604b49b00d7276b907cd6d0d50b7198 Mon Sep 17 00:00:00 2001 From: thepianistdirector <78630787+thepianistdirector@users.noreply.github.com> Date: Tue, 19 May 2026 21:25:33 -0300 Subject: [PATCH] feat: add fake payment API --- README.md | 17 +++++ lib/db/db-client.ts | 114 ++++++++++++++++++++++++++++++++-- lib/db/schema.ts | 20 ++++++ routes/payments/cancel.ts | 54 ++++++++++++++++ routes/payments/complete.ts | 54 ++++++++++++++++ routes/payments/get.ts | 36 +++++++++++ routes/payments/list.ts | 26 ++++++++ routes/payments/send.ts | 27 ++++++++ tests/routes/payments.test.ts | 67 ++++++++++++++++++++ 9 files changed, 411 insertions(+), 4 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..142f84a 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 payment API + +The API includes an in-memory payment flow for testing bounty-like payment +lifecycle behavior. + +- `POST /payments/send` creates a pending payment. The request accepts + `recipient`, `amount`, optional `currency`, optional bounty metadata, and an + optional `idempotency_key` for retry-safe replay. +- `GET /payments/list` returns payments and supports `recipient`, `status`, + `repository`, `bounty_id`, and `issue_number` query filters. +- `GET /payments/get?payment_id=...` returns one payment or + `payment_not_found`. +- `POST /payments/complete` marks a pending payment completed. +- `POST /payments/cancel` marks a pending payment canceled. + +Completed and canceled payments cannot be transitioned again. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..9e77cb9 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)) @@ -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,104 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + createPayment: ( + payment: Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" + >, + ) => { + const now = new Date().toISOString() + const matchingPayment = payment.idempotency_key + ? get().payments.find( + (existingPayment) => + existingPayment.idempotency_key === payment.idempotency_key, + ) + : undefined + + if (matchingPayment) { + return matchingPayment + } + + let createdPayment!: Payment + set((state) => { + createdPayment = { + ...payment, + payment_id: state.paymentIdCounter.toString(), + status: "pending", + created_at: now, + updated_at: now, + } + + return { + payments: [...state.payments, createdPayment], + paymentIdCounter: state.paymentIdCounter + 1, + } + }) + + return createdPayment + }, + listPayments: (filters?: { + recipient?: string + status?: PaymentStatus + repository?: string + bounty_id?: string + issue_number?: string + }) => { + return get().payments.filter((payment) => { + if (filters?.recipient && payment.recipient !== filters.recipient) { + return false + } + if (filters?.status && payment.status !== filters.status) { + return false + } + if (filters?.repository && payment.repository !== filters.repository) { + return false + } + if (filters?.bounty_id && payment.bounty_id !== filters.bounty_id) { + return false + } + if ( + filters?.issue_number && + payment.issue_number !== filters.issue_number + ) { + return false + } + return true + }) + }, + getPayment: (payment_id: string) => { + return get().payments.find((payment) => payment.payment_id === payment_id) + }, + updatePaymentStatus: (payment_id: string, status: PaymentStatus) => { + let updatedPayment: Payment | undefined + set((state) => { + const existingPayment = state.payments.find( + (payment) => payment.payment_id === payment_id, + ) + + if (!existingPayment) { + return state + } + + if (existingPayment.status !== "pending") { + updatedPayment = existingPayment + return state + } + + const now = new Date().toISOString() + updatedPayment = { + ...existingPayment, + status, + updated_at: now, + } + + return { + payments: state.payments.map((payment) => + payment.payment_id === payment_id ? updatedPayment! : payment, + ), + } + }) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..41098ee 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,28 @@ 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.string().optional(), + repository: z.string().optional(), + idempotency_key: z.string().optional(), + 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([]), + paymentIdCounter: 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..c976b50 --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,54 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const statusBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +const responseSchema = z.union([ + z.object({ + ok: z.literal(true), + payment: paymentSchema, + }), + z.object({ + ok: z.literal(false), + error: z.literal("payment_not_found"), + }), + z.object({ + ok: z.literal(false), + error: z.literal("payment_not_pending"), + payment: paymentSchema, + }), +]) + +const jsonResponse = (body: z.infer, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + }, + }) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: statusBodySchema, + jsonResponse: responseSchema, +})(async (req, ctx) => { + const { payment_id } = statusBodySchema.parse(await req.json()) + const existingPayment = ctx.db.getPayment(payment_id) + + if (!existingPayment) { + return jsonResponse({ ok: false, error: "payment_not_found" }, 404) + } + + if (existingPayment.status !== "pending") { + return jsonResponse( + { ok: false, error: "payment_not_pending", payment: existingPayment }, + 409, + ) + } + + const payment = ctx.db.updatePaymentStatus(payment_id, "canceled") + return jsonResponse({ ok: true, payment: payment! }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..7101e83 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,54 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const statusBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +const responseSchema = z.union([ + z.object({ + ok: z.literal(true), + payment: paymentSchema, + }), + z.object({ + ok: z.literal(false), + error: z.literal("payment_not_found"), + }), + z.object({ + ok: z.literal(false), + error: z.literal("payment_not_pending"), + payment: paymentSchema, + }), +]) + +const jsonResponse = (body: z.infer, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + }, + }) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: statusBodySchema, + jsonResponse: responseSchema, +})(async (req, ctx) => { + const { payment_id } = statusBodySchema.parse(await req.json()) + const existingPayment = ctx.db.getPayment(payment_id) + + if (!existingPayment) { + return jsonResponse({ ok: false, error: "payment_not_found" }, 404) + } + + if (existingPayment.status !== "pending") { + return jsonResponse( + { ok: false, error: "payment_not_pending", payment: existingPayment }, + 409, + ) + } + + const payment = ctx.db.updatePaymentStatus(payment_id, "completed") + return jsonResponse({ ok: true, payment: payment! }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..54b4e34 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,36 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const responseSchema = z.union([ + z.object({ + ok: z.literal(true), + payment: paymentSchema, + }), + z.object({ + ok: z.literal(false), + error: z.literal("payment_not_found"), + }), +]) + +const jsonResponse = (body: z.infer, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + }, + }) + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: responseSchema, +})((req, ctx) => { + const paymentId = new URL(req.url).searchParams.get("payment_id") + const payment = paymentId ? ctx.db.getPayment(paymentId) : undefined + + if (!payment) { + return jsonResponse({ ok: false, error: "payment_not_found" }, 404) + } + + return jsonResponse({ ok: true, payment }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..091e6aa --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,26 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payments: z.array(paymentSchema), + }), +})((req, ctx) => { + const searchParams = new URL(req.url).searchParams + const statusParam = searchParams.get("status") + const status = statusParam + ? paymentStatusSchema.parse(statusParam) + : undefined + + const payments = ctx.db.listPayments({ + recipient: searchParams.get("recipient") ?? undefined, + status, + repository: searchParams.get("repository") ?? undefined, + bounty_id: searchParams.get("bounty_id") ?? undefined, + issue_number: searchParams.get("issue_number") ?? undefined, + }) + + return ctx.json({ payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..c0c0a79 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,27 @@ +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(1).default("USD"), + bounty_id: z.string().min(1).optional(), + issue_number: z.string().min(1).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(), + payment: paymentSchema, + }), +})(async (req, ctx) => { + const body = sendPaymentBodySchema.parse(await req.json()) + const payment = ctx.db.createPayment(body) + + return ctx.json({ ok: true, payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..eea8972 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send, list, complete, and get fake payments", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "alice", + amount: 10, + currency: "USD", + bounty_id: "fake-algora-1", + issue_number: "1", + repository: "tscircuit/fake-algora", + idempotency_key: "send-alice-10", + }) + + expect(sendData.payment.payment_id).toBe("0") + expect(sendData.payment.status).toBe("pending") + + const { data: replayData } = await axios.post("/payments/send", { + recipient: "alice", + amount: 10, + currency: "USD", + idempotency_key: "send-alice-10", + }) + + expect(replayData.payment.payment_id).toBe(sendData.payment.payment_id) + + const { data: listData } = await axios.get( + "/payments/list?recipient=alice&status=pending", + ) + + expect(listData.payments).toHaveLength(1) + + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: sendData.payment.payment_id, + }) + + expect(completeData.payment.status).toBe("completed") + + const { data: getData } = await axios.get( + `/payments/get?payment_id=${sendData.payment.payment_id}`, + ) + + expect(getData.payment.status).toBe("completed") +}) + +test("cancel rejects already completed fake payments", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "bob", + amount: 5, + }) + + await axios.post("/payments/complete", { + payment_id: sendData.payment.payment_id, + }) + + await expect( + axios.post("/payments/cancel", { + payment_id: sendData.payment.payment_id, + }), + ).rejects.toMatchObject({ + status: 409, + }) +})