diff --git a/README.md b/README.md index 824427a..547f449 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,19 @@ 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 fake payment lifecycle is exposed through Winterspec routes: + +- `POST /payments/send` creates a pending payment. It accepts + `recipient_email`, `amount_cents`, `currency`, optional bounty metadata, and + an optional `idempotency_key`. +- `GET /payments/list` lists payments. Optional query filters: + `status`, `recipient_email`, and `repository`. +- `GET /payments/get?payment_id=...` returns one payment or `null`. +- `POST /payments/complete` marks a payment as completed. +- `POST /payments/cancel` marks a payment as canceled. + +Repeated `POST /payments/send` calls with the same `idempotency_key` return the +existing payment with `idempotent_replay: true`. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..5537438 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,15 @@ -import { createStore, type StoreApi } from "zustand/vanilla" +import { type HoistedStoreApi, hoist } from "zustand-hoist" import { immer } from "zustand/middleware/immer" -import { hoist, type HoistedStoreApi } from "zustand-hoist" +import { type StoreApi, createStore } from "zustand/vanilla" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" import { combine } from "zustand/middleware" +import { + type DatabaseSchema, + type Payment, + type PaymentStatus, + type Thing, + databaseSchema, +} from "./schema.ts" export const createDatabase = () => { return hoist(createStore(initializer)) @@ -11,7 +17,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 +27,67 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + createPayment: ( + payment: Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" + >, + ) => { + const now = new Date().toISOString() + const nextPayment: Payment = { + ...payment, + payment_id: get().paymentIdCounter.toString(), + status: "pending", + created_at: now, + updated_at: now, + } + + set((state) => ({ + payments: [...state.payments, nextPayment], + paymentIdCounter: state.paymentIdCounter + 1, + })) + + return nextPayment + }, + getPaymentById: (paymentId: string) => { + return get().payments.find((payment) => payment.payment_id === paymentId) + }, + getPaymentByIdempotencyKey: (idempotencyKey: string) => { + return get().payments.find( + (payment) => payment.idempotency_key === idempotencyKey, + ) + }, + listPayments: (filters?: { + status?: PaymentStatus + recipient_email?: string + repository?: string + }) => { + 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 + }) + }, + updatePaymentStatus: (paymentId: string, status: PaymentStatus) => { + const now = new Date().toISOString() + let updatedPayment: Payment | undefined + + set((state) => ({ + payments: state.payments.map((payment) => { + if (payment.payment_id !== paymentId) 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..5b8d541 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,29 @@ 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), + status: paymentStatusSchema, + idempotency_key: z.string().optional(), + bounty_id: z.string().optional(), + repository: z.string().optional(), + issue_number: z.number().int().positive().optional(), + memo: 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), + 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/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..14d7f2c --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,20 @@ +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(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id } = updatePaymentBodySchema.parse(await req.json()) + return ctx.json({ + payment: ctx.db.updatePaymentStatus(payment_id, "canceled") ?? null, + }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..4298145 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,20 @@ +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(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: updatePaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id } = updatePaymentBodySchema.parse(await req.json()) + return ctx.json({ + payment: ctx.db.updatePaymentStatus(payment_id, "completed") ?? null, + }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..cf7ac5a --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,17 @@ +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 paymentId = url.searchParams.get("payment_id") + + return ctx.json({ + payment: paymentId ? ctx.db.getPaymentById(paymentId) ?? null : null, + }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..034ca52 --- /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 + + return ctx.json({ + payments: ctx.db.listPayments({ + status, + recipient_email: url.searchParams.get("recipient_email") ?? undefined, + repository: url.searchParams.get("repository") ?? undefined, + }), + }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..d43cea9 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,37 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const sendPaymentBodySchema = z.object({ + recipient_email: z.string().email(), + amount_cents: z.number().int().positive(), + currency: z.string().length(3).default("USD"), + idempotency_key: z.string().min(1).optional(), + bounty_id: z.string().min(1).optional(), + repository: z.string().min(1).optional(), + issue_number: z.number().int().positive().optional(), + memo: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema, + idempotent_replay: z.boolean(), + }), +})(async (req, ctx) => { + const body = sendPaymentBodySchema.parse(await req.json()) + + if (body.idempotency_key) { + const existingPayment = ctx.db.getPaymentByIdempotencyKey( + body.idempotency_key, + ) + if (existingPayment) { + return ctx.json({ payment: existingPayment, idempotent_replay: true }) + } + } + + const payment = ctx.db.createPayment(body) + return ctx.json({ payment, idempotent_replay: false }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..27f6099 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("payment API supports send, idempotent replay, filtering, and status changes", async () => { + const { axios } = await getTestServer() + + const sendResponse = await axios.post("/payments/send", { + recipient_email: "dev@example.com", + amount_cents: 1250, + currency: "USD", + idempotency_key: "issue-1-pr-22", + bounty_id: "bounty_1", + repository: "tscircuit/fake-algora", + issue_number: 1, + memo: "Fake Algora payout", + }) + + expect(sendResponse.status).toBe(200) + expect(sendResponse.data.idempotent_replay).toBe(false) + expect(sendResponse.data.payment).toMatchObject({ + recipient_email: "dev@example.com", + amount_cents: 1250, + currency: "USD", + status: "pending", + idempotency_key: "issue-1-pr-22", + repository: "tscircuit/fake-algora", + issue_number: 1, + }) + + const replayResponse = await axios.post("/payments/send", { + recipient_email: "dev@example.com", + amount_cents: 1250, + currency: "USD", + idempotency_key: "issue-1-pr-22", + }) + + expect(replayResponse.data.idempotent_replay).toBe(true) + expect(replayResponse.data.payment.payment_id).toBe( + sendResponse.data.payment.payment_id, + ) + + const pendingListResponse = await axios.get( + "/payments/list?status=pending&repository=tscircuit/fake-algora", + ) + + expect(pendingListResponse.data.payments).toHaveLength(1) + + const completeResponse = await axios.post("/payments/complete", { + payment_id: sendResponse.data.payment.payment_id, + }) + + expect(completeResponse.data.payment.status).toBe("completed") + + const getResponse = await axios.get( + `/payments/get?payment_id=${sendResponse.data.payment.payment_id}`, + ) + + expect(getResponse.data.payment.status).toBe("completed") +})