From dc642a4220ead7a71afb052c5a1138a89c2070be Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 13 May 2026 07:05:50 +0000 Subject: [PATCH] feat: add fake payments API --- README.md | 21 ++++++++++ bun.lockb | Bin 67071 -> 68527 bytes lib/db/db-client.ts | 61 ++++++++++++++++++++++++++--- lib/db/schema.ts | 24 ++++++++++++ routes/payments/get.ts | 20 ++++++++++ routes/payments/list.ts | 25 ++++++++++++ routes/payments/send.ts | 30 ++++++++++++++ routes/payments/update-status.ts | 19 +++++++++ tests/routes/payments/send.test.ts | 41 +++++++++++++++++++ 9 files changed, 236 insertions(+), 5 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/send.test.ts 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 557d79cc3b88accaaa0f1f96a19cff1aa0a4aa86..7e3c28a599e7b1272808af15e4f59c29c2007846 100755 GIT binary patch delta 3280 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 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") +})