diff --git a/README.md b/README.md index 824427a..360a35e 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,24 @@ 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 project includes a small in-memory payment API suitable for fake bounty/payment flows: + +- `POST /payments/send` — create a pending fake payment. Supports `idempotency_key` so retries return the original payment. +- `GET /payments/list` — list payments, optionally filtered by `status` or `bounty_id`. +- `GET /payments/get?payment_id=...` — fetch a single payment. +- `POST /payments/update-status` — update a payment to `pending`, `completed`, `cancelled`, or `failed`. + +Example send body: + +```json +{ + "recipient": "maintainer@example.com", + "amount": 10, + "currency": "USD", + "bounty_id": "tscircuit/fake-algora#1", + "idempotency_key": "bounty-1-payment" +} +``` diff --git a/bun.lockb b/bun.lockb index 557d79c..7e3c28a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..dad62c1 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,8 +1,12 @@ -import { createStore, type StoreApi } from "zustand/vanilla" -import { immer } from "zustand/middleware/immer" -import { hoist, type HoistedStoreApi } from "zustand-hoist" +import { createStore } from "zustand/vanilla" +import { hoist } from "zustand-hoist" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" +import { + databaseSchema, + type Payment, + type PaymentStatus, + type Thing, +} from "./schema.ts" import { combine } from "zustand/middleware" export const createDatabase = () => { @@ -11,7 +15,9 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +const nowIso = () => new Date().toISOString() + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +27,49 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + + createPayment: ( + payment: Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" + >, + ) => { + const existing = payment.idempotency_key + ? get().payments.find((p) => p.idempotency_key === payment.idempotency_key) + : undefined + + if (existing) return existing + + const timestamp = nowIso() + const newPayment: Payment = { + ...payment, + payment_id: get().paymentIdCounter.toString(), + status: "pending", + created_at: timestamp, + updated_at: timestamp, + } + + set((state) => ({ + payments: [...state.payments, newPayment], + paymentIdCounter: state.paymentIdCounter + 1, + })) + + return newPayment + }, + + updatePaymentStatus: (paymentId: string, status: PaymentStatus) => { + let updatedPayment: Payment | undefined + set((state) => ({ + payments: state.payments.map((payment) => { + if (payment.payment_id !== paymentId) return payment + updatedPayment = { + ...payment, + status, + updated_at: nowIso(), + } + return updatedPayment + }), + })) + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..73ca4e1 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,32 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum([ + "pending", + "completed", + "cancelled", + "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().min(1), + memo: z.string().optional(), + bounty_id: z.string().optional(), + idempotency_key: z.string().optional(), + status: paymentStatusSchema, + 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..8219606 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,20 @@ +import { z } from "zod" +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: z.object({ + payment_id: z.string(), + }), + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})((req, ctx) => { + const url = new URL(req.url) + const paymentId = url.searchParams.get("payment_id") + const payment = + ctx.db.payments.find((p) => p.payment_id === paymentId) ?? null + + return ctx.json({ payment }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..a01414e --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,25 @@ +import { z } from "zod" +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: z.object({ + status: paymentStatusSchema.optional(), + bounty_id: z.string().optional(), + }), + jsonResponse: z.object({ + payments: z.array(paymentSchema), + }), +})((req, ctx) => { + const url = new URL(req.url) + const status = url.searchParams.get("status") + const bountyId = url.searchParams.get("bounty_id") + const payments = ctx.db.payments.filter((payment) => { + if (status && payment.status !== status) return false + if (bountyId && payment.bounty_id !== bountyId) 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..32d1f41 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,30 @@ +import { z } from "zod" +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" + +export const sendPaymentBodySchema = z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(1).default("USD"), + memo: z.string().optional(), + bounty_id: z.string().optional(), + idempotency_key: z.string().optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema, + idempotent: z.boolean(), + }), +})(async (req, ctx) => { + const body = await req.json() + const beforeCount = ctx.db.payments.length + const payment = ctx.db.createPayment(body) + + return ctx.json({ + payment, + idempotent: ctx.db.payments.length === beforeCount, + }) +}) diff --git a/routes/payments/update-status.ts b/routes/payments/update-status.ts new file mode 100644 index 0000000..157be83 --- /dev/null +++ b/routes/payments/update-status.ts @@ -0,0 +1,19 @@ +import { z } from "zod" +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + payment_id: z.string(), + status: paymentStatusSchema, + }), + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id, status } = await req.json() + const payment = ctx.db.updatePaymentStatus(payment_id, status) ?? null + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments/send.test.ts b/tests/routes/payments/send.test.ts new file mode 100644 index 0000000..88a8334 --- /dev/null +++ b/tests/routes/payments/send.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send, list, get, and update fake payments", async () => { + const { axios } = await getTestServer() + + const first = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + currency: "USD", + bounty_id: "tscircuit/fake-algora#1", + idempotency_key: "bounty-1-payment", + }) + + expect(first.data.payment.payment_id).toBe("0") + expect(first.data.payment.status).toBe("pending") + expect(first.data.idempotent).toBe(false) + + const retry = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + currency: "USD", + bounty_id: "tscircuit/fake-algora#1", + idempotency_key: "bounty-1-payment", + }) + + expect(retry.data.payment.payment_id).toBe(first.data.payment.payment_id) + expect(retry.data.idempotent).toBe(true) + + const list = await axios.get("/payments/list?status=pending") + expect(list.data.payments).toHaveLength(1) + + const get = await axios.get("/payments/get?payment_id=0") + expect(get.data.payment.recipient).toBe("maintainer@example.com") + + const updated = await axios.post("/payments/update-status", { + payment_id: "0", + status: "completed", + }) + expect(updated.data.payment.status).toBe("completed") +})