diff --git a/README.md b/README.md index 824427a..f24a717 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,36 @@ 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 API can be used to simulate sending and tracking bounty +payments. + +### Send a payment + +```http +POST /payments/send +Content-Type: application/json + +{ + "recipient": "maintainer@example.com", + "amount_usd": 10, + "bounty_id": "bounty_1", + "repository": "tscircuit/fake-algora", + "issue_number": 1, + "idempotency_key": "claim-1" +} +``` + +The `idempotency_key` can also be provided with the `Idempotency-Key` header. +Repeating the same key returns the original payment with +`idempotent_replay: true` instead of creating a duplicate. + +### Other endpoints + +- `GET /payments/list` lists payments. Optional query filters: `status`, + `recipient`, and `repository`. +- `GET /payments/get?payment_id=pay_0` returns one payment. +- `POST /payments/complete` marks a pending payment as completed. +- `POST /payments/cancel` marks a pending payment as canceled. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..9c542e5 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,64 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + createPayment: ( + payment: Omit< + Payment, + "payment_id" | "status" | "created_at" | "completed_at" | "canceled_at" + >, + ) => { + const existingPayment = payment.idempotency_key + ? get().payments.find( + (currentPayment) => + currentPayment.idempotency_key === payment.idempotency_key, + ) + : undefined + + if (existingPayment) { + return { payment: existingPayment, idempotent_replay: true } + } + + const newPayment: Payment = { + ...payment, + payment_id: `pay_${get().paymentIdCounter}`, + status: "pending", + created_at: new Date().toISOString(), + } + + set((state) => ({ + payments: [...state.payments, newPayment], + paymentIdCounter: state.paymentIdCounter + 1, + })) + + return { payment: newPayment, idempotent_replay: false } + }, + updatePaymentStatus: (payment_id: string, status: PaymentStatus) => { + const currentPayment = get().payments.find( + (payment) => payment.payment_id === payment_id, + ) + + if (!currentPayment) { + return null + } + + if (currentPayment.status !== "pending") { + return currentPayment + } + + const updatedPayment = { + ...currentPayment, + status, + ...(status === "completed" + ? { completed_at: new Date().toISOString() } + : { canceled_at: new Date().toISOString() }), + } + + set((state) => ({ + payments: state.payments.map((payment) => + payment.payment_id === payment_id ? updatedPayment : payment, + ), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..16dbfcc 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,28 @@ 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: z.string(), + amount_usd: z.number(), + status: paymentStatusSchema, + bounty_id: z.string().optional(), + repository: z.string().optional(), + issue_number: z.number().optional(), + idempotency_key: z.string().optional(), + created_at: z.string(), + completed_at: z.string().optional(), + canceled_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..0d21a14 --- /dev/null +++ b/lib/payments/schemas.ts @@ -0,0 +1,42 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { z } from "zod" + +export const sendPaymentBodySchema = z.object({ + recipient: z.string().min(1), + amount_usd: z.number().positive(), + bounty_id: z.string().min(1).optional(), + repository: z.string().min(1).optional(), + issue_number: z.number().int().positive().optional(), + idempotency_key: z.string().min(1).optional(), +}) + +export const paymentResponseSchema = z.object({ + payment: paymentSchema, +}) + +export const paymentOrErrorResponseSchema = z.union([ + paymentResponseSchema, + z.object({ error: z.string() }), +]) + +export const sendPaymentResponseSchema = paymentResponseSchema.extend({ + idempotent_replay: z.boolean(), +}) + +export const paymentListResponseSchema = z.object({ + payments: z.array(paymentSchema), +}) + +export const paymentStatusBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export const paymentStatusQuerySchema = z.object({ + status: paymentStatusSchema.optional(), + recipient: z.string().optional(), + repository: z.string().optional(), +}) + +export const paymentGetQuerySchema = 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..a0e9a59 --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,20 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentOrErrorResponseSchema, + paymentStatusBodySchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentStatusBodySchema, + jsonResponse: paymentOrErrorResponseSchema, +})(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..e752b5b --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,20 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentOrErrorResponseSchema, + paymentStatusBodySchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentStatusBodySchema, + jsonResponse: paymentOrErrorResponseSchema, +})(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..3a75836 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,21 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentGetQuerySchema, + paymentOrErrorResponseSchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: paymentGetQuerySchema, + jsonResponse: paymentOrErrorResponseSchema, +})((req, ctx) => { + const payment = ctx.db.payments.find( + (currentPayment) => currentPayment.payment_id === req.query.payment_id, + ) + + 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..2cf2181 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,21 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { + paymentListResponseSchema, + paymentStatusQuerySchema, +} from "lib/payments/schemas" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: paymentStatusQuerySchema, + jsonResponse: paymentListResponseSchema, +})((req, ctx) => { + const { status, recipient, repository } = req.query + const payments = ctx.db.payments.filter((payment) => { + if (status && payment.status !== status) return false + if (recipient && payment.recipient !== recipient) return false + if (repository && payment.repository !== repository) 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..011676c --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,20 @@ +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 paymentRequest = await req.json() + const idempotencyHeader = req.headers.get("idempotency-key") ?? undefined + const result = ctx.db.createPayment({ + ...paymentRequest, + idempotency_key: paymentRequest.idempotency_key ?? idempotencyHeader, + }) + + return ctx.json(result) +}) diff --git a/tests/routes/payments/payments.test.ts b/tests/routes/payments/payments.test.ts new file mode 100644 index 0000000..8787db2 --- /dev/null +++ b/tests/routes/payments/payments.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("creates and reads a fake payment", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount_usd: 10, + bounty_id: "bounty_1", + repository: "tscircuit/fake-algora", + issue_number: 1, + }) + + expect(sendData.idempotent_replay).toBe(false) + expect(sendData.payment).toMatchObject({ + payment_id: "pay_0", + recipient: "maintainer@example.com", + amount_usd: 10, + status: "pending", + bounty_id: "bounty_1", + repository: "tscircuit/fake-algora", + issue_number: 1, + }) + expect(sendData.payment.created_at).toBeString() + + const { data: getData } = await axios.get("/payments/get", { + params: { payment_id: "pay_0" }, + }) + + expect(getData.payment).toEqual(sendData.payment) +}) + +test("reuses a payment when an idempotency key is repeated", async () => { + const { axios } = await getTestServer() + + const firstResponse = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount_usd: 10, + idempotency_key: "claim-1", + }) + + const secondResponse = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount_usd: 10, + idempotency_key: "claim-1", + }) + + expect(firstResponse.data.idempotent_replay).toBe(false) + expect(secondResponse.data.idempotent_replay).toBe(true) + expect(secondResponse.data.payment).toEqual(firstResponse.data.payment) + + const { data: listData } = await axios.get("/payments/list") + expect(listData.payments).toHaveLength(1) +}) + +test("filters payments and updates lifecycle status", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient: "one@example.com", + amount_usd: 10, + repository: "tscircuit/fake-algora", + }) + await axios.post("/payments/send", { + recipient: "two@example.com", + amount_usd: 20, + repository: "tscircuit/other", + }) + + const { data: filteredByRecipient } = await axios.get("/payments/list", { + params: { recipient: "one@example.com" }, + }) + expect(filteredByRecipient.payments).toHaveLength(1) + expect(filteredByRecipient.payments[0].payment_id).toBe("pay_0") + + const { data: filteredByRepository } = await axios.get("/payments/list", { + params: { repository: "tscircuit/other" }, + }) + expect(filteredByRepository.payments).toHaveLength(1) + expect(filteredByRepository.payments[0].payment_id).toBe("pay_1") + + const { data: completedData } = await axios.post("/payments/complete", { + payment_id: "pay_0", + }) + expect(completedData.payment.status).toBe("completed") + expect(completedData.payment.completed_at).toBeString() + + const { data: canceledData } = await axios.post("/payments/cancel", { + payment_id: "pay_1", + }) + expect(canceledData.payment.status).toBe("canceled") + expect(canceledData.payment.canceled_at).toBeString() + + const { data: completedList } = await axios.get("/payments/list", { + params: { status: "completed" }, + }) + expect(completedList.payments).toHaveLength(1) + expect(completedList.payments[0].payment_id).toBe("pay_0") +})