diff --git a/README.md b/README.md index 824427a..84e7638 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,14 @@ 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 API includes an in-memory fake payment flow for bounty payout simulations. + +- `POST /payments/send` creates a pending payment. +- `GET /payments/list` lists payments, optionally filtered by `recipient`, `repository`, `bounty_issue`, or `status`. +- `GET /payments/get?payment_id=...` returns a single payment. +- `POST /payments/update-status` updates a payment to `pending`, `completed`, `failed`, or `canceled`. + +`POST /payments/send` accepts an optional `idempotency_key`. Reusing the same key returns the original payment with `idempotent_replay: true`, so callers can retry safely without creating duplicate fake transfers. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..5e6b20e 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,12 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +type NewPayment = Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" +> + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +30,45 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + addPayment: (paymentInput: NewPayment) => { + const now = new Date().toISOString() + const payment: Payment = { + ...paymentInput, + payment_id: `payment_${get().paymentIdCounter}`, + status: "pending", + created_at: now, + updated_at: now, + } + + set((state) => ({ + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + })) + + return payment + }, + updatePaymentStatus: (paymentId: string, status: PaymentStatus) => { + const now = new Date().toISOString() + const existingPayment = get().payments.find( + (payment) => payment.payment_id === paymentId, + ) + + if (!existingPayment) { + return undefined + } + + const updatedPayment: Payment = { + ...existingPayment, + status, + updated_at: now, + } + + set((state) => ({ + payments: state.payments.map((payment) => + payment.payment_id === paymentId ? updatedPayment : payment, + ), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..48f5ece 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", + "failed", + "canceled", +]) +export type PaymentStatus = z.infer + +export const paymentSchema = z.object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number(), + currency: z.string(), + repository: z.string().optional(), + bounty_issue: 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..542136c --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,26 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payment: paymentSchema, + }), +})((req, ctx) => { + const paymentId = new URL(req.url).searchParams.get("payment_id") + const payment = ctx.db.payments.find( + (payment) => payment.payment_id === paymentId, + ) + + if (!payment) { + return new Response(JSON.stringify({ error: "Payment not found" }), { + status: 404, + headers: { + "content-type": "application/json", + }, + }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..f2ff67c --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,30 @@ +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 recipient = searchParams.get("recipient") + const repository = searchParams.get("repository") + const bountyIssue = searchParams.get("bounty_issue") + const status = paymentStatusSchema + .optional() + .safeParse(searchParams.get("status")) + + const payments = ctx.db.payments.filter((payment) => { + if (recipient && payment.recipient !== recipient) return false + if (repository && payment.repository !== repository) return false + if (bountyIssue && payment.bounty_issue !== bountyIssue) return false + if (status.success && status.data && payment.status !== status.data) { + 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..5efe0be --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,43 @@ +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).default("USD"), + repository: z.string().optional(), + bounty_issue: z.string().optional(), + idempotency_key: z.string().optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: z.object({ + idempotent_replay: z.boolean(), + payment: paymentSchema, + }), +})(async (req, ctx) => { + const body = sendPaymentBodySchema.parse(await req.json()) + + if (body.idempotency_key) { + const existingPayment = ctx.db.payments.find( + (payment) => payment.idempotency_key === body.idempotency_key, + ) + + if (existingPayment) { + return ctx.json({ + idempotent_replay: true, + payment: existingPayment, + }) + } + } + + const payment = ctx.db.addPayment(body) + + return ctx.json({ + idempotent_replay: false, + payment, + }) +}) diff --git a/routes/payments/update-status.ts b/routes/payments/update-status.ts new file mode 100644 index 0000000..e5d1d10 --- /dev/null +++ b/routes/payments/update-status.ts @@ -0,0 +1,32 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const updatePaymentStatusBodySchema = z.object({ + payment_id: z.string(), + status: paymentStatusSchema, +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentStatusBodySchema, + jsonResponse: z.object({ + payment: paymentSchema, + }), +})(async (req, ctx) => { + const { payment_id, status } = updatePaymentStatusBodySchema.parse( + await req.json(), + ) + const payment = ctx.db.updatePaymentStatus(payment_id, status) + + if (!payment) { + return new Response(JSON.stringify({ error: "Payment not found" }), { + status: 404, + headers: { + "content-type": "application/json", + }, + }) + } + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..3e4051b --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,83 @@ +import { expect, it } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +it("sends and replays fake payments with an idempotency key", async () => { + const { axios } = await getTestServer() + + const payload = { + recipient: "maintainer@example.com", + amount: 1250, + currency: "USD", + repository: "tscircuit/fake-algora", + bounty_issue: "https://github.com/tscircuit/fake-algora/issues/1", + idempotency_key: "retry-safe-payment-1", + } + + const firstSend = await axios.post("/payments/send", payload) + + expect(firstSend.status).toBe(200) + expect(firstSend.data.idempotent_replay).toBe(false) + expect(firstSend.data.payment).toMatchObject({ + payment_id: "payment_0", + recipient: payload.recipient, + amount: payload.amount, + currency: payload.currency, + repository: payload.repository, + bounty_issue: payload.bounty_issue, + status: "pending", + }) + expect(firstSend.data.payment.created_at).toBeString() + expect(firstSend.data.payment.updated_at).toBeString() + + const replay = await axios.post("/payments/send", payload) + + expect(replay.status).toBe(200) + expect(replay.data.idempotent_replay).toBe(true) + expect(replay.data.payment.payment_id).toBe("payment_0") + + const lookup = await axios.get("/payments/get?payment_id=payment_0") + + expect(lookup.status).toBe(200) + expect(lookup.data.payment).toMatchObject({ + payment_id: "payment_0", + recipient: payload.recipient, + amount: payload.amount, + status: "pending", + }) + + const list = await axios.get( + "/payments/list?recipient=maintainer@example.com&status=pending", + ) + + expect(list.status).toBe(200) + expect(list.data.payments).toHaveLength(1) + expect(list.data.payments[0].payment_id).toBe("payment_0") +}) + +it("updates fake payment status once it is sent", async () => { + const { axios } = await getTestServer() + + const send = await axios.post("/payments/send", { + recipient: "contributor@example.com", + amount: 500, + currency: "USD", + repository: "tscircuit/fake-algora", + }) + + const update = await axios.post("/payments/update-status", { + payment_id: send.data.payment.payment_id, + status: "completed", + }) + + expect(update.status).toBe(200) + expect(update.data.payment).toMatchObject({ + payment_id: send.data.payment.payment_id, + status: "completed", + }) + + const list = await axios.get("/payments/list?status=completed") + + expect(list.status).toBe(200) + expect(list.data.payments).toHaveLength(1) + expect(list.data.payments[0].status).toBe("completed") +})