diff --git a/README.md b/README.md index 824427a..ba8c59f 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,15 @@ 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 API includes an in-memory fake payment lifecycle for bounty payouts: + +- `POST /payments/send` creates a pending payment. Include `recipient`, `amount`, `currency`, and optional `repository`, `issue_number`, `bounty_id`, or `idempotency_key`. +- `GET /payments/list` returns payments, optionally filtered by `status`, `recipient`, or `repository`. +- `POST /payments/get` accepts `payment_id` and returns one payment. +- `POST /payments/complete` marks a pending payment as completed. +- `POST /payments/cancel` marks a pending payment as canceled. + +Repeated `/payments/send` calls with the same `idempotency_key` body field or `Idempotency-Key` header return the original payment instead of creating duplicates. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..ce71b11 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,14 @@ -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 { hoist } from "zustand-hoist" import { combine } from "zustand/middleware" +import { createStore } from "zustand/vanilla" + +import { + type DatabaseSchema, + type Payment, + type PaymentStatus, + type Thing, + databaseSchema, +} from "./schema.ts" export const createDatabase = () => { return hoist(createStore(initializer)) @@ -11,7 +16,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 +26,97 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: ( + payment: Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" + >, + ) => { + const now = new Date().toISOString() + let createdPayment: Payment | undefined + + set((state) => { + const existingPayment = payment.idempotency_key + ? state.payments.find( + (storedPayment) => + storedPayment.idempotency_key === payment.idempotency_key, + ) + : undefined + + if (existingPayment) { + createdPayment = existingPayment + return state + } + + createdPayment = { + ...payment, + payment_id: state.paymentIdCounter.toString(), + status: "pending", + created_at: now, + updated_at: now, + } + + return { + payments: [...state.payments, createdPayment], + paymentIdCounter: state.paymentIdCounter + 1, + } + }) + + if (!createdPayment) { + throw new Error("Failed to create payment") + } + + return createdPayment + }, + findPayment: (paymentId: string) => { + return get().payments.find((payment) => payment.payment_id === paymentId) + }, + listPayments: (filters: { + status?: PaymentStatus + recipient?: string + repository?: string + }) => { + return get().payments.filter((payment) => { + return ( + (!filters.status || payment.status === filters.status) && + (!filters.recipient || payment.recipient === filters.recipient) && + (!filters.repository || payment.repository === filters.repository) + ) + }) + }, + transitionPayment: ( + paymentId: string, + status: Exclude, + ) => { + let updatedPayment: Payment | undefined + let transitionError: "not_found" | "terminal" | undefined + + set((state) => { + const payment = state.payments.find( + (storedPayment) => storedPayment.payment_id === paymentId, + ) + + if (!payment) { + transitionError = "not_found" + return state + } + + if (payment.status !== "pending") { + transitionError = "terminal" + updatedPayment = payment + return state + } + + const now = new Date().toISOString() + const payments = state.payments.map((storedPayment) => { + if (storedPayment.payment_id !== paymentId) return storedPayment + updatedPayment = { ...storedPayment, status, updated_at: now } + return updatedPayment + }) + + return { payments } + }) + + return { payment: updatedPayment, error: transitionError } + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..5134bfc 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(), + repository: z.string().optional(), + issue_number: z.number().int().positive().optional(), + bounty_id: 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), things: z.array(thingSchema).default([]), + paymentIdCounter: 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..9eb8f72 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 defaultDb = 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 = defaultDb } return next(req, ctx) } diff --git a/lib/payments/route-schemas.ts b/lib/payments/route-schemas.ts new file mode 100644 index 0000000..7ac349b --- /dev/null +++ b/lib/payments/route-schemas.ts @@ -0,0 +1,34 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { z } from "zod" + +export const sendPaymentRequestSchema = z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(1).default("USD"), + repository: z.string().min(1).optional(), + issue_number: z.number().int().positive().optional(), + bounty_id: z.string().min(1).optional(), + idempotency_key: z.string().min(1).optional(), +}) + +export const paymentResponseSchema = z.object({ + payment: paymentSchema, +}) + +export const listPaymentsQuerySchema = z.object({ + status: paymentStatusSchema.optional(), + recipient: z.string().optional(), + repository: z.string().optional(), +}) + +export const listPaymentsResponseSchema = z.object({ + payments: z.array(paymentSchema), +}) + +export const paymentIdRequestSchema = z.object({ + payment_id: z.string().min(1), +}) + +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..544f42d --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,27 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentErrorResponseSchema, + paymentIdRequestSchema, + paymentResponseSchema, +} from "lib/payments/route-schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentIdRequestSchema, + jsonResponse: paymentResponseSchema.or(paymentErrorResponseSchema), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const { payment, error } = ctx.db.transitionPayment(payment_id, "canceled") + + if (error === "not_found") { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + if (error === "terminal") { + return ctx.json({ error: "Payment is already terminal" }, { status: 409 }) + } + if (!payment) { + return ctx.json({ error: "Payment transition failed" }, { status: 500 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..b672f3e --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,27 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentErrorResponseSchema, + paymentIdRequestSchema, + paymentResponseSchema, +} from "lib/payments/route-schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentIdRequestSchema, + jsonResponse: paymentResponseSchema.or(paymentErrorResponseSchema), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const { payment, error } = ctx.db.transitionPayment(payment_id, "completed") + + if (error === "not_found") { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + if (error === "terminal") { + return ctx.json({ error: "Payment is already terminal" }, { status: 409 }) + } + if (!payment) { + return ctx.json({ error: "Payment transition failed" }, { status: 500 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..260ea4e --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,19 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentErrorResponseSchema, + paymentIdRequestSchema, + paymentResponseSchema, +} from "lib/payments/route-schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentIdRequestSchema, + jsonResponse: paymentResponseSchema.or(paymentErrorResponseSchema), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.findPayment(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..d4ef0e3 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,13 @@ +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({ payments: ctx.db.listPayments(req.query) }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..57de68f --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,22 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentResponseSchema, + sendPaymentRequestSchema, +} from "lib/payments/route-schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentRequestSchema, + jsonResponse: paymentResponseSchema, +})(async (req, ctx) => { + const paymentRequest = await req.json() + const payment = ctx.db.sendPayment({ + ...paymentRequest, + idempotency_key: + paymentRequest.idempotency_key ?? + req.headers.get("Idempotency-Key") ?? + undefined, + }) + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments-default-middleware.test.ts b/tests/routes/payments-default-middleware.test.ts new file mode 100644 index 0000000..493bd72 --- /dev/null +++ b/tests/routes/payments-default-middleware.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test" +import { join } from "node:path" +import { Request as EdgeRuntimeRequest } from "@edge-runtime/primitives" +import { createWinterSpecBundleFromDir } from "winterspec/adapters/node" + +const request = async ( + path: string, + options: { + method?: string + body?: unknown + headers?: Record + } = {}, +) => { + const headers = new Headers(options.headers) + if (options.body && !headers.has("content-type")) { + headers.set("content-type", "application/json") + } + + const req = new EdgeRuntimeRequest(`http://127.0.0.1${path}`, { + method: options.method ?? "GET", + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }) + + const bundle = await createWinterSpecBundleFromDir( + join(import.meta.dir, "../../routes"), + ) + + return bundle.makeRequest(req as any) +} + +test("default middleware keeps payment state across requests", async () => { + const sendResponse = await request("/payments/send", { + method: "POST", + body: { + recipient: "maintainer@example.com", + amount: 10, + currency: "USD", + idempotency_key: "default-middleware-key", + }, + }) + const sendJson = await sendResponse.json() + + expect(sendJson.payment.status).toBe("pending") + + const listResponse = await request("/payments/list") + const listJson = await listResponse.json() + + expect( + listJson.payments.some( + (payment: { payment_id: string }) => + payment.payment_id === sendJson.payment.payment_id, + ), + ).toBe(true) +}) + +test("send payment accepts Idempotency-Key header", async () => { + const firstResponse = await request("/payments/send", { + method: "POST", + headers: { "Idempotency-Key": "header-idempotency-key" }, + body: { + recipient: "maintainer@example.com", + amount: 20, + currency: "USD", + }, + }) + const secondResponse = await request("/payments/send", { + method: "POST", + headers: { "Idempotency-Key": "header-idempotency-key" }, + body: { + recipient: "maintainer@example.com", + amount: 20, + currency: "USD", + }, + }) + + const firstJson = await firstResponse.json() + const secondJson = await secondResponse.json() + + expect(secondJson.payment.payment_id).toBe(firstJson.payment.payment_id) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..fab346a --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +const expectPostError = async ( + request: Promise, + expectedStatus: number, + expectedError: string, +) => { + try { + await request + throw new Error("Expected request to fail") + } catch (error) { + const response = error as { status?: number; data?: { error?: string } } + expect(response.status).toBe(expectedStatus) + expect(response.data?.error).toBe(expectedError) + } +} + +test("creates, lists, fetches, and completes a payment", async () => { + const { axios } = await getTestServer() + + const sendResponse = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + currency: "USD", + repository: "tscircuit/fake-algora", + issue_number: 1, + bounty_id: "fake-algora-1", + }) + + expect(sendResponse.data.payment).toMatchObject({ + payment_id: "0", + recipient: "maintainer@example.com", + amount: 10, + currency: "USD", + repository: "tscircuit/fake-algora", + issue_number: 1, + bounty_id: "fake-algora-1", + status: "pending", + }) + + const listResponse = await axios.get("/payments/list", { + params: { + status: "pending", + repository: "tscircuit/fake-algora", + }, + }) + + expect(listResponse.data.payments).toHaveLength(1) + + const getResponse = await axios.post("/payments/get", { + payment_id: "0", + }) + + expect(getResponse.data.payment.payment_id).toBe("0") + + const completeResponse = await axios.post("/payments/complete", { + payment_id: "0", + }) + + expect(completeResponse.data.payment.status).toBe("completed") + + const completedListResponse = await axios.get("/payments/list", { + params: { status: "completed" }, + }) + + expect(completedListResponse.data.payments).toHaveLength(1) +}) + +test("replays payment send with the same idempotency key", async () => { + const { axios } = await getTestServer() + + const firstResponse = await axios.post("/payments/send", { + recipient: "contributor@example.com", + amount: 35, + currency: "USD", + idempotency_key: "retry-key", + }) + const secondResponse = await axios.post("/payments/send", { + recipient: "contributor@example.com", + amount: 35, + currency: "USD", + idempotency_key: "retry-key", + }) + + expect(secondResponse.data.payment.payment_id).toBe( + firstResponse.data.payment.payment_id, + ) + + const listResponse = await axios.get("/payments/list") + expect(listResponse.data.payments).toHaveLength(1) +}) + +test("rejects terminal payment transitions", async () => { + const { axios } = await getTestServer() + + const sendResponse = await axios.post("/payments/send", { + recipient: "contributor@example.com", + amount: 12, + currency: "USD", + }) + const paymentId = sendResponse.data.payment.payment_id + + await axios.post("/payments/cancel", { payment_id: paymentId }) + + await expectPostError( + axios.post("/payments/complete", { + payment_id: paymentId, + }), + 409, + "Payment is already terminal", + ) +}) + +test("returns 404 for missing payment lookup", async () => { + const { axios } = await getTestServer() + + await expectPostError( + axios.post("/payments/get", { + payment_id: "missing", + }), + 404, + "Payment not found", + ) +})