diff --git a/README.md b/README.md index 824427a..45fde0a 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,43 @@ 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 payment routes provide a small in-memory API for simulating bounty payouts. + +### Send a payment + +`POST /payments/send` + +```json +{ + "recipient": "octocat", + "amount": 10, + "currency": "USD", + "bounty_id": "fake-algora-1", + "issue_number": 1, + "repository": "tscircuit/fake-algora", + "idempotency_key": "optional-retry-key" +} +``` + +### Read payments + +- `GET /payments/list` +- `GET /payments/list?recipient=octocat&status=pending` +- `GET /payments/get?payment_id=0` + +### Transition payments + +- `POST /payments/complete` +- `POST /payments/cancel` +- `POST /payments/fail` + +Each transition accepts: + +```json +{ + "payment_id": "0" +} +``` diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..b54093b 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 { 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 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) => ({ +export interface CreatePaymentInput { + recipient: string + amount: number + currency?: string + bounty_id?: string + issue_number?: number + repository?: string + idempotency_key?: string + note?: string +} + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +36,107 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + createPayment: (input: CreatePaymentInput) => { + let result: { payment: Payment; idempotent_replay: boolean } | undefined + + set((state) => { + const existingPayment = input.idempotency_key + ? state.payments.find( + (payment) => payment.idempotency_key === input.idempotency_key, + ) + : undefined + + if (existingPayment) { + result = { + payment: existingPayment, + idempotent_replay: true, + } + return {} + } + + const now = new Date().toISOString() + const payment: Payment = { + payment_id: state.paymentIdCounter.toString(), + recipient: input.recipient, + amount: input.amount, + currency: input.currency ?? "USD", + status: "pending", + bounty_id: input.bounty_id, + issue_number: input.issue_number, + repository: input.repository, + idempotency_key: input.idempotency_key, + note: input.note, + created_at: now, + updated_at: now, + } + + result = { + payment, + idempotent_replay: false, + } + + return { + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + } + }) + + if (!result) { + throw new Error("Payment was not created") + } + + return result + }, + listPayments: (filters: { + recipient?: string + status?: PaymentStatus + repository?: string + }) => { + return get().payments.filter((payment) => { + if (filters.recipient && payment.recipient !== filters.recipient) { + return false + } + if (filters.status && payment.status !== filters.status) { + return false + } + if (filters.repository && payment.repository !== filters.repository) { + return false + } + return true + }) + }, + getPayment: (payment_id: string) => { + return get().payments.find((payment) => payment.payment_id === payment_id) + }, + updatePaymentStatus: (payment_id: string, status: PaymentStatus) => { + let updatedPayment: Payment | undefined + + set((state) => { + const now = new Date().toISOString() + const payments = state.payments.map((payment) => { + if (payment.payment_id !== payment_id) { + return payment + } + + updatedPayment = { + ...payment, + status, + updated_at: now, + completed_at: + status === "completed" ? payment.completed_at ?? now : undefined, + canceled_at: + status === "canceled" ? payment.canceled_at ?? now : undefined, + failed_at: status === "failed" ? payment.failed_at ?? now : undefined, + } + + return updatedPayment + }) + + return { + payments, + } + }) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..e789571 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,37 @@ 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(), + note: 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([]), + paymentIdCounter: z.number().default(0), + payments: z.array(paymentSchema).default([]), }) export type DatabaseSchema = z.infer diff --git a/lib/payments/schemas.ts b/lib/payments/schemas.ts new file mode 100644 index 0000000..8d94e46 --- /dev/null +++ b/lib/payments/schemas.ts @@ -0,0 +1,33 @@ +import { paymentSchema } from "lib/db/schema" +import { z } from "zod" + +export 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(), + note: z.string().optional(), +}) + +export const paymentResponseSchema = z.object({ + payment: paymentSchema, +}) + +export const sendPaymentResponseSchema = paymentResponseSchema.extend({ + idempotent_replay: z.boolean(), +}) + +export const nullablePaymentResponseSchema = z.object({ + payment: paymentSchema.nullable(), +}) + +export const listPaymentsResponseSchema = z.object({ + payments: z.array(paymentSchema), +}) + +export const transitionPaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..88d9003 --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,16 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + nullablePaymentResponseSchema, + transitionPaymentBodySchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: transitionPaymentBodySchema, + jsonResponse: nullablePaymentResponseSchema, +})(async (req, ctx) => { + const { payment_id } = transitionPaymentBodySchema.parse(await req.json()) + const payment = ctx.db.updatePaymentStatus(payment_id, "canceled") + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..5e06c12 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,16 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + nullablePaymentResponseSchema, + transitionPaymentBodySchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: transitionPaymentBodySchema, + jsonResponse: nullablePaymentResponseSchema, +})(async (req, ctx) => { + const { payment_id } = transitionPaymentBodySchema.parse(await req.json()) + const payment = ctx.db.updatePaymentStatus(payment_id, "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..2e65f48 --- /dev/null +++ b/routes/payments/fail.ts @@ -0,0 +1,16 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + nullablePaymentResponseSchema, + transitionPaymentBodySchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: transitionPaymentBodySchema, + jsonResponse: nullablePaymentResponseSchema, +})(async (req, ctx) => { + const { payment_id } = transitionPaymentBodySchema.parse(await req.json()) + const payment = ctx.db.updatePaymentStatus(payment_id, "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..8a6ca2d --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,12 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { nullablePaymentResponseSchema } from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: nullablePaymentResponseSchema, +})((req, ctx) => { + const paymentId = new URL(req.url).searchParams.get("payment_id") + const payment = paymentId ? ctx.db.getPayment(paymentId) : undefined + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..cae8ea4 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,19 @@ +import { paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { listPaymentsResponseSchema } from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: listPaymentsResponseSchema, +})((req, ctx) => { + const searchParams = new URL(req.url).searchParams + const status = paymentStatusSchema.safeParse(searchParams.get("status")) + + const payments = ctx.db.listPayments({ + recipient: searchParams.get("recipient") ?? undefined, + repository: searchParams.get("repository") ?? undefined, + status: status.success ? status.data : undefined, + }) + + return ctx.json({ payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..9ca4fe2 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,16 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + sendPaymentBodySchema, + sendPaymentResponseSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: sendPaymentResponseSchema, +})(async (req, ctx) => { + const body = sendPaymentBodySchema.parse(await req.json()) + const result = ctx.db.createPayment(body) + + return ctx.json(result) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..6a03e22 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("sends and lists fake payments", async () => { + const { axios } = await getTestServer() + + const { data: sent } = await axios.post("/payments/send", { + recipient: "octocat", + amount: 10, + currency: "USD", + bounty_id: "fake-algora-1", + issue_number: 1, + repository: "tscircuit/fake-algora", + note: "Bounty payout", + }) + + expect(sent.idempotent_replay).toBe(false) + expect(sent.payment).toMatchObject({ + payment_id: "0", + recipient: "octocat", + amount: 10, + currency: "USD", + status: "pending", + bounty_id: "fake-algora-1", + issue_number: 1, + repository: "tscircuit/fake-algora", + }) + + const { data: listed } = await axios.get( + "/payments/list?recipient=octocat&status=pending", + ) + + expect(listed.payments).toHaveLength(1) + expect(listed.payments[0].payment_id).toBe("0") +}) + +test("reuses payment for matching idempotency key", async () => { + const { axios } = await getTestServer() + + const requestBody = { + recipient: "retry-safe-user", + amount: 5, + idempotency_key: "retry-key-1", + } + + const { data: first } = await axios.post("/payments/send", requestBody) + const { data: second } = await axios.post("/payments/send", requestBody) + const { data: listed } = await axios.get("/payments/list") + + expect(first.idempotent_replay).toBe(false) + expect(second.idempotent_replay).toBe(true) + expect(second.payment.payment_id).toBe(first.payment.payment_id) + expect(listed.payments).toHaveLength(1) +}) + +test("gets and transitions payment status", async () => { + const { axios } = await getTestServer() + + const { data: sent } = await axios.post("/payments/send", { + recipient: "maintainer", + amount: 12, + }) + + const { data: fetched } = await axios.get( + `/payments/get?payment_id=${sent.payment.payment_id}`, + ) + + expect(fetched.payment.status).toBe("pending") + + const { data: completed } = await axios.post("/payments/complete", { + payment_id: sent.payment.payment_id, + }) + + expect(completed.payment.status).toBe("completed") + expect(typeof completed.payment.completed_at).toBe("string") +})