diff --git a/README.md b/README.md index 824427a..f648858 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,62 @@ 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 + +This project exposes a small fake payment API for exercising bounty and payout +flows without moving real funds. + +### Send a payment + +```http +POST /payments/send +Content-Type: application/json +``` + +```json +{ + "recipient": "octocat", + "amount": 10, + "currency": "USD", + "bounty_id": "bounty_1", + "issue_number": 1, + "repository": "tscircuit/fake-algora", + "idempotency_key": "optional-retry-key" +} +``` + +The response includes a `payment` object with `status: "pending"`. If an +`idempotency_key` is provided, repeat sends with the same key return the +existing payment instead of creating a duplicate. + +### List payments + +```http +GET /payments/list +GET /payments/list?recipient=octocat&status=pending +GET /payments/list?repository=tscircuit/fake-algora&status=completed +``` + +Supported filters are `recipient`, `repository`, and `status`. + +### Get a payment + +```http +GET /payments/get?payment_id=payment_0 +``` + +### Complete, cancel, or fail a payment + +```http +POST /payments/complete +POST /payments/cancel +POST /payments/fail +Content-Type: application/json +``` + +```json +{ + "payment_id": "payment_0" +} +``` diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..88db0f0 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -2,7 +2,13 @@ import { createStore, type StoreApi } from "zustand/vanilla" import { immer } from "zustand/middleware/immer" import { hoist, type HoistedStoreApi } from "zustand-hoist" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" +import { + databaseSchema, + type DatabaseSchema, + type Payment, + type PaymentStatus, + type Thing, +} from "./schema.ts" import { combine } from "zustand/middleware" export const createDatabase = () => { @@ -11,7 +17,12 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +type CreatePaymentInput = Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" +> + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +32,78 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: (paymentInput: CreatePaymentInput) => { + const now = new Date().toISOString() + const existingPayment = + paymentInput.idempotency_key !== undefined + ? get().payments.find( + (payment) => + payment.idempotency_key === paymentInput.idempotency_key, + ) + : undefined + + if (existingPayment) { + return existingPayment + } + + 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 + }, + listPayments: (filters?: { + recipient?: string + repository?: string + status?: PaymentStatus + }) => { + return get().payments.filter((payment) => { + if (filters?.recipient && payment.recipient !== filters.recipient) { + return false + } + if (filters?.repository && payment.repository !== filters.repository) { + return false + } + if (filters?.status && payment.status !== filters.status) { + return false + } + return true + }) + }, + getPayment: (payment_id: string) => { + return get().payments.find((payment) => payment.payment_id === payment_id) + }, + updatePaymentStatus: ({ + payment_id, + status, + }: { + payment_id: string + status: PaymentStatus + }) => { + let updatedPayment: Payment | undefined + const now = new Date().toISOString() + + set((state) => ({ + payments: state.payments.map((payment) => { + if (payment.payment_id !== payment_id) return payment + updatedPayment = { + ...payment, + status, + updated_at: now, + } + return updatedPayment + }), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..0bce0be 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,33 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum([ + "pending", + "completed", + "failed", + "cancelled", +]) +export type PaymentStatus = z.infer + +export const paymentSchema = z.object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number(), + currency: z.string(), + status: paymentStatusSchema, + bounty_id: z.string().optional(), + issue_number: z.number().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..37f541f --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,23 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const updatePaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id } = updatePaymentBodySchema.parse(await req.json()) + const payment = ctx.db.updatePaymentStatus({ + payment_id, + status: "cancelled", + }) + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..43a1a4c --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,23 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const updatePaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id } = updatePaymentBodySchema.parse(await req.json()) + const payment = ctx.db.updatePaymentStatus({ + payment_id, + status: "completed", + }) + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/fail.ts b/routes/payments/fail.ts new file mode 100644 index 0000000..0c696de --- /dev/null +++ b/routes/payments/fail.ts @@ -0,0 +1,23 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const updatePaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id } = updatePaymentBodySchema.parse(await req.json()) + const payment = ctx.db.updatePaymentStatus({ + payment_id, + status: "failed", + }) + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..7b846bc --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,15 @@ +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.nullable(), + }), +})((req, ctx) => { + const url = new URL(req.url) + const payment = ctx.db.getPayment(url.searchParams.get("payment_id") ?? "") + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..28e8540 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,24 @@ +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 url = new URL(req.url) + const statusParam = url.searchParams.get("status") + const status = statusParam + ? paymentStatusSchema.parse(statusParam) + : undefined + + const payments = ctx.db.listPayments({ + recipient: url.searchParams.get("recipient") ?? undefined, + repository: url.searchParams.get("repository") ?? undefined, + status, + }) + + return ctx.json({ payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..64ab565 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,26 @@ +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.number().int().positive().optional(), + repository: z.string().min(1).optional(), + idempotency_key: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema, + }), +})(async (req, ctx) => { + const body = sendPaymentBodySchema.parse(await req.json()) + const payment = ctx.db.sendPayment(body) + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..66f21e1 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send, list, get, and complete a fake payment", async () => { + const { axios } = await getTestServer() + + const sendResponse = await axios.post("/payments/send", { + recipient: "octocat", + amount: 10, + currency: "USD", + bounty_id: "bounty_1", + issue_number: 1, + repository: "tscircuit/fake-algora", + }) + + expect(sendResponse.data.payment).toMatchObject({ + payment_id: "payment_0", + recipient: "octocat", + amount: 10, + currency: "USD", + status: "pending", + bounty_id: "bounty_1", + issue_number: 1, + repository: "tscircuit/fake-algora", + }) + + const listResponse = await axios.get( + "/payments/list?recipient=octocat&status=pending", + ) + expect(listResponse.data.payments).toHaveLength(1) + expect(listResponse.data.payments[0].payment_id).toBe("payment_0") + + const getResponse = await axios.get("/payments/get?payment_id=payment_0") + expect(getResponse.data.payment.payment_id).toBe("payment_0") + + const completeResponse = await axios.post("/payments/complete", { + payment_id: "payment_0", + }) + expect(completeResponse.data.payment.status).toBe("completed") + + const completedListResponse = await axios.get( + "/payments/list?repository=tscircuit/fake-algora&status=completed", + ) + expect(completedListResponse.data.payments).toHaveLength(1) +}) + +test("send payment is idempotent when an idempotency key is supplied", async () => { + const { axios } = await getTestServer() + + const firstResponse = await axios.post("/payments/send", { + recipient: "maintainer", + amount: 25, + currency: "USD", + repository: "tscircuit/fake-algora", + idempotency_key: "retry-safe-transfer", + }) + const secondResponse = await axios.post("/payments/send", { + recipient: "maintainer", + amount: 25, + currency: "USD", + repository: "tscircuit/fake-algora", + idempotency_key: "retry-safe-transfer", + }) + + expect(secondResponse.data.payment).toEqual(firstResponse.data.payment) + + const listResponse = await axios.get("/payments/list") + expect(listResponse.data.payments).toHaveLength(1) +}) + +test("cancel a pending fake payment", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient: "octocat", + amount: 5, + currency: "USD", + }) + + const cancelResponse = await axios.post("/payments/cancel", { + payment_id: "payment_0", + }) + + expect(cancelResponse.data.payment.status).toBe("cancelled") +}) + +test("fail a pending fake payment", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient: "octocat", + amount: 15, + currency: "USD", + }) + + const failResponse = await axios.post("/payments/fail", { + payment_id: "payment_0", + }) + + expect(failResponse.data.payment.status).toBe("failed") +})