diff --git a/README.md b/README.md index 824427a..cfa5aae 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 + +## Payment API + +The fake payment lifecycle supports the core bounty-payment flow: + +- `POST /payments/send` creates a pending payment record. Include an optional + `idempotency_key` to make retries return the original payment instead of + creating duplicates. +- `GET /payments/list` returns payments, with optional `recipient`, `status`, + and `repository` query filters. +- `GET /payments/get?payment_id=` returns a single payment. +- `POST /payments/complete` marks a payment as completed. +- `POST /payments/cancel` marks a payment as canceled. diff --git a/bun.lockb b/bun.lockb index 557d79c..0c63394 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..90ed9ed 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 { 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 Payment, + type PaymentStatus, + type Thing, + databaseSchema, +} from "./schema.ts" export const createDatabase = () => { return hoist(createStore(initializer)) @@ -11,7 +16,25 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +type PaymentInput = Omit< + Payment, + | "payment_id" + | "status" + | "created_at" + | "updated_at" + | "completed_at" + | "canceled_at" + | "failed_at" +> + +const statusTimestampField = { + completed: "completed_at", + canceled: "canceled_at", + failed: "failed_at", + pending: undefined, +} as const satisfies Record + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +44,77 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + addPayment: (payment: PaymentInput): Payment => { + const existingPayment = payment.idempotency_key + ? get().payments.find( + (storedPayment) => + storedPayment.idempotency_key === payment.idempotency_key, + ) + : undefined + + if (existingPayment) { + return existingPayment + } + + const now = new Date().toISOString() + const newPayment: Payment = { + ...payment, + payment_id: get().idCounter.toString(), + status: "pending", + created_at: now, + updated_at: now, + } + + set((state) => ({ + payments: [...state.payments, newPayment], + idCounter: state.idCounter + 1, + })) + + return newPayment + }, + getPayment: (payment_id: string): Payment | undefined => { + return get().payments.find((payment) => payment.payment_id === payment_id) + }, + listPayments: ( + filters: Partial>, + ): Payment[] => { + 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 + }) + }, + updatePaymentStatus: ( + payment_id: string, + status: Exclude, + ): Payment | undefined => { + const now = new Date().toISOString() + let updatedPayment: Payment | undefined + + set((state) => ({ + payments: state.payments.map((payment) => { + if (payment.payment_id !== payment_id) { + return payment + } + + const timestampField = statusTimestampField[status] + updatedPayment = { + ...payment, + status, + updated_at: now, + ...(timestampField ? { [timestampField]: now } : {}), + } + return updatedPayment + }), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..67044b6 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,35 @@ 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().positive(), + currency: z.string(), + status: paymentStatusSchema, + bounty_id: z.string().optional(), + issue_number: z.number().int().positive().optional(), + repository: z.string().optional(), + idempotency_key: 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([]), + 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..6e622ec --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,27 @@ +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(), + }), + jsonResponse: z.union([ + z.object({ + payment: paymentSchema, + }), + z.object({ + error: z.string(), + }), + ]), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.updatePaymentStatus(payment_id, "canceled") + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..1af6be4 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,27 @@ +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(), + }), + jsonResponse: z.union([ + z.object({ + payment: paymentSchema, + }), + z.object({ + error: z.string(), + }), + ]), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.updatePaymentStatus(payment_id, "completed") + + if (!payment) { + return ctx.json({ error: "Payment not found" }, { status: 404 }) + } + + return ctx.json({ payment }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..2f898b5 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,25 @@ +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.union([ + z.object({ + payment: paymentSchema, + }), + z.object({ + error: z.string(), + }), + ]), +})((req, ctx) => { + const url = new URL(req.url) + const paymentId = url.searchParams.get("payment_id") + const payment = paymentId ? ctx.db.getPayment(paymentId) : undefined + + 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..28e8540 --- /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 + + const payments = ctx.db.listPayments({ + recipient: url.searchParams.get("recipient") ?? undefined, + repository: url.searchParams.get("repository") ?? undefined, + status, + }) + + return ctx.json({ payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..28f4bc5 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,26 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const paymentRequestSchema = z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(1).default("USD"), + repository: z.string().optional(), + issue_number: z.number().int().positive().optional(), + bounty_id: z.string().optional(), + idempotency_key: z.string().optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentRequestSchema, + jsonResponse: z.object({ + payment: paymentSchema, + }), +})(async (req, ctx) => { + const paymentRequest = await req.json() + const payment = ctx.db.addPayment(paymentRequest) + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..08aa968 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send payment is idempotent and queryable", async () => { + const { axios } = await getTestServer() + + const paymentRequest = { + recipient: "richboyneedcash", + amount: 10, + currency: "USD", + repository: "tscircuit/fake-algora", + issue_number: 1, + bounty_id: "fake-algora-1", + idempotency_key: "issue-1-richboyneedcash", + } + + const firstResponse = await axios.post("/payments/send", paymentRequest) + const replayResponse = await axios.post("/payments/send", paymentRequest) + + expect(replayResponse.data.payment.payment_id).toBe( + firstResponse.data.payment.payment_id, + ) + + const { data: listData } = await axios.get( + "/payments/list?recipient=richboyneedcash&status=pending", + ) + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0]).toMatchObject({ + recipient: "richboyneedcash", + amount: 10, + currency: "USD", + repository: "tscircuit/fake-algora", + issue_number: 1, + status: "pending", + }) +}) + +test("payment lifecycle can be completed or canceled", async () => { + const { axios } = await getTestServer() + + const { data: sentData } = await axios.post("/payments/send", { + recipient: "first-contributor", + amount: 15, + currency: "USD", + repository: "tscircuit/fake-algora", + issue_number: 1, + }) + + const paymentId = sentData.payment.payment_id + + const { data: getData } = await axios.get( + `/payments/get?payment_id=${paymentId}`, + ) + expect(getData.payment.status).toBe("pending") + + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: paymentId, + }) + expect(completeData.payment.status).toBe("completed") + expect(completeData.payment.completed_at).toBeString() + + const { data: cancelSentData } = await axios.post("/payments/send", { + recipient: "second-contributor", + amount: 20, + currency: "USD", + }) + const { data: cancelData } = await axios.post("/payments/cancel", { + payment_id: cancelSentData.payment.payment_id, + }) + expect(cancelData.payment.status).toBe("canceled") + expect(cancelData.payment.canceled_at).toBeString() +})