From 66a623c56df935139a5a32915eb92162a17dc63b Mon Sep 17 00:00:00 2001 From: zergzorg Date: Wed, 20 May 2026 23:25:54 +0300 Subject: [PATCH 1/3] Add fake payments API --- README.md | 13 ++++ lib/db/db-client.ts | 108 +++++++++++++++++++++++++++++-- lib/db/schema.ts | 20 ++++++ lib/payments/response-schemas.ts | 14 ++++ routes/payments/cancel.ts | 25 +++++++ routes/payments/complete.ts | 25 +++++++ routes/payments/get.ts | 25 +++++++ routes/payments/list.ts | 22 +++++++ routes/payments/send.ts | 24 +++++++ tests/routes/payments.test.ts | 103 +++++++++++++++++++++++++++++ 10 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 lib/payments/response-schemas.ts 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..423be7d 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,16 @@ 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 fake payment routes are intentionally in-memory and safe for local testing: + +- `POST /payments/send` creates a pending fake payment and accepts optional bounty metadata. +- `GET /payments/list` lists payments, with optional `recipient`, `repository`, and `status` filters. +- `GET /payments/get?payment_id=...` returns one payment. +- `POST /payments/complete` marks a pending payment as completed. +- `POST /payments/cancel` marks a pending payment as canceled. + +`POST /payments/send` also supports an optional `idempotency_key` so retrying the +same fake payment request does not create duplicate records. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..44a16a4 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,23 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +type SendPaymentInput = Pick & + Partial< + Pick< + Payment, + | "currency" + | "bounty_id" + | "issue_number" + | "repository" + | "idempotency_key" + > + > + +type ListPaymentsFilters = Partial< + Pick +> + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +43,82 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: (input: SendPaymentInput) => { + const idempotentPayment = + input.idempotency_key != null + ? get().payments.find( + (payment) => payment.idempotency_key === input.idempotency_key, + ) + : undefined + + if (idempotentPayment) { + return idempotentPayment + } + + const now = new Date().toISOString() + const payment: Payment = { + payment_id: get().paymentIdCounter.toString(), + recipient: input.recipient, + amount: input.amount, + currency: input.currency ?? "USD", + status: "pending", + bounty_id: input.bounty_id, + issue_number: input.issue_number, + repository: input.repository, + idempotency_key: input.idempotency_key, + created_at: now, + updated_at: now, + } + + set((state) => ({ + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + })) + + return payment + }, + getPayment: (paymentId: string) => { + return get().payments.find((payment) => payment.payment_id === paymentId) + }, + listPayments: (filters: ListPaymentsFilters = {}) => { + 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 + } + return true + }) + }, + updatePaymentStatus: (paymentId: string, status: PaymentStatus) => { + const payment = get().payments.find( + (candidate) => candidate.payment_id === paymentId, + ) + + if (!payment) { + return undefined + } + + if (payment.status !== "pending") { + return payment + } + + const updatedPayment = { + ...payment, + status, + updated_at: new Date().toISOString(), + } + + set((state) => ({ + payments: state.payments.map((candidate) => + candidate.payment_id === paymentId ? updatedPayment : candidate, + ), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..82fc460 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.number().int().positive().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/lib/payments/response-schemas.ts b/lib/payments/response-schemas.ts new file mode 100644 index 0000000..d8c5063 --- /dev/null +++ b/lib/payments/response-schemas.ts @@ -0,0 +1,14 @@ +import { paymentSchema } from "lib/db/schema" +import { z } from "zod" + +export const paymentResponseSchema = z.object({ + payment: paymentSchema, +}) + +export const paymentsResponseSchema = z.object({ + payments: z.array(paymentSchema), +}) + +export const paymentErrorResponseSchema = z.object({ + error: z.string(), +}) diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..82424bc --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,25 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentErrorResponseSchema, + paymentResponseSchema, +} from "lib/payments/response-schemas" +import { z } from "zod" + +const paymentStatusRequestSchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentStatusRequestSchema, + jsonResponse: paymentResponseSchema.or(paymentErrorResponseSchema), +})(async (req, ctx) => { + const { payment_id } = paymentStatusRequestSchema.parse(await req.json()) + const payment = ctx.db.updatePaymentStatus(payment_id, "canceled") + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..f4287fe --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,25 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentErrorResponseSchema, + paymentResponseSchema, +} from "lib/payments/response-schemas" +import { z } from "zod" + +const paymentStatusRequestSchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentStatusRequestSchema, + jsonResponse: paymentResponseSchema.or(paymentErrorResponseSchema), +})(async (req, ctx) => { + const { payment_id } = paymentStatusRequestSchema.parse(await req.json()) + const payment = ctx.db.updatePaymentStatus(payment_id, "completed") + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..4db3753 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,25 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentErrorResponseSchema, + paymentResponseSchema, +} from "lib/payments/response-schemas" +import { z } from "zod" + +const getPaymentQuerySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: paymentResponseSchema.or(paymentErrorResponseSchema), +})(async (req, ctx) => { + const query = Object.fromEntries(new URL(req.url).searchParams.entries()) + const { payment_id } = getPaymentQuerySchema.parse(query) + const payment = ctx.db.getPayment(payment_id) + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..9600392 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,22 @@ +import { paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentsResponseSchema } from "lib/payments/response-schemas" +import { z } from "zod" + +const listPaymentQuerySchema = z.object({ + recipient: z.string().optional(), + repository: z.string().optional(), + status: paymentStatusSchema.optional(), +}) + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: paymentsResponseSchema, +})(async (req, ctx) => { + const query = Object.fromEntries(new URL(req.url).searchParams.entries()) + const filters = listPaymentQuerySchema.parse(query) + + return ctx.json({ + payments: ctx.db.listPayments(filters), + }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..66021fa --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,24 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentResponseSchema } from "lib/payments/response-schemas" +import { z } from "zod" + +const sendPaymentRequestSchema = z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(3).default("USD"), + bounty_id: z.string().optional(), + issue_number: z.number().int().positive().optional(), + repository: z.string().optional(), + idempotency_key: z.string().optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentRequestSchema, + jsonResponse: paymentResponseSchema, +})(async (req, ctx) => { + const paymentInput = sendPaymentRequestSchema.parse(await req.json()) + const payment = ctx.db.sendPayment(paymentInput) + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..23de626 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send and list fake payments", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "alice", + amount: 10, + currency: "USD", + bounty_id: "1", + issue_number: 1, + repository: "tscircuit/fake-algora", + }) + + expect(sendData.payment).toMatchObject({ + payment_id: "0", + recipient: "alice", + amount: 10, + currency: "USD", + status: "pending", + bounty_id: "1", + issue_number: 1, + repository: "tscircuit/fake-algora", + }) + + const { data: listData } = await axios.get("/payments/list", { + params: { + recipient: "alice", + status: "pending", + }, + }) + + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0].payment_id).toBe("0") +}) + +test("reuses payments for matching idempotency keys", async () => { + const { axios } = await getTestServer() + + const { data: firstSendData } = await axios.post("/payments/send", { + recipient: "bob", + amount: 15, + idempotency_key: "retry-token", + }) + + const { data: secondSendData } = await axios.post("/payments/send", { + recipient: "bob", + amount: 15, + idempotency_key: "retry-token", + }) + + expect(secondSendData.payment.payment_id).toBe( + firstSendData.payment.payment_id, + ) + + const { data: listData } = await axios.get("/payments/list") + expect(listData.payments).toHaveLength(1) +}) + +test("gets and completes pending fake payments", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "carol", + amount: 25, + }) + + const { data: getData } = await axios.get("/payments/get", { + params: { + payment_id: sendData.payment.payment_id, + }, + }) + + expect(getData.payment.status).toBe("pending") + + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: sendData.payment.payment_id, + }) + + expect(completeData.payment.status).toBe("completed") +}) + +test("does not mutate terminal fake payment states", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "dana", + amount: 30, + }) + + const { data: cancelData } = await axios.post("/payments/cancel", { + payment_id: sendData.payment.payment_id, + }) + + expect(cancelData.payment.status).toBe("canceled") + + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: sendData.payment.payment_id, + }) + + expect(completeData.payment.status).toBe("canceled") +}) From b7c7121ed68d01cfea91a7e3ac778ba133a8f8b5 Mon Sep 17 00:00:00 2001 From: zergzorg Date: Thu, 21 May 2026 10:32:03 +0300 Subject: [PATCH 2/3] Add failed fake payment state Signed-off-by: zergzorg --- README.md | 1 + lib/db/schema.ts | 7 ++++++- routes/payments/fail.ts | 25 +++++++++++++++++++++++++ tests/routes/payments.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 routes/payments/fail.ts diff --git a/README.md b/README.md index 423be7d..f43359a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The fake payment routes are intentionally in-memory and safe for local testing: - `GET /payments/get?payment_id=...` returns one payment. - `POST /payments/complete` marks a pending payment as completed. - `POST /payments/cancel` marks a pending payment as canceled. +- `POST /payments/fail` marks a pending payment as failed. `POST /payments/send` also supports an optional `idempotency_key` so retrying the same fake payment request does not create duplicate records. diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 82fc460..fd48110 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,7 +9,12 @@ export const thingSchema = z.object({ }) export type Thing = z.infer -export const paymentStatusSchema = z.enum(["pending", "completed", "canceled"]) +export const paymentStatusSchema = z.enum([ + "pending", + "completed", + "canceled", + "failed", +]) export type PaymentStatus = z.infer export const paymentSchema = z.object({ diff --git a/routes/payments/fail.ts b/routes/payments/fail.ts new file mode 100644 index 0000000..d032b4a --- /dev/null +++ b/routes/payments/fail.ts @@ -0,0 +1,25 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentErrorResponseSchema, + paymentResponseSchema, +} from "lib/payments/response-schemas" +import { z } from "zod" + +const paymentStatusRequestSchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentStatusRequestSchema, + jsonResponse: paymentResponseSchema.or(paymentErrorResponseSchema), +})(async (req, ctx) => { + const { payment_id } = paymentStatusRequestSchema.parse(await req.json()) + const payment = ctx.db.updatePaymentStatus(payment_id, "failed") + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts index 23de626..3f8cfa7 100644 --- a/tests/routes/payments.test.ts +++ b/tests/routes/payments.test.ts @@ -101,3 +101,29 @@ test("does not mutate terminal fake payment states", async () => { expect(completeData.payment.status).toBe("canceled") }) + +test("marks pending fake payments as failed", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "erin", + amount: 45, + repository: "tscircuit/fake-algora", + }) + + const { data: failData } = await axios.post("/payments/fail", { + payment_id: sendData.payment.payment_id, + }) + + expect(failData.payment.status).toBe("failed") + + const { data: listData } = await axios.get("/payments/list", { + params: { + repository: "tscircuit/fake-algora", + status: "failed", + }, + }) + + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0].payment_id).toBe(sendData.payment.payment_id) +}) From 95ddf8838f58a38d486ea4e754898d7b16a88000 Mon Sep 17 00:00:00 2001 From: zergzorg Date: Sun, 24 May 2026 00:28:22 +0300 Subject: [PATCH 3/3] Keep fallback db across requests Signed-off-by: zergzorg --- lib/middleware/with-db.ts | 4 +++- tests/middleware/with-db.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/middleware/with-db.test.ts diff --git a/lib/middleware/with-db.ts b/lib/middleware/with-db.ts index 5ae5826..5eb9271 100644 --- a/lib/middleware/with-db.ts +++ b/lib/middleware/with-db.ts @@ -2,6 +2,8 @@ import type { DbClient } from "lib/db/db-client" import { createDatabase } from "lib/db/db-client" import type { Middleware } from "winterspec" +const fallbackDb = createDatabase() + export const withDb: Middleware< {}, { @@ -9,7 +11,7 @@ export const withDb: Middleware< } > = async (req, ctx, next) => { if (!ctx.db) { - ctx.db = createDatabase() + ctx.db = fallbackDb } return next(req, ctx) } diff --git a/tests/middleware/with-db.test.ts b/tests/middleware/with-db.test.ts new file mode 100644 index 0000000..2974f3d --- /dev/null +++ b/tests/middleware/with-db.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test" +import { createDatabase } from "lib/db/db-client" +import { withDb } from "lib/middleware/with-db" + +const request = new Request("http://localhost/test") as any +const next = async () => new Response("ok") + +test("reuses fallback db when middleware context has no db", async () => { + const firstContext: Record = {} + const secondContext: Record = {} + + await withDb(request, firstContext as any, next as any) + await withDb(request, secondContext as any, next as any) + + expect(firstContext.db).toBe(secondContext.db) +}) + +test("preserves db supplied by an outer middleware", async () => { + const suppliedDb = createDatabase() + const context = { db: suppliedDb } + + await withDb(request, context as any, next as any) + + expect(context.db).toBe(suppliedDb) +})