diff --git a/README.md b/README.md index 824427a..1fd703c 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,35 @@ 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 simulate Algora reward transfers in the in-memory store. + +### Send a payment + +```bash +curl -X POST http://localhost:3000/payments/send \ + -H "content-type: application/json" \ + -H "idempotency-key: reward-pr-123" \ + -d '{ + "recipient": "contributor@example.com", + "amount": 10, + "currency": "usd", + "repository": "tscircuit/fake-algora", + "issue_number": 1, + "bounty_id": "bounty_1" + }' +``` + +`POST /payments/send` returns a `pending` payment. Reusing the same +`idempotency-key` header or `idempotency_key` body value returns the original +payment with `idempotent_replay: true`. + +### Read and transition payments + +- `GET /payments/list` +- `GET /payments/list?status=pending&repository=tscircuit%2Ffake-algora` +- `GET /payments/get?payment_id=pay_0` +- `POST /payments/complete` with `{ "payment_id": "pay_0" }` +- `POST /payments/cancel` with `{ "payment_id": "pay_0" }` diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..1e6746e 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,15 @@ -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 DatabaseSchema, + type Payment, + type PaymentStatus, + type Thing, + databaseSchema, +} from "./schema.ts" export const createDatabase = () => { return hoist(createStore(initializer)) @@ -11,7 +17,14 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +type NewPayment = Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" +> & { + status?: PaymentStatus +} + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +34,49 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + addPayment: (paymentInput: NewPayment) => { + const now = new Date().toISOString() + const payment: Payment = { + ...paymentInput, + payment_id: `pay_${get().paymentIdCounter}`, + status: paymentInput.status ?? "pending", + created_at: now, + updated_at: now, + } + + set((state) => ({ + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + })) + + return payment + }, + findPaymentById: (paymentId: string) => { + return get().payments.find((payment) => payment.payment_id === paymentId) + }, + findPaymentByIdempotencyKey: (idempotencyKey: string) => { + return get().payments.find( + (payment) => payment.idempotency_key === idempotencyKey, + ) + }, + updatePaymentStatus: (paymentId: string, status: PaymentStatus) => { + const existingPayment = get().payments.find( + (payment) => payment.payment_id === paymentId, + ) + if (!existingPayment) return null + + const updatedPayment = { + ...existingPayment, + status, + updated_at: new Date().toISOString(), + } + + set((state) => ({ + payments: state.payments.map((payment) => + payment.payment_id === paymentId ? updatedPayment : payment, + ), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..6fcfc5d 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,33 @@ 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, + repository: z.string().optional(), + issue_number: z.number().optional(), + bounty_id: 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), + 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..5825e11 --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,38 @@ +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(), + payment: paymentSchema.nullable(), + error: z.string().optional(), + }), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.findPaymentById(payment_id) + if (!payment) { + return ctx.json({ + ok: false, + payment: null, + error: "Payment not found", + }) + } + + if (payment.status !== "pending") { + return ctx.json({ + ok: false, + payment, + error: `Cannot cancel a ${payment.status} payment`, + }) + } + + return ctx.json({ + ok: true, + payment: ctx.db.updatePaymentStatus(payment_id, "canceled"), + }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..fec244f --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,38 @@ +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(), + payment: paymentSchema.nullable(), + error: z.string().optional(), + }), +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.findPaymentById(payment_id) + if (!payment) { + return ctx.json({ + ok: false, + payment: null, + error: "Payment not found", + }) + } + + if (payment.status !== "pending") { + return ctx.json({ + ok: false, + payment, + error: `Cannot complete a ${payment.status} payment`, + }) + } + + return ctx.json({ + ok: true, + payment: ctx.db.updatePaymentStatus(payment_id, "completed"), + }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..65085a4 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,17 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: z.object({ + payment_id: z.string().min(1), + }), + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})((req, ctx) => { + return ctx.json({ + payment: ctx.db.findPaymentById(req.query.payment_id) ?? null, + }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..e6ae225 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,27 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: z.object({ + recipient: z.string().optional(), + status: paymentStatusSchema.optional(), + repository: z.string().optional(), + bounty_id: z.string().optional(), + }), + jsonResponse: z.object({ + payments: z.array(paymentSchema), + }), +})((req, ctx) => { + const { recipient, status, repository, bounty_id } = req.query + const payments = ctx.db.payments.filter((payment) => { + if (recipient && payment.recipient !== recipient) return false + if (status && payment.status !== status) return false + if (repository && payment.repository !== repository) return false + if (bounty_id && payment.bounty_id !== bounty_id) 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..b8e716b --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,51 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const sendPaymentBodySchema = z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(1).default("USD"), + repository: z.string().min(1).optional(), + issue_number: z.number().int().positive().optional(), + bounty_id: z.string().min(1).optional(), + idempotency_key: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema, + idempotent_replay: z.boolean(), + }), +})(async (req, ctx) => { + const body = await req.json() + const idempotencyKey = + req.headers.get("idempotency-key") ?? body.idempotency_key + + if (idempotencyKey) { + const existingPayment = ctx.db.findPaymentByIdempotencyKey(idempotencyKey) + if (existingPayment) { + return ctx.json({ + payment: existingPayment, + idempotent_replay: true, + }) + } + } + + const payment = ctx.db.addPayment({ + recipient: body.recipient, + amount: body.amount, + currency: (body.currency ?? "USD").toUpperCase(), + repository: body.repository, + issue_number: body.issue_number, + bounty_id: body.bounty_id, + idempotency_key: idempotencyKey ?? undefined, + }) + + return ctx.json({ + payment, + idempotent_replay: false, + }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..0d73dc3 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send, list, get, and complete a fake payment", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post( + "/payments/send", + { + recipient: "contributor@example.com", + amount: 10, + currency: "usd", + repository: "tscircuit/fake-algora", + issue_number: 1, + bounty_id: "bounty_1", + }, + { + headers: { + "idempotency-key": "reward-pr-123", + }, + }, + ) + + expect(sendData.idempotent_replay).toBe(false) + expect(sendData.payment).toMatchObject({ + payment_id: "pay_0", + recipient: "contributor@example.com", + amount: 10, + currency: "USD", + status: "pending", + repository: "tscircuit/fake-algora", + issue_number: 1, + bounty_id: "bounty_1", + idempotency_key: "reward-pr-123", + }) + + const { data: listData } = await axios.get( + "/payments/list?status=pending&repository=tscircuit%2Ffake-algora", + ) + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0].payment_id).toBe("pay_0") + + const { data: getData } = await axios.get("/payments/get?payment_id=pay_0") + expect(getData.payment.payment_id).toBe("pay_0") + + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: "pay_0", + }) + expect(completeData.ok).toBe(true) + expect(completeData.payment.status).toBe("completed") +}) + +test("send is idempotent when the same idempotency key is reused", async () => { + const { axios } = await getTestServer() + + const first = await axios.post("/payments/send", { + recipient: "contributor@example.com", + amount: 10, + idempotency_key: "same-pr", + }) + + const replay = await axios.post("/payments/send", { + recipient: "contributor@example.com", + amount: 10, + idempotency_key: "same-pr", + }) + + const { data: listData } = await axios.get("/payments/list") + + expect(first.data.idempotent_replay).toBe(false) + expect(replay.data.idempotent_replay).toBe(true) + expect(replay.data.payment.payment_id).toBe(first.data.payment.payment_id) + expect(listData.payments).toHaveLength(1) +}) + +test("cancel refuses terminal payments", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient: "contributor@example.com", + amount: 10, + }) + await axios.post("/payments/complete", { + payment_id: "pay_0", + }) + + const { data: cancelData } = await axios.post("/payments/cancel", { + payment_id: "pay_0", + }) + + expect(cancelData.ok).toBe(false) + expect(cancelData.payment.status).toBe("completed") + expect(cancelData.error).toBe("Cannot cancel a completed payment") +})