diff --git a/README.md b/README.md index 824427a..b672de1 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,27 @@ 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 + +Send a fake payment: + +```ts +POST /payments/send +{ + "recipient": "maintainer@example.com", + "amount_usd": 16.88, + "memo": "Bounty reward", + "idempotency_key": "claim-123" +} +``` + +The response includes the generated `payment_id`, a `sent` status, timestamps, +and `idempotent_replay: true` when the same idempotency key is submitted again. + +List sent payments: + +```ts +GET /payments/list +GET /payments/list?recipient=maintainer@example.com +``` diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..927c665 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,8 +1,7 @@ -import { createStore, type StoreApi } from "zustand/vanilla" -import { immer } from "zustand/middleware/immer" -import { hoist, type HoistedStoreApi } from "zustand-hoist" +import { createStore } from "zustand/vanilla" +import { hoist } from "zustand-hoist" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" +import { databaseSchema, type Payment, type Thing } from "./schema.ts" import { combine } from "zustand/middleware" export const createDatabase = () => { @@ -11,7 +10,10 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +type SendPaymentInput = Pick & + Partial> + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +23,35 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: (input: SendPaymentInput) => { + const currentState = get() + const existingPayment = input.idempotency_key + ? currentState.payments.find( + (payment) => payment.idempotency_key === input.idempotency_key, + ) + : undefined + + if (existingPayment) { + return { payment: existingPayment, idempotent_replay: true } + } + + const timestamp = new Date().toISOString() + const payment: Payment = { + payment_id: `pay_${currentState.paymentIdCounter}`, + recipient: input.recipient, + amount_usd: input.amount_usd, + memo: input.memo, + idempotency_key: input.idempotency_key, + status: "sent", + created_at: timestamp, + sent_at: timestamp, + } + + set((state) => ({ + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + })) + + return { payment, idempotent_replay: false } + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..ffabc15 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,26 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum(["sent", "failed"]) + +export const paymentSchema = z.object({ + payment_id: z.string(), + recipient: z.string(), + amount_usd: z.number().positive(), + memo: z.string().optional(), + idempotency_key: z.string().optional(), + status: paymentStatusSchema, + created_at: z.string(), + sent_at: z.string().optional(), + failure_reason: z.string().optional(), +}) +export type Payment = z.infer +export type PaymentStatus = 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/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..ff2c6f7 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,30 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const paymentResponseSchema = z.object({ + payment_id: z.string(), + recipient: z.string(), + amount_usd: z.number(), + memo: z.string().optional(), + idempotency_key: z.string().optional(), + status: z.enum(["sent", "failed"]), + created_at: z.string(), + sent_at: z.string().optional(), + failure_reason: z.string().optional(), +}) + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payments: z.array(paymentResponseSchema), + }), +})((req, ctx) => { + const url = new URL(req.url) + const recipient = url.searchParams.get("recipient") + + const payments = recipient + ? ctx.db.payments.filter((payment) => payment.recipient === recipient) + : ctx.db.payments + + return ctx.json({ payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..d59bd20 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,39 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const paymentResponseSchema = z.object({ + payment_id: z.string(), + recipient: z.string(), + amount_usd: z.number(), + memo: z.string().optional(), + idempotency_key: z.string().optional(), + status: z.enum(["sent", "failed"]), + created_at: z.string(), + sent_at: z.string().optional(), + failure_reason: z.string().optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + recipient: z.string().min(1), + amount_usd: z.number().positive(), + memo: z.string().optional(), + idempotency_key: z.string().min(1).optional(), + }), + jsonResponse: z.object({ + ok: z.boolean(), + payment: paymentResponseSchema, + idempotent_replay: z.boolean(), + }), +})(async (req, ctx) => { + const { recipient, amount_usd, memo, idempotency_key } = await req.json() + const result = ctx.db.sendPayment({ + recipient, + amount_usd, + memo, + idempotency_key, + }) + + return ctx.json({ ok: true, ...result }) +}) diff --git a/tests/routes/payments/send.test.ts b/tests/routes/payments/send.test.ts new file mode 100644 index 0000000..d66ec06 --- /dev/null +++ b/tests/routes/payments/send.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send a payment and list it", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount_usd: 16.88, + memo: "Bounty reward", + }) + + expect(sendData.ok).toBe(true) + expect(sendData.idempotent_replay).toBe(false) + expect(sendData.payment.payment_id).toBe("pay_0") + expect(sendData.payment.recipient).toBe("maintainer@example.com") + expect(sendData.payment.amount_usd).toBe(16.88) + expect(sendData.payment.status).toBe("sent") + expect(sendData.payment.sent_at).toBeDefined() + + const { data: listData } = await axios.get("/payments/list") + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0]).toMatchObject({ + payment_id: sendData.payment.payment_id, + recipient: "maintainer@example.com", + amount_usd: 16.88, + status: "sent", + }) +}) + +test("replays payment requests with the same idempotency key", async () => { + const { axios } = await getTestServer() + + const firstResponse = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount_usd: 25, + idempotency_key: "claim-1", + }) + + const secondResponse = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount_usd: 25, + idempotency_key: "claim-1", + }) + + 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 listed payments by recipient", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient: "alice@example.com", + amount_usd: 10, + }) + await axios.post("/payments/send", { + recipient: "bob@example.com", + amount_usd: 20, + }) + + const { data } = await axios.get( + "/payments/list?recipient=alice%40example.com", + ) + + expect(data.payments).toHaveLength(1) + expect(data.payments[0].recipient).toBe("alice@example.com") +}) diff --git a/tests/routes/things/create.test.ts b/tests/routes/things/create.test.ts index 4ea7077..e341688 100644 --- a/tests/routes/things/create.test.ts +++ b/tests/routes/things/create.test.ts @@ -4,7 +4,7 @@ import { test, expect } from "bun:test" test("create a thing", async () => { const { axios } = await getTestServer() - axios.post("/things/create", { + await axios.post("/things/create", { name: "Thing1", description: "Thing1 Description", })