diff --git a/README.md b/README.md index 824427a..d30ab90 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 + +- `POST /payments/send` creates a pending fake payment. The body accepts + `recipient_email`, `amount_cents`, optional `currency`, bounty metadata, and an + optional `idempotency_key` for retry-safe sends. +- `GET /payments/list` returns payments. It can filter by `status`, + `recipient_email`, and `repository`. +- `GET /payments/get?payment_id=...` returns one payment or `404`. +- `POST /payments/complete` marks a pending payment as completed. +- `POST /payments/cancel` marks a pending payment as canceled. + +Completed and canceled payments are terminal; later status changes return `409`. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..deb5d27 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,29 @@ -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 interface CreatePaymentInput { + recipient_email: string + amount_cents: number + currency?: string + bounty_id?: string + issue_number?: number + repository?: string + idempotency_key?: string +} + +export interface ListPaymentsInput { + status?: PaymentStatus + recipient_email?: string + repository?: string +} export const createDatabase = () => { return hoist(createStore(initializer)) @@ -11,7 +31,7 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +41,91 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + createPayment: (input: CreatePaymentInput) => { + const now = new Date().toISOString() + let payment: Payment | undefined + let idempotentReplay = false + + set((state) => { + if (input.idempotency_key) { + const existingPayment = state.payments.find( + (candidate) => candidate.idempotency_key === input.idempotency_key, + ) + if (existingPayment) { + payment = existingPayment + idempotentReplay = true + return state + } + } + + payment = { + payment_id: `payment_${state.paymentIdCounter}`, + recipient_email: input.recipient_email, + amount_cents: input.amount_cents, + currency: (input.currency ?? "usd").toLowerCase(), + bounty_id: input.bounty_id, + issue_number: input.issue_number, + repository: input.repository, + idempotency_key: input.idempotency_key, + status: "pending", + created_at: now, + updated_at: now, + } + + return { + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + } + }) + + return { payment: payment!, idempotent_replay: idempotentReplay } + }, + listPayments: (filters: ListPaymentsInput = {}) => { + return get().payments.filter((payment) => { + if (filters.status && payment.status !== filters.status) return false + if ( + filters.recipient_email && + payment.recipient_email !== filters.recipient_email + ) { + return false + } + if (filters.repository && payment.repository !== filters.repository) { + return false + } + return true + }) + }, + getPayment: (paymentId: string) => { + return get().payments.find((payment) => payment.payment_id === paymentId) + }, + updatePaymentStatus: (paymentId: string, status: PaymentStatus) => { + let updatedPayment: Payment | undefined + let error: "not_found" | "terminal_status" | undefined + const now = new Date().toISOString() + + set((state) => { + const payment = state.payments.find( + (candidate) => candidate.payment_id === paymentId, + ) + if (!payment) { + error = "not_found" + return state + } + if (payment.status !== "pending") { + error = "terminal_status" + updatedPayment = payment + return state + } + + const payments = state.payments.map((candidate) => { + if (candidate.payment_id !== paymentId) return candidate + updatedPayment = { ...candidate, status, updated_at: now } + return updatedPayment + }) + + return { payments } + }) + + return { payment: updatedPayment, error } + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..5694fed 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_email: z.string().email(), + amount_cents: z.number().int().positive(), + currency: z.string().length(3), + bounty_id: z.string().optional(), + issue_number: z.number().int().positive().optional(), + repository: 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/lib/middleware/with-db.ts b/lib/middleware/with-db.ts index 5ae5826..4cc5374 100644 --- a/lib/middleware/with-db.ts +++ b/lib/middleware/with-db.ts @@ -2,6 +2,17 @@ import type { DbClient } from "lib/db/db-client" import { createDatabase } from "lib/db/db-client" import type { Middleware } from "winterspec" +let defaultDb: DbClient | undefined + +export const getDefaultDb = () => { + defaultDb ??= createDatabase() + return defaultDb +} + +export const resetDefaultDbForTests = () => { + defaultDb = createDatabase() +} + export const withDb: Middleware< {}, { @@ -9,7 +20,7 @@ export const withDb: Middleware< } > = async (req, ctx, next) => { if (!ctx.db) { - ctx.db = createDatabase() + ctx.db = getDefaultDb() } return next(req, ctx) } diff --git a/lib/payments/route-schemas.ts b/lib/payments/route-schemas.ts new file mode 100644 index 0000000..c5a5d05 --- /dev/null +++ b/lib/payments/route-schemas.ts @@ -0,0 +1,50 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { z } from "zod" + +export const sendPaymentBodySchema = z.object({ + recipient_email: z.string().email(), + amount_cents: z.number().int().positive(), + currency: z.string().length(3).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 listPaymentsQuerySchema = z.object({ + status: paymentStatusSchema.optional(), + recipient_email: z.string().email().optional(), + repository: z.string().min(1).optional(), +}) + +export const paymentIdBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export const paymentIdQuerySchema = paymentIdBodySchema + +export const paymentSuccessResponseSchema = z.object({ + ok: z.literal(true), + payment: paymentSchema, +}) + +export const paymentErrorResponseSchema = z.object({ + ok: z.literal(false), + error: z.string(), +}) + +export const paymentLookupResponseSchema = z.union([ + paymentSuccessResponseSchema, + paymentErrorResponseSchema, +]) + +export const sendPaymentResponseSchema = z.object({ + ok: z.literal(true), + payment: paymentSchema, + idempotent_replay: z.boolean(), +}) + +export const listPaymentsResponseSchema = z.object({ + ok: z.literal(true), + payments: z.array(paymentSchema), +}) diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..7be94a6 --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,23 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentIdBodySchema, + paymentLookupResponseSchema, +} from "lib/payments/route-schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentIdBodySchema, + jsonResponse: paymentLookupResponseSchema, +})((req, ctx) => { + const { payment, error } = ctx.db.updatePaymentStatus( + req.jsonBody.payment_id, + "canceled", + ) + if (error === "not_found") { + return ctx.json({ ok: false, error: "payment_not_found" }).status(404) + } + if (error === "terminal_status") { + return ctx.json({ ok: false, error: "payment_is_not_pending" }).status(409) + } + return ctx.json({ ok: true, payment: payment! }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..ee9d8e5 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,23 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentIdBodySchema, + paymentLookupResponseSchema, +} from "lib/payments/route-schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentIdBodySchema, + jsonResponse: paymentLookupResponseSchema, +})((req, ctx) => { + const { payment, error } = ctx.db.updatePaymentStatus( + req.jsonBody.payment_id, + "completed", + ) + if (error === "not_found") { + return ctx.json({ ok: false, error: "payment_not_found" }).status(404) + } + if (error === "terminal_status") { + return ctx.json({ ok: false, error: "payment_is_not_pending" }).status(409) + } + return ctx.json({ ok: true, payment: payment! }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..34510e8 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,17 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentIdQuerySchema, + paymentLookupResponseSchema, +} from "lib/payments/route-schemas" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: paymentIdQuerySchema, + jsonResponse: paymentLookupResponseSchema, +})((req, ctx) => { + const payment = ctx.db.getPayment(req.query.payment_id) + if (!payment) { + return ctx.json({ ok: false, error: "payment_not_found" }).status(404) + } + return ctx.json({ ok: true, payment }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..1146551 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,16 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + listPaymentsQuerySchema, + listPaymentsResponseSchema, +} from "lib/payments/route-schemas" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: listPaymentsQuerySchema, + jsonResponse: listPaymentsResponseSchema, +})((req, ctx) => { + return ctx.json({ + ok: true, + payments: ctx.db.listPayments(req.query), + }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..809d0a6 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,14 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + sendPaymentBodySchema, + sendPaymentResponseSchema, +} from "lib/payments/route-schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: sendPaymentResponseSchema, +})(async (req, ctx) => { + const { payment, idempotent_replay } = ctx.db.createPayment(req.jsonBody) + return ctx.json({ ok: true, payment, idempotent_replay }) +}) diff --git a/tests/routes/payments-default-middleware.test.ts b/tests/routes/payments-default-middleware.test.ts new file mode 100644 index 0000000..16de96c --- /dev/null +++ b/tests/routes/payments-default-middleware.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, expect, test } from "bun:test" +import { join } from "node:path" +import { Request as EdgeRuntimeRequest } from "@edge-runtime/primitives" +import { resetDefaultDbForTests } from "lib/middleware/with-db" +import { createWinterSpecBundleFromDir } from "winterspec/adapters/node" + +const makeRequest = async (path: string, init?: RequestInit) => { + const winterspecBundle = await createWinterSpecBundleFromDir( + join(import.meta.dir, "../../routes"), + ) + const req = new EdgeRuntimeRequest(`http://127.0.0.1${path}`, init) + const res = await winterspecBundle.makeRequest(req as any) + + return { + status: res.status, + data: await res.json(), + } +} + +beforeEach(() => { + resetDefaultDbForTests() +}) + +test("default database middleware preserves payments across requests", async () => { + await makeRequest("/payments/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + recipient_email: "solver@example.com", + amount_cents: 1000, + }), + }) + + const listRes = await makeRequest("/payments/list") + const getRes = await makeRequest("/payments/get?payment_id=payment_0") + + expect(listRes.status).toBe(200) + expect(listRes.data.payments).toHaveLength(1) + expect(getRes.status).toBe(200) + expect(getRes.data.payment).toMatchObject({ + payment_id: "payment_0", + recipient_email: "solver@example.com", + }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..9edd3d5 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("creates and reads a fake payment", async () => { + const { axios } = await getTestServer() + + const createRes = await axios.post("/payments/send", { + recipient_email: "solver@example.com", + amount_cents: 2500, + bounty_id: "bounty_1", + issue_number: 1, + repository: "tscircuit/fake-algora", + idempotency_key: "claim-1", + }) + + expect(createRes.status).toBe(200) + expect(createRes.data.ok).toBe(true) + expect(createRes.data.idempotent_replay).toBe(false) + expect(createRes.data.payment).toMatchObject({ + payment_id: "payment_0", + recipient_email: "solver@example.com", + amount_cents: 2500, + currency: "usd", + status: "pending", + bounty_id: "bounty_1", + issue_number: 1, + repository: "tscircuit/fake-algora", + idempotency_key: "claim-1", + }) + + const getRes = await axios.get("/payments/get?payment_id=payment_0") + + expect(getRes.status).toBe(200) + expect(getRes.data.payment.payment_id).toBe("payment_0") +}) + +test("replays matching idempotency keys without creating duplicates", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient_email: "solver@example.com", + amount_cents: 1000, + idempotency_key: "repeatable-send", + }) + const replayRes = await axios.post("/payments/send", { + recipient_email: "solver@example.com", + amount_cents: 9999, + idempotency_key: "repeatable-send", + }) + const listRes = await axios.get("/payments/list") + + expect(replayRes.data.idempotent_replay).toBe(true) + expect(replayRes.data.payment.amount_cents).toBe(1000) + expect(listRes.data.payments).toHaveLength(1) +}) + +test("filters payments by status, recipient, and repository", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient_email: "one@example.com", + amount_cents: 1000, + repository: "tscircuit/fake-algora", + }) + await axios.post("/payments/send", { + recipient_email: "two@example.com", + amount_cents: 2000, + repository: "other/repo", + }) + await axios.post("/payments/complete", { payment_id: "payment_1" }) + + const pendingRes = await axios.get("/payments/list?status=pending") + const completedRes = await axios.get( + "/payments/list?status=completed&repository=other/repo", + ) + const recipientRes = await axios.get( + "/payments/list?recipient_email=one@example.com", + ) + + expect( + pendingRes.data.payments.map((payment: any) => payment.payment_id), + ).toEqual(["payment_0"]) + expect( + completedRes.data.payments.map((payment: any) => payment.payment_id), + ).toEqual(["payment_1"]) + expect( + recipientRes.data.payments.map((payment: any) => payment.payment_id), + ).toEqual(["payment_0"]) +}) + +test("prevents terminal payment status changes", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient_email: "solver@example.com", + amount_cents: 1000, + }) + const completeRes = await axios.post("/payments/complete", { + payment_id: "payment_0", + }) + + expect(completeRes.data.payment.status).toBe("completed") + + try { + await axios.post("/payments/cancel", { payment_id: "payment_0" }) + throw new Error("Expected canceling a completed payment to fail") + } catch (error: any) { + expect(error.status).toBe(409) + expect(error.data).toEqual({ + ok: false, + error: "payment_is_not_pending", + }) + } +}) + +test("returns a 404 for missing payments", async () => { + const { axios } = await getTestServer() + + try { + await axios.get("/payments/get?payment_id=missing") + throw new Error("Expected missing payment lookup to fail") + } catch (error: any) { + expect(error.status).toBe(404) + expect(error.data).toEqual({ + ok: false, + error: "payment_not_found", + }) + } +})