diff --git a/README.md b/README.md index 824427a..5aa7b60 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,57 @@ 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 + +This project includes a fake payment API for exercising bounty payout flows +without moving real money. + +### Send a payment + +```http +POST /payments/send +``` + +```json +{ + "recipient": "maintainer@example.com", + "amount": 10, + "currency": "USD", + "owner": "tscircuit", + "repo": "fake-algora", + "repository": "tscircuit/fake-algora", + "issue_number": 1, + "bounty_id": "bounty_123", + "idempotency_key": "retry-safe-payment" +} +``` + +The response includes the fake `payment` and an `idempotent` boolean. Reusing +the same `idempotency_key` returns the original payment instead of creating a +duplicate. + +### Read payments + +```http +GET /payments/get?payment_id=0 +GET /payments/list?recipient=maintainer@example.com&status=pending +GET /payments/list?owner=tscircuit&repo=fake-algora&issue_number=1 +``` + +List filters support `recipient`, `status`, `owner`, `repo`, `repository`, +`bounty_id`, and `issue_number`. + +### Update payment status + +```http +POST /payments/complete +POST /payments/cancel +POST /payments/fail +``` + +```json +{ "payment_id": "0" } +``` + +Cancel and fail also accept `cancel_reason` and `failure_reason` respectively. 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..ae5a5a0 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,36 @@ -import { createStore, type StoreApi } from "zustand/vanilla" +import { type HoistedStoreApi, hoist } from "zustand-hoist" +import { combine } from "zustand/middleware" import { immer } from "zustand/middleware/immer" -import { hoist, type HoistedStoreApi } from "zustand-hoist" +import { type StoreApi, createStore } from "zustand/vanilla" +import { + type Payment, + type PaymentStatus, + type Thing, + databaseSchema, +} from "./schema.ts" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" -import { combine } from "zustand/middleware" +type CreatePaymentInput = Omit< + Payment, + | "payment_id" + | "status" + | "created_at" + | "updated_at" + | "completed_at" + | "canceled_at" + | "failed_at" + | "cancel_reason" + | "failure_reason" +> + +type PaymentFilters = { + recipient?: string + status?: PaymentStatus + owner?: string + repo?: string + repository?: string + bounty_id?: string + issue_number?: number +} export const createDatabase = () => { return hoist(createStore(initializer)) @@ -11,7 +38,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 +48,111 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: (input: CreatePaymentInput) => { + if (input.idempotency_key) { + const existingPayment = get().payments.find( + (item) => item.idempotency_key === input.idempotency_key, + ) + + if (existingPayment) return existingPayment + } + + const now = new Date().toISOString() + const payment: Payment = { + ...input, + payment_id: get().paymentIdCounter.toString(), + status: "pending", + created_at: now, + updated_at: now, + } + + set((state) => ({ + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + })) + + return payment + }, + listPayments: (filters: PaymentFilters = {}) => { + 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.owner && payment.owner !== filters.owner) { + return false + } + if (filters.repo && payment.repo !== filters.repo) { + return false + } + if (filters.repository && payment.repository !== filters.repository) { + return false + } + if (filters.bounty_id && payment.bounty_id !== filters.bounty_id) { + return false + } + if ( + filters.issue_number !== undefined && + payment.issue_number !== filters.issue_number + ) { + return false + } + return true + }) + }, + getPayment: (paymentId: string) => { + return get().payments.find((payment) => payment.payment_id === paymentId) + }, + completePayment: (paymentId: string) => { + return get().updatePaymentStatus(paymentId, "completed") + }, + cancelPayment: (paymentId: string, cancelReason?: string) => { + return get().updatePaymentStatus(paymentId, "canceled", { + cancel_reason: cancelReason, + }) + }, + failPayment: (paymentId: string, failureReason?: string) => { + return get().updatePaymentStatus(paymentId, "failed", { + failure_reason: failureReason, + }) + }, + updatePaymentStatus: ( + paymentId: string, + status: Exclude, + options: { cancel_reason?: string; failure_reason?: string } = {}, + ) => { + const existingPayment = get().payments.find( + (payment) => payment.payment_id === paymentId, + ) + + if (!existingPayment) return undefined + + const now = new Date().toISOString() + const payment: Payment = { + ...existingPayment, + status, + updated_at: now, + completed_at: status === "completed" ? now : existingPayment.completed_at, + canceled_at: status === "canceled" ? now : existingPayment.canceled_at, + failed_at: status === "failed" ? now : existingPayment.failed_at, + cancel_reason: + status === "canceled" + ? options.cancel_reason + : existingPayment.cancel_reason, + failure_reason: + status === "failed" + ? options.failure_reason + : existingPayment.failure_reason, + } + + set((state) => ({ + payments: state.payments.map((item) => + item.payment_id === paymentId ? payment : item, + ), + })) + + return payment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..69e0cf4 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,40 @@ 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, + owner: z.string().optional(), + repo: z.string().optional(), + bounty_id: z.string().optional(), + issue_number: z.number().optional(), + repository: z.string().optional(), + idempotency_key: z.string().optional(), + cancel_reason: z.string().optional(), + failure_reason: 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), + 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..a9ad8af --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,23 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const cancelPaymentBodySchema = z.object({ + payment_id: z.string().min(1), + cancel_reason: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: cancelPaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id, cancel_reason } = cancelPaymentBodySchema.parse( + await req.json(), + ) + const payment = ctx.db.cancelPayment(payment_id, cancel_reason) + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..5261a82 --- /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 completePaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: completePaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id } = completePaymentBodySchema.parse(await req.json()) + const payment = ctx.db.completePayment(payment_id) + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/fail.ts b/routes/payments/fail.ts new file mode 100644 index 0000000..ec5f90e --- /dev/null +++ b/routes/payments/fail.ts @@ -0,0 +1,23 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const failPaymentBodySchema = z.object({ + payment_id: z.string().min(1), + failure_reason: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: failPaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id, failure_reason } = failPaymentBodySchema.parse( + await req.json(), + ) + const payment = ctx.db.failPayment(payment_id, failure_reason) + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..27f0046 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,16 @@ +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") + 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..cc553ce --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,26 @@ +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 issueNumber = url.searchParams.get("issue_number") + const status = paymentStatusSchema.safeParse(url.searchParams.get("status")) + + const payments = ctx.db.listPayments({ + recipient: url.searchParams.get("recipient") ?? undefined, + status: status.success ? status.data : undefined, + owner: url.searchParams.get("owner") ?? undefined, + repo: url.searchParams.get("repo") ?? undefined, + repository: url.searchParams.get("repository") ?? undefined, + bounty_id: url.searchParams.get("bounty_id") ?? undefined, + issue_number: issueNumber ? Number(issueNumber) : undefined, + }) + + return ctx.json({ payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..7bbe4d7 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,34 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const paymentBodySchema = z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(1).default("USD"), + owner: z.string().min(1).optional(), + repo: z.string().min(1).optional(), + 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(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema, + idempotent: z.boolean(), + }), +})(async (req, ctx) => { + const body = paymentBodySchema.parse(await req.json()) + const existingPayment = body.idempotency_key + ? ctx.db.payments.find( + (payment) => payment.idempotency_key === body.idempotency_key, + ) + : undefined + const payment = ctx.db.sendPayment(body) + + return ctx.json({ payment, idempotent: Boolean(existingPayment) }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..35f8f09 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send and complete a payment", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + owner: "tscircuit", + repo: "fake-algora", + bounty_id: "bounty_123", + issue_number: 1, + repository: "tscircuit/fake-algora", + idempotency_key: "retry-safe-payment", + }) + + expect(sendData.payment).toMatchObject({ + payment_id: "0", + recipient: "maintainer@example.com", + amount: 10, + currency: "USD", + status: "pending", + owner: "tscircuit", + repo: "fake-algora", + bounty_id: "bounty_123", + issue_number: 1, + repository: "tscircuit/fake-algora", + idempotency_key: "retry-safe-payment", + }) + expect(sendData.idempotent).toBe(false) + + const { data: duplicateData } = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + idempotency_key: "retry-safe-payment", + }) + + expect(duplicateData.payment.payment_id).toBe("0") + expect(duplicateData.idempotent).toBe(true) + + const { data: listData } = await axios.get("/payments/list", { + params: { + recipient: "maintainer@example.com", + status: "pending", + owner: "tscircuit", + repo: "fake-algora", + repository: "tscircuit/fake-algora", + bounty_id: "bounty_123", + issue_number: 1, + }, + }) + + expect(listData.payments).toHaveLength(1) + + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: "0", + }) + + expect(completeData.payment.status).toBe("completed") + expect(completeData.payment.completed_at).toBeString() + + const { data: getData } = await axios.get("/payments/get", { + params: { + payment_id: "0", + }, + }) + + expect(getData.payment.status).toBe("completed") +}) + +test("cancel and fail update payment status", async () => { + const { axios } = await getTestServer() + + const { data: cancelSource } = await axios.post("/payments/send", { + recipient: "first@example.com", + amount: 20, + }) + + const { data: cancelData } = await axios.post("/payments/cancel", { + payment_id: cancelSource.payment.payment_id, + cancel_reason: "duplicate bounty claim", + }) + + expect(cancelData.payment.status).toBe("canceled") + expect(cancelData.payment.cancel_reason).toBe("duplicate bounty claim") + expect(cancelData.payment.canceled_at).toBeString() + + const { data: failSource } = await axios.post("/payments/send", { + recipient: "second@example.com", + amount: 30, + }) + + const { data: failData } = await axios.post("/payments/fail", { + payment_id: failSource.payment.payment_id, + failure_reason: "recipient missing payout details", + }) + + expect(failData.payment.status).toBe("failed") + expect(failData.payment.failure_reason).toBe( + "recipient missing payout details", + ) + expect(failData.payment.failed_at).toBeString() +}) + +test("missing payments return null", async () => { + const { axios } = await getTestServer() + + const { data: getData } = await axios.get("/payments/get", { + params: { payment_id: "missing" }, + }) + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: "missing", + }) + const { data: cancelData } = await axios.post("/payments/cancel", { + payment_id: "missing", + }) + + expect(getData.payment).toBeNull() + expect(completeData.payment).toBeNull() + expect(cancelData.payment).toBeNull() +})