From b7fe59dfcb28d24b10d8517fa5960ed6438e3f21 Mon Sep 17 00:00:00 2001 From: Vinzz2303 Date: Fri, 15 May 2026 10:39:24 +0700 Subject: [PATCH] Add fake payment API --- README.md | 9 ++++ lib/db/db-client.ts | 78 +++++++++++++++++++++++++++++++++-- lib/db/schema.ts | 23 +++++++++++ routes/payments/cancel.ts | 17 ++++++++ routes/payments/complete.ts | 17 ++++++++ routes/payments/fail.ts | 17 ++++++++ routes/payments/get.ts | 24 +++++++++++ routes/payments/list.ts | 26 ++++++++++++ routes/payments/schema.ts | 22 ++++++++++ routes/payments/send.ts | 23 +++++++++++ tests/routes/payments.test.ts | 64 ++++++++++++++++++++++++++++ 11 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 routes/payments/cancel.ts create mode 100644 routes/payments/complete.ts create mode 100644 routes/payments/fail.ts create mode 100644 routes/payments/get.ts create mode 100644 routes/payments/list.ts create mode 100644 routes/payments/schema.ts create mode 100644 routes/payments/send.ts create mode 100644 tests/routes/payments.test.ts diff --git a/README.md b/README.md index 824427a..4e708f9 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,12 @@ 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 Payments API + +- `POST /payments/send` creates a pending fake payment. +- `GET /payments/list` lists payments, with optional `recipient_github_username` and `status` filters. +- `GET /payments/get?payment_id=...` fetches one payment. +- `POST /payments/complete`, `POST /payments/cancel`, and `POST /payments/fail` transition pending payments into final states. + +`/payments/send` accepts an optional `idempotency_key`. Reusing the same key returns the original payment instead of creating a duplicate. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..1b3cbac 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)) @@ -21,4 +27,70 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + addPayment: ( + payment: Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" + >, + ) => { + let savedPayment: Payment | undefined + + set((state) => { + if (payment.idempotency_key) { + const existingPayment = state.payments.find( + (candidate) => candidate.idempotency_key === payment.idempotency_key, + ) + if (existingPayment) { + savedPayment = existingPayment + return state + } + } + + const now = new Date().toISOString() + savedPayment = { + ...payment, + payment_id: state.paymentIdCounter.toString(), + status: "pending", + created_at: now, + updated_at: now, + } + + return { + payments: [...state.payments, savedPayment], + paymentIdCounter: state.paymentIdCounter + 1, + } + }) + + return savedPayment! + }, + updatePaymentStatus: (payment_id: string, status: PaymentStatus) => { + let updatedPayment: Payment | undefined + + set((state) => { + const existingPayment = state.payments.find( + (payment) => payment.payment_id === payment_id, + ) + if (!existingPayment) return state + + if (existingPayment.status !== "pending") { + updatedPayment = existingPayment + return state + } + + const now = new Date().toISOString() + updatedPayment = { + ...existingPayment, + status, + updated_at: now, + } + + return { + 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..787481b 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,31 @@ 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_github_username: z.string(), + amount: z.number(), + currency: z.string(), + status: paymentStatusSchema, + bounty_issue_url: 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..fa2f411 --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,17 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentStatusBodySchema, paymentStatusResponseSchema } from "./schema" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentStatusBodySchema, + jsonResponse: paymentStatusResponseSchema, +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.updatePaymentStatus(payment_id, "canceled") + + if (!payment) { + return ctx.json({ ok: false, error: "Payment not found" }) + } + + return ctx.json({ ok: true, payment }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..c000790 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,17 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentStatusBodySchema, paymentStatusResponseSchema } from "./schema" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentStatusBodySchema, + jsonResponse: paymentStatusResponseSchema, +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.updatePaymentStatus(payment_id, "completed") + + if (!payment) { + return ctx.json({ ok: false, error: "Payment not found" }) + } + + return ctx.json({ ok: true, payment }) +}) diff --git a/routes/payments/fail.ts b/routes/payments/fail.ts new file mode 100644 index 0000000..80e4eed --- /dev/null +++ b/routes/payments/fail.ts @@ -0,0 +1,17 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentStatusBodySchema, paymentStatusResponseSchema } from "./schema" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentStatusBodySchema, + jsonResponse: paymentStatusResponseSchema, +})(async (req, ctx) => { + const { payment_id } = await req.json() + const payment = ctx.db.updatePaymentStatus(payment_id, "failed") + + if (!payment) { + return ctx.json({ ok: false, error: "Payment not found" }) + } + + return ctx.json({ ok: true, payment }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..f4af804 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,24 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" +import { paymentResponseSchema } from "./schema" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: paymentResponseSchema.extend({ + payment: paymentResponseSchema.shape.payment.optional(), + ok: z.boolean(), + error: z.string().optional(), + }), +})((req, ctx) => { + const url = new URL(req.url) + const paymentId = url.searchParams.get("payment_id") + const payment = ctx.db.payments.find( + (candidate) => candidate.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..e667581 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,26 @@ +import { paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentListResponseSchema } from "./schema" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: paymentListResponseSchema, +})((req, ctx) => { + const url = new URL(req.url) + const recipient = url.searchParams.get("recipient_github_username") + const status = url.searchParams.get("status") + + const payments = ctx.db.payments.filter((payment) => { + if (recipient && payment.recipient_github_username !== recipient) { + return false + } + + if (status && paymentStatusSchema.safeParse(status).success) { + return payment.status === status + } + + return true + }) + + return ctx.json({ payments }) +}) diff --git a/routes/payments/schema.ts b/routes/payments/schema.ts new file mode 100644 index 0000000..9596f05 --- /dev/null +++ b/routes/payments/schema.ts @@ -0,0 +1,22 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { z } from "zod" + +export const paymentResponseSchema = z.object({ + payment: paymentSchema, +}) + +export const paymentListResponseSchema = z.object({ + payments: z.array(paymentSchema), +}) + +export const paymentStatusBodySchema = z.object({ + payment_id: z.string(), +}) + +export const paymentStatusResponseSchema = z.object({ + payment: paymentSchema.optional(), + ok: z.boolean(), + error: z.string().optional(), +}) + +export { paymentStatusSchema } diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..46ef55c --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,23 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" +import { paymentResponseSchema } from "./schema" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + recipient_github_username: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(3).default("USD"), + bounty_issue_url: z.string().url().optional(), + idempotency_key: z.string().min(1).optional(), + }), + jsonResponse: paymentResponseSchema, +})(async (req, ctx) => { + const body = await req.json() + const payment = ctx.db.addPayment({ + ...body, + currency: body.currency ?? "USD", + }) + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..3cbf333 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send and list fake payments", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient_github_username: "Vinzz2303", + amount: 10, + currency: "USD", + bounty_issue_url: "https://github.com/tscircuit/fake-algora/issues/1", + }) + + expect(sendData.payment.payment_id).toBe("0") + expect(sendData.payment.status).toBe("pending") + + const { data: listData } = await axios.get( + "/payments/list?recipient_github_username=Vinzz2303", + ) + + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0].amount).toBe(10) +}) + +test("idempotency key replays the first payment", async () => { + const { axios } = await getTestServer() + + const body = { + recipient_github_username: "Vinzz2303", + amount: 10, + currency: "USD", + idempotency_key: "claim-1", + } + + const { data: firstData } = await axios.post("/payments/send", body) + const { data: replayData } = await axios.post("/payments/send", body) + const { data: listData } = await axios.get("/payments/list") + + expect(replayData.payment.payment_id).toBe(firstData.payment.payment_id) + expect(listData.payments).toHaveLength(1) +}) + +test("complete only mutates pending payments", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient_github_username: "Vinzz2303", + amount: 10, + }) + + 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") + + const { data: cancelData } = await axios.post("/payments/cancel", { + payment_id: sendData.payment.payment_id, + }) + + expect(cancelData.ok).toBe(true) + expect(cancelData.payment.status).toBe("completed") +})