diff --git a/README.md b/README.md index 824427a..390c171 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,17 @@ 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 fake payment lifecycle for bounty payouts: + +- `POST /payments/send` creates a pending payment. The body accepts `recipient`, + `amount`, optional `currency`, optional bounty metadata, and an optional + `idempotency_key` for retry-safe sends. +- `GET /payments/list` lists payments. Optional query filters: `recipient`, + `repository`, and `status`. +- `GET /payments/get?payment_id=pay_1` returns one payment. +- `POST /payments/complete` marks a pending payment as completed. +- `POST /payments/cancel` marks a pending payment as canceled. +- `POST /payments/fail` marks a pending payment as failed. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..4d9fd94 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,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 +25,62 @@ 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() + const existingPayment = payment.idempotency_key + ? get().payments.find( + (existing) => existing.idempotency_key === payment.idempotency_key, + ) + : undefined + + if (existingPayment) { + return existingPayment + } + + let createdPayment: Payment | undefined + set((state) => { + createdPayment = { + ...payment, + payment_id: `pay_${state.idCounter}`, + status: "pending", + created_at: now, + updated_at: now, + } + + return { + payments: [...state.payments, createdPayment], + idCounter: state.idCounter + 1, + } + }) + + return createdPayment! + }, + updatePaymentStatus: (paymentId: string, status: PaymentStatus) => { + const payment = get().payments.find( + (existing) => existing.payment_id === paymentId, + ) + + if (!payment) { + return undefined + } + + const updatedPayment = { + ...payment, + status, + updated_at: new Date().toISOString(), + } + + set((state) => ({ + payments: state.payments.map((existing) => + existing.payment_id === paymentId ? updatedPayment : existing, + ), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..c415708 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,32 @@ 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(), +}) +export type Payment = z.infer + export const databaseSchema = z.object({ idCounter: 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..a0622e8 --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,32 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + payment_id: z.string().min(1), + }), + jsonResponse: z.object({ + ok: z.boolean(), + error: z.string().optional(), + payment: paymentSchema.optional(), + }), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.payments.find( + (existing) => existing.payment_id === payment_id, + ) + + if (!payment) { + return ctx.json({ ok: false, error: "Payment not found" }) + } + + if (payment.status !== "pending") { + return ctx.json({ ok: false, error: "Only pending payments can cancel" }) + } + + const updatedPayment = ctx.db.updatePaymentStatus(payment_id, "canceled") + + return ctx.json({ ok: true, payment: updatedPayment }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..ebe8954 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,32 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + payment_id: z.string().min(1), + }), + jsonResponse: z.object({ + ok: z.boolean(), + error: z.string().optional(), + payment: paymentSchema.optional(), + }), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.payments.find( + (existing) => existing.payment_id === payment_id, + ) + + if (!payment) { + return ctx.json({ ok: false, error: "Payment not found" }) + } + + if (payment.status !== "pending") { + return ctx.json({ ok: false, error: "Only pending payments can complete" }) + } + + const updatedPayment = ctx.db.updatePaymentStatus(payment_id, "completed") + + return ctx.json({ ok: true, payment: updatedPayment }) +}) diff --git a/routes/payments/fail.ts b/routes/payments/fail.ts new file mode 100644 index 0000000..cd9179d --- /dev/null +++ b/routes/payments/fail.ts @@ -0,0 +1,32 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + payment_id: z.string().min(1), + }), + jsonResponse: z.object({ + ok: z.boolean(), + error: z.string().optional(), + payment: paymentSchema.optional(), + }), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.payments.find( + (existing) => existing.payment_id === payment_id, + ) + + if (!payment) { + return ctx.json({ ok: false, error: "Payment not found" }) + } + + if (payment.status !== "pending") { + return ctx.json({ ok: false, error: "Only pending payments can fail" }) + } + + const updatedPayment = ctx.db.updatePaymentStatus(payment_id, "failed") + + return ctx.json({ ok: true, payment: updatedPayment }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..6061a89 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,23 @@ +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({ + ok: z.boolean(), + error: z.string().optional(), + payment: paymentSchema.optional(), + }), +})((req, ctx) => { + const paymentId = new URL(req.url).searchParams.get("payment_id") + const payment = ctx.db.payments.find( + (existing) => existing.payment_id === paymentId, + ) + + if (!payment) { + return ctx.json({ ok: false, error: "Payment not found" }) + } + + return ctx.json({ ok: true, payment }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..1631225 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,25 @@ +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 searchParams = new URL(req.url).searchParams + const recipient = searchParams.get("recipient") + const repository = searchParams.get("repository") + const statusResult = paymentStatusSchema.safeParse(searchParams.get("status")) + const status = statusResult.success ? statusResult.data : undefined + + const payments = ctx.db.payments.filter((payment) => { + if (recipient && payment.recipient !== recipient) return false + if (repository && payment.repository !== repository) return false + if (status && payment.status !== status) 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..7fb8091 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,42 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(1).default("USD"), + bounty_id: z.string().optional(), + issue_number: z.number().int().positive().optional(), + repository: z.string().optional(), + idempotency_key: z.string().optional(), + }), + jsonResponse: z.object({ + ok: z.boolean(), + payment: paymentSchema, + }), +})(async (req, ctx) => { + const { + recipient, + amount, + currency = "USD", + bounty_id, + issue_number, + repository, + idempotency_key, + } = await req.json() + + const payment = ctx.db.sendPayment({ + recipient, + amount, + currency, + bounty_id, + issue_number, + repository, + idempotency_key, + }) + + return ctx.json({ ok: true, payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..de44d80 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send, list, and complete a payment", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + currency: "USD", + bounty_id: "bounty_123", + issue_number: 1, + repository: "tscircuit/fake-algora", + }) + + expect(sendData.ok).toBe(true) + expect(sendData.payment.payment_id.startsWith("pay_")).toBe(true) + expect(sendData.payment.status).toBe("pending") + expect(sendData.payment.amount).toBe(10) + + const { data: listData } = await axios.get( + "/payments/list?repository=tscircuit/fake-algora&status=pending", + ) + + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0].payment_id).toBe(sendData.payment.payment_id) + + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: sendData.payment.payment_id, + }) + + expect(completeData.ok).toBe(true) + expect(completeData.payment.status).toBe("completed") +}) + +test("send payment is idempotent when an idempotency key is supplied", async () => { + const { axios } = await getTestServer() + const paymentRequest = { + recipient: "contributor@example.com", + amount: 25, + idempotency_key: "retry-safe-key", + } + + const { data: firstSendData } = await axios.post( + "/payments/send", + paymentRequest, + ) + const { data: secondSendData } = await axios.post( + "/payments/send", + paymentRequest, + ) + const { data: listData } = await axios.get("/payments/list") + + expect(secondSendData.payment.payment_id).toBe( + firstSendData.payment.payment_id, + ) + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0].currency).toBe("USD") +}) + +test("terminal payments cannot be canceled", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "contributor@example.com", + amount: 5, + }) + + await axios.post("/payments/complete", { + payment_id: sendData.payment.payment_id, + }) + + const { data: cancelData } = await axios.post("/payments/cancel", { + payment_id: sendData.payment.payment_id, + }) + + expect(cancelData.ok).toBe(false) + expect(cancelData.error).toBe("Only pending payments can cancel") +})