diff --git a/README.md b/README.md index 824427a..9cf8df8 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,23 @@ 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 fake payment endpoints support a simple bounty payout lifecycle: + +- `POST /payments/send` creates a pending payment. Optional `idempotency_key` + values make retries safe for the same recipient. +- `GET /payments/list` lists payments and supports `recipient`, `status`, and + `repository` query filters. +- `GET /payments/get?payment_id=` returns one payment. +- `POST /payments/complete`, `POST /payments/cancel`, and `POST /payments/fail` + transition pending payments into terminal states. + +Example: + +```bash +curl -X POST http://localhost:3000/payments/send \ + -H 'content-type: application/json' \ + -d '{"recipient":"agent@example.com","amount":10,"currency":"USD","idempotency_key":"issue-1-agent"}' +``` diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..8407a98 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,18 @@ 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 terminalPaymentStatuses = new Set([ + "completed", + "canceled", + "failed", +]) + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +36,85 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + createPayment: (payment: CreatePaymentInput) => { + let existingPayment: Payment | undefined + let createdPayment: Payment | undefined + + set((state) => { + existingPayment = payment.idempotency_key + ? state.payments.find( + (existing) => + existing.idempotency_key === payment.idempotency_key && + existing.recipient === payment.recipient, + ) + : undefined + + if (existingPayment) { + return state + } + + const timestamp = new Date().toISOString() + createdPayment = { + ...payment, + payment_id: state.paymentCounter.toString(), + status: "pending", + created_at: timestamp, + updated_at: timestamp, + } + + return { + payments: [...state.payments, createdPayment], + paymentCounter: state.paymentCounter + 1, + } + }) + + return { + idempotent: Boolean(existingPayment), + payment: existingPayment ?? createdPayment!, + } + }, + getPayment: (paymentId: string) => { + return get().payments.find((payment) => payment.payment_id === paymentId) + }, + updatePaymentStatus: (paymentId: string, status: PaymentStatus) => { + let payment: Payment | undefined + let error: string | undefined + + set((state) => { + const existingPayment = state.payments.find( + (payment) => payment.payment_id === paymentId, + ) + + if (!existingPayment) { + error = "payment_not_found" + return state + } + + if (terminalPaymentStatuses.has(existingPayment.status)) { + error = "payment_already_terminal" + payment = existingPayment + return state + } + + const timestamp = new Date().toISOString() + payment = { + ...existingPayment, + status, + updated_at: timestamp, + completed_at: + status === "completed" ? timestamp : existingPayment.completed_at, + canceled_at: + status === "canceled" ? timestamp : existingPayment.canceled_at, + failed_at: status === "failed" ? timestamp : existingPayment.failed_at, + } + + return { + payments: state.payments.map((existing) => + existing.payment_id === paymentId ? payment! : existing, + ), + } + }) + + return { error, payment } + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..bf06555 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,36 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum([ + "pending", + "completed", + "canceled", + "failed", +]) +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(), + completed_at: z.string().optional(), + canceled_at: z.string().optional(), + failed_at: z.string().optional(), +}) +export type Payment = z.infer + export const databaseSchema = z.object({ idCounter: z.number().default(0), things: z.array(thingSchema).default([]), + paymentCounter: z.number().default(0), + payments: z.array(paymentSchema).default([]), }) export type DatabaseSchema = z.infer diff --git a/lib/middleware/with-db.ts b/lib/middleware/with-db.ts index 5ae5826..27f68dd 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 db = 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 = db } return next(req, ctx) } diff --git a/lib/payments/schemas.ts b/lib/payments/schemas.ts new file mode 100644 index 0000000..2faa69d --- /dev/null +++ b/lib/payments/schemas.ts @@ -0,0 +1,38 @@ +import { paymentSchema } from "lib/db/schema" +import { z } from "zod" + +export const paymentResponseSchema = paymentSchema + +export const paymentErrorResponseSchema = z.object({ + error: z.string(), +}) + +export const paymentOrErrorResponseSchema = z.union([ + z.object({ + payment: paymentResponseSchema, + }), + paymentErrorResponseSchema, +]) + +export const paymentListResponseSchema = z.object({ + payments: z.array(paymentResponseSchema), +}) + +export const paymentSendBodySchema = 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 const paymentIdBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export const sendPaymentResponseSchema = z.object({ + idempotent: z.boolean(), + payment: paymentResponseSchema, +}) diff --git a/package.json b/package.json index d03438f..8bb43da 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "start": "bun run dev", "dev": "winterspec dev", "build": "winterspec bundle -o dist/bundle.js", - "next:dev": "next dev" + "next:dev": "next dev", + "test": "bun test" } } diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..22ea045 --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,23 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentIdBodySchema, + paymentOrErrorResponseSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentIdBodySchema, + jsonResponse: paymentOrErrorResponseSchema, +})(async (req, ctx) => { + const { payment_id } = paymentIdBodySchema.parse(await req.json()) + const { payment, error } = ctx.db.updatePaymentStatus(payment_id, "canceled") + + if (error) { + return Response.json( + { error }, + { status: error === "payment_not_found" ? 404 : 409 }, + ) + } + + return ctx.json({ payment: payment! }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..ec6253d --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,23 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentIdBodySchema, + paymentOrErrorResponseSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentIdBodySchema, + jsonResponse: paymentOrErrorResponseSchema, +})(async (req, ctx) => { + const { payment_id } = paymentIdBodySchema.parse(await req.json()) + const { payment, error } = ctx.db.updatePaymentStatus(payment_id, "completed") + + if (error) { + return Response.json( + { error }, + { status: error === "payment_not_found" ? 404 : 409 }, + ) + } + + return ctx.json({ payment: payment! }) +}) diff --git a/routes/payments/fail.ts b/routes/payments/fail.ts new file mode 100644 index 0000000..c480da9 --- /dev/null +++ b/routes/payments/fail.ts @@ -0,0 +1,23 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentIdBodySchema, + paymentOrErrorResponseSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentIdBodySchema, + jsonResponse: paymentOrErrorResponseSchema, +})(async (req, ctx) => { + const { payment_id } = paymentIdBodySchema.parse(await req.json()) + const { payment, error } = ctx.db.updatePaymentStatus(payment_id, "failed") + + if (error) { + return Response.json( + { error }, + { status: error === "payment_not_found" ? 404 : 409 }, + ) + } + + return ctx.json({ payment: payment! }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..2f85bf6 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,22 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentOrErrorResponseSchema } from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: paymentOrErrorResponseSchema, +})((req, ctx) => { + const url = new URL(req.url) + const paymentId = url.searchParams.get("payment_id") + + if (!paymentId) { + return Response.json({ error: "payment_id_required" }, { status: 400 }) + } + + const payment = ctx.db.getPayment(paymentId) + + if (!payment) { + return Response.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..ca731ab --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,21 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentListResponseSchema } from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: paymentListResponseSchema, +})((req, ctx) => { + const url = new URL(req.url) + const recipient = url.searchParams.get("recipient") + const status = url.searchParams.get("status") + const repository = url.searchParams.get("repository") + + const payments = ctx.db.payments.filter((payment) => { + if (recipient && payment.recipient !== recipient) return false + if (status && payment.status !== status) return false + if (repository && payment.repository !== repository) 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..0f08d73 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,19 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentSendBodySchema, + sendPaymentResponseSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentSendBodySchema, + jsonResponse: sendPaymentResponseSchema, +})(async (req, ctx) => { + const body = paymentSendBodySchema.parse(await req.json()) + const { payment, idempotent } = ctx.db.createPayment(body) + + return ctx.json({ + idempotent, + payment, + }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..e26f26b --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("creates, lists, and looks up fake payments", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "contributor@example.com", + amount: 10, + currency: "USD", + bounty_id: "bounty-1", + issue_number: 1, + repository: "tscircuit/fake-algora", + }) + + expect(sendData.idempotent).toBe(false) + expect(sendData.payment).toMatchObject({ + payment_id: "0", + recipient: "contributor@example.com", + amount: 10, + currency: "USD", + bounty_id: "bounty-1", + issue_number: 1, + repository: "tscircuit/fake-algora", + status: "pending", + }) + + const { data: listData } = await axios.get("/payments/list") + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0].payment_id).toBe("0") + + const { data: getData } = await axios.get("/payments/get?payment_id=0") + expect(getData.payment.recipient).toBe("contributor@example.com") +}) + +test("reuses an existing payment when idempotency key is replayed", async () => { + const { axios } = await getTestServer() + + const requestBody = { + recipient: "agent@example.com", + amount: 25, + idempotency_key: "issue-1-agent@example.com", + } + + const { data: firstSend } = await axios.post("/payments/send", requestBody) + const { data: replayedSend } = await axios.post("/payments/send", requestBody) + const { data: listData } = await axios.get("/payments/list") + + expect(firstSend.idempotent).toBe(false) + expect(replayedSend.idempotent).toBe(true) + expect(replayedSend.payment.payment_id).toBe(firstSend.payment.payment_id) + expect(listData.payments).toHaveLength(1) +}) + +test("filters fake payments and guards terminal status transitions", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient: "alice@example.com", + amount: 10, + repository: "tscircuit/fake-algora", + }) + await axios.post("/payments/send", { + recipient: "bob@example.com", + amount: 20, + repository: "tscircuit/file-server", + }) + + const { data: filteredByRecipient } = await axios.get( + "/payments/list?recipient=alice@example.com", + ) + expect(filteredByRecipient.payments).toHaveLength(1) + expect(filteredByRecipient.payments[0].recipient).toBe("alice@example.com") + + const { data: completed } = await axios.post("/payments/complete", { + payment_id: "0", + }) + expect(completed.payment.status).toBe("completed") + expect(completed.payment.completed_at).toBeTruthy() + + try { + await axios.post("/payments/cancel", { payment_id: "0" }) + throw new Error("Expected terminal transition to fail") + } catch (error: any) { + expect(error.status).toBe(409) + expect(error.data.error).toBe("payment_already_terminal") + } + + const { data: completedPayments } = await axios.get( + "/payments/list?status=completed", + ) + expect(completedPayments.payments).toHaveLength(1) +})