From bb64c7daa5bc90bf035b936b8af8fa50b10319e7 Mon Sep 17 00:00:00 2001 From: andyphp Date: Sat, 16 May 2026 23:51:04 +0800 Subject: [PATCH] Bootstrap payment API: send and list payments Add payment endpoints with in-memory database support: - POST /payments/send - send a payment with amount, currency, recipient, description - GET /payments/list - list all sent payments - Database schema and client methods for payments - Comprehensive test coverage --- lib/db/db-client.ts | 16 +++++- lib/db/schema.ts | 12 ++++ routes/payments/list.ts | 21 +++++++ routes/payments/send.ts | 28 +++++++++ tests/routes/payments/list.test.ts | 28 +++++++++ tests/routes/payments/send.test.ts | 92 ++++++++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 routes/payments/list.ts create mode 100644 routes/payments/send.ts create mode 100644 tests/routes/payments/list.test.ts create mode 100644 tests/routes/payments/send.test.ts diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..aee0613 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -2,7 +2,7 @@ import { createStore, type StoreApi } from "zustand/vanilla" import { immer } from "zustand/middleware/immer" import { hoist, type HoistedStoreApi } from "zustand-hoist" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" +import { databaseSchema, type DatabaseSchema, type Thing, type Payment } from "./schema.ts" import { combine } from "zustand/middleware" export const createDatabase = () => { @@ -21,4 +21,18 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + addPayment: (payment: Omit) => { + const now = new Date().toISOString() + set((state) => ({ + payments: [ + ...state.payments, + { + ...payment, + payment_id: `pay_${state.idCounter}`, + created_at: now, + }, + ], + idCounter: state.idCounter + 1, + })) + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..d3f42cc 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,20 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentSchema = z.object({ + payment_id: z.string(), + amount: z.number().positive(), + currency: z.string().default("USD"), + recipient: z.string(), + description: z.string(), + status: z.enum(["pending", "completed", "failed"]).default("completed"), + created_at: z.string(), +}) +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/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..5ac0c8d --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,21 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payments: z.array( + z.object({ + payment_id: z.string(), + amount: z.number(), + currency: z.string(), + recipient: z.string(), + description: z.string(), + status: z.string(), + created_at: z.string(), + }), + ), + }), +})((req, ctx) => { + return ctx.json({ payments: ctx.db.payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..a7a7e36 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,28 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + amount: z.number().positive(), + currency: z.string().default("USD"), + recipient: z.string(), + description: z.string(), + }), + jsonResponse: z.object({ + ok: z.boolean(), + payment_id: z.string(), + }), +})(async (req, ctx) => { + const { amount, currency, recipient, description } = await req.json() + ctx.db.addPayment({ + amount, + currency, + recipient, + description, + status: "completed", + }) + const payments = ctx.db.payments + const payment = payments[payments.length - 1] + return ctx.json({ ok: true, payment_id: payment.payment_id }) +}) diff --git a/tests/routes/payments/list.test.ts b/tests/routes/payments/list.test.ts new file mode 100644 index 0000000..e60450a --- /dev/null +++ b/tests/routes/payments/list.test.ts @@ -0,0 +1,28 @@ +import { getTestServer } from "tests/fixtures/get-test-server" +import { test, expect } from "bun:test" + +test("list payments returns empty array initially", async () => { + const { axios } = await getTestServer() + + const { data } = await axios.get("/payments/list") + + expect(data.payments).toEqual([]) +}) + +test("list payments after sending one", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + amount: 100, + recipient: "frank@example.com", + description: "Large payment", + }) + + const { data } = await axios.get("/payments/list") + + expect(data.payments).toHaveLength(1) + expect(data.payments[0].amount).toBe(100) + expect(data.payments[0].recipient).toBe("frank@example.com") + expect(data.payments[0].description).toBe("Large payment") + expect(data.payments[0].status).toBe("completed") +}) diff --git a/tests/routes/payments/send.test.ts b/tests/routes/payments/send.test.ts new file mode 100644 index 0000000..396e3ab --- /dev/null +++ b/tests/routes/payments/send.test.ts @@ -0,0 +1,92 @@ +import { getTestServer } from "tests/fixtures/get-test-server" +import { test, expect } from "bun:test" + +test("send a payment", async () => { + const { axios } = await getTestServer() + + const res = await axios.post("/payments/send", { + amount: 42.50, + currency: "USD", + recipient: "alice@example.com", + description: "Test payment", + }) + + expect(res.status).toBe(200) + expect(res.data.ok).toBe(true) + expect(res.data.payment_id).toBeDefined() + expect(res.data.payment_id).toMatch(/^pay_/) +}) + +test("send payment with default currency", async () => { + const { axios } = await getTestServer() + + const res = await axios.post("/payments/send", { + amount: 10, + recipient: "bob@example.com", + description: "Coffee", + }) + + expect(res.status).toBe(200) + expect(res.data.ok).toBe(true) + expect(res.data.payment_id).toBeDefined() +}) + +test("send and list payments", async () => { + const { axios } = await getTestServer() + + // Send two payments + await axios.post("/payments/send", { + amount: 25, + recipient: "carol@example.com", + description: "First payment", + }) + + await axios.post("/payments/send", { + amount: 15.99, + currency: "EUR", + recipient: "dave@example.com", + description: "Second payment", + }) + + // List payments + const { data } = await axios.get("/payments/list") + + expect(data.payments).toHaveLength(2) + expect(data.payments[0].amount).toBe(25) + expect(data.payments[0].recipient).toBe("carol@example.com") + expect(data.payments[0].currency).toBe("USD") + expect(data.payments[1].amount).toBe(15.99) + expect(data.payments[1].currency).toBe("EUR") + expect(data.payments[1].recipient).toBe("dave@example.com") +}) + +test("send payment with invalid amount", async () => { + const { axios } = await getTestServer() + + try { + await axios.post("/payments/send", { + amount: -10, + recipient: "eve@example.com", + description: "Negative amount", + }) + // Should not reach here + expect(true).toBe(false) + } catch (err: any) { + expect(err.response.status).toBe(400) + } +}) + +test("send payment with missing recipient", async () => { + const { axios } = await getTestServer() + + try { + await axios.post("/payments/send", { + amount: 50, + description: "Missing recipient", + }) + // Should not reach here + expect(true).toBe(false) + } catch (err: any) { + expect(err.response.status).toBe(400) + } +})