Skip to content

Commit 0d89d70

Browse files
feat: add fake payment API
1 parent ac3322a commit 0d89d70

8 files changed

Lines changed: 278 additions & 5 deletions

File tree

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,53 @@
11
# Template API Project
22

33
This is a template project with best-practice modules:
4+
45
- Winterspec for defining the API
56
- bun testing
67
- Zustand store with zod definition for database state
8+
9+
## Fake payment API
10+
11+
This repo now includes a focused fake payment lifecycle API for bounty/testing workflows.
12+
13+
### Endpoints
14+
15+
- `POST /payments/send` — create a pending fake payment.
16+
- `GET /payments/list` — list all payments.
17+
- `GET /payments/list?status=pending|completed|cancelled|failed` — list payments by status.
18+
- `GET /payments/get?payment_id=0` — fetch one payment by id.
19+
- `POST /payments/update_status` — update a payment status.
20+
21+
### Idempotency
22+
23+
`/payments/send` accepts an optional `idempotency_key`. Reusing the same key returns the original payment instead of creating a duplicate.
24+
25+
### Example request
26+
27+
```json
28+
{
29+
"amount": 25,
30+
"currency": "usd",
31+
"recipient": "octocat",
32+
"memo": "bounty payout",
33+
"idempotency_key": "idem-1"
34+
}
35+
```
36+
37+
### Example response
38+
39+
```json
40+
{
41+
"payment": {
42+
"payment_id": "0",
43+
"amount": 25,
44+
"currency": "USD",
45+
"recipient": "octocat",
46+
"memo": "bounty payout",
47+
"idempotency_key": "idem-1",
48+
"status": "pending",
49+
"created_at": "2026-05-13T00:00:00.000Z",
50+
"updated_at": "2026-05-13T00:00:00.000Z"
51+
}
52+
}
53+
```

lib/db/db-client.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { createStore, type StoreApi } from "zustand/vanilla"
2-
import { immer } from "zustand/middleware/immer"
3-
import { hoist, type HoistedStoreApi } from "zustand-hoist"
1+
import { createStore } from "zustand/vanilla"
2+
import { hoist } from "zustand-hoist"
43

5-
import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts"
4+
import {
5+
databaseSchema,
6+
type Payment,
7+
type PaymentStatus,
8+
type Thing,
9+
} from "./schema.ts"
610
import { combine } from "zustand/middleware"
711

812
export const createDatabase = () => {
@@ -11,7 +15,15 @@ export const createDatabase = () => {
1115

1216
export type DbClient = ReturnType<typeof createDatabase>
1317

14-
const initializer = combine(databaseSchema.parse({}), (set) => ({
18+
export interface CreatePaymentInput {
19+
amount: number
20+
currency: string
21+
recipient: string
22+
memo?: string
23+
idempotency_key?: string
24+
}
25+
26+
const initializer = combine(databaseSchema.parse({}), (set, get) => ({
1527
addThing: (thing: Omit<Thing, "thing_id">) => {
1628
set((state) => ({
1729
things: [
@@ -21,4 +33,61 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
2133
idCounter: state.idCounter + 1,
2234
}))
2335
},
36+
37+
createPayment: (payment: CreatePaymentInput): Payment => {
38+
const now = new Date().toISOString()
39+
const existingPayment = payment.idempotency_key
40+
? get().payments.find(
41+
(existing) => existing.idempotency_key === payment.idempotency_key,
42+
)
43+
: undefined
44+
45+
if (existingPayment) return existingPayment
46+
47+
const newPayment: Payment = {
48+
payment_id: get().paymentIdCounter.toString(),
49+
amount: payment.amount,
50+
currency: payment.currency.toUpperCase(),
51+
recipient: payment.recipient,
52+
memo: payment.memo,
53+
idempotency_key: payment.idempotency_key,
54+
status: "pending",
55+
created_at: now,
56+
updated_at: now,
57+
}
58+
59+
set((state) => ({
60+
payments: [...state.payments, newPayment],
61+
paymentIdCounter: state.paymentIdCounter + 1,
62+
}))
63+
64+
return newPayment
65+
},
66+
67+
getPayment: (paymentId: string): Payment | undefined => {
68+
return get().payments.find((payment) => payment.payment_id === paymentId)
69+
},
70+
71+
listPayments: (status?: PaymentStatus): Payment[] => {
72+
const payments = get().payments
73+
return status ? payments.filter((payment) => payment.status === status) : payments
74+
},
75+
76+
updatePaymentStatus: (
77+
paymentId: string,
78+
status: PaymentStatus,
79+
): Payment | undefined => {
80+
let updatedPayment: Payment | undefined
81+
const now = new Date().toISOString()
82+
83+
set((state) => ({
84+
payments: state.payments.map((payment) => {
85+
if (payment.payment_id !== paymentId) return payment
86+
updatedPayment = { ...payment, status, updated_at: now }
87+
return updatedPayment
88+
}),
89+
}))
90+
91+
return updatedPayment
92+
},
2493
}))

lib/db/schema.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,31 @@ export const thingSchema = z.object({
99
})
1010
export type Thing = z.infer<typeof thingSchema>
1111

12+
export const paymentStatusSchema = z.enum([
13+
"pending",
14+
"completed",
15+
"cancelled",
16+
"failed",
17+
])
18+
export type PaymentStatus = z.infer<typeof paymentStatusSchema>
19+
20+
export const paymentSchema = z.object({
21+
payment_id: z.string(),
22+
amount: z.number().positive(),
23+
currency: z.string().min(3).max(12),
24+
recipient: z.string().min(1),
25+
memo: z.string().optional(),
26+
idempotency_key: z.string().optional(),
27+
status: paymentStatusSchema.default("pending"),
28+
created_at: z.string(),
29+
updated_at: z.string(),
30+
})
31+
export type Payment = z.infer<typeof paymentSchema>
32+
1233
export const databaseSchema = z.object({
1334
idCounter: z.number().default(0),
35+
paymentIdCounter: z.number().default(0),
1436
things: z.array(thingSchema).default([]),
37+
payments: z.array(paymentSchema).default([]),
1538
})
1639
export type DatabaseSchema = z.infer<typeof databaseSchema>

routes/payments/get.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { paymentSchema } from "lib/db/schema"
2+
import { withRouteSpec } from "lib/middleware/with-winter-spec"
3+
import { z } from "zod"
4+
5+
export default withRouteSpec({
6+
methods: ["GET"],
7+
jsonResponse: z.object({
8+
ok: z.boolean(),
9+
payment: paymentSchema.optional(),
10+
error: z.string().optional(),
11+
}),
12+
})((req, ctx) => {
13+
const url = new URL(req.url)
14+
const paymentId = url.searchParams.get("payment_id")
15+
16+
if (!paymentId) {
17+
return ctx.json({ ok: false, error: "payment_id is required" })
18+
}
19+
20+
const payment = ctx.db.getPayment(paymentId)
21+
if (!payment) {
22+
return ctx.json({ ok: false, error: "payment not found" })
23+
}
24+
25+
return ctx.json({ ok: true, payment })
26+
})

routes/payments/list.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { paymentSchema, paymentStatusSchema } from "lib/db/schema"
2+
import { withRouteSpec } from "lib/middleware/with-winter-spec"
3+
import { z } from "zod"
4+
5+
export default withRouteSpec({
6+
methods: ["GET"],
7+
jsonResponse: z.object({
8+
payments: z.array(paymentSchema),
9+
}),
10+
})((req, ctx) => {
11+
const url = new URL(req.url)
12+
const statusParam = url.searchParams.get("status")
13+
const status = statusParam ? paymentStatusSchema.parse(statusParam) : undefined
14+
15+
return ctx.json({ payments: ctx.db.listPayments(status) })
16+
})

routes/payments/send.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { paymentSchema } from "lib/db/schema"
2+
import { withRouteSpec } from "lib/middleware/with-winter-spec"
3+
import { z } from "zod"
4+
5+
export default withRouteSpec({
6+
methods: ["POST"],
7+
jsonBody: z.object({
8+
amount: z.number().positive(),
9+
currency: z.string().min(3).max(12),
10+
recipient: z.string().min(1),
11+
memo: z.string().optional(),
12+
idempotency_key: z.string().min(1).optional(),
13+
}),
14+
jsonResponse: z.object({
15+
payment: paymentSchema,
16+
}),
17+
})(async (req, ctx) => {
18+
const paymentRequest = await req.json()
19+
const payment = ctx.db.createPayment(paymentRequest)
20+
21+
return ctx.json({ payment })
22+
})

routes/payments/update_status.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { paymentSchema, paymentStatusSchema } from "lib/db/schema"
2+
import { withRouteSpec } from "lib/middleware/with-winter-spec"
3+
import { z } from "zod"
4+
5+
export default withRouteSpec({
6+
methods: ["POST"],
7+
jsonBody: z.object({
8+
payment_id: z.string().min(1),
9+
status: paymentStatusSchema,
10+
}),
11+
jsonResponse: z.object({
12+
ok: z.boolean(),
13+
payment: paymentSchema.optional(),
14+
error: z.string().optional(),
15+
}),
16+
})(async (req, ctx) => {
17+
const { payment_id, status } = await req.json()
18+
const payment = ctx.db.updatePaymentStatus(payment_id, status)
19+
20+
if (!payment) {
21+
return ctx.json({ ok: false, error: "payment not found" })
22+
}
23+
24+
return ctx.json({ ok: true, payment })
25+
})

tests/routes/payments.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { getTestServer } from "tests/fixtures/get-test-server"
2+
import { test, expect } from "bun:test"
3+
4+
test("send, list, get, and update payments", async () => {
5+
const { axios } = await getTestServer()
6+
7+
const first = await axios.post("/payments/send", {
8+
amount: 25,
9+
currency: "usd",
10+
recipient: "octocat",
11+
memo: "bounty payout",
12+
idempotency_key: "idem-1",
13+
})
14+
15+
expect(first.data.payment.payment_id).toBe("0")
16+
expect(first.data.payment.currency).toBe("USD")
17+
expect(first.data.payment.status).toBe("pending")
18+
19+
const duplicate = await axios.post("/payments/send", {
20+
amount: 25,
21+
currency: "usd",
22+
recipient: "octocat",
23+
memo: "bounty payout",
24+
idempotency_key: "idem-1",
25+
})
26+
27+
expect(duplicate.data.payment.payment_id).toBe(first.data.payment.payment_id)
28+
29+
const list = await axios.get("/payments/list")
30+
expect(list.data.payments).toHaveLength(1)
31+
32+
const get = await axios.get("/payments/get?payment_id=0")
33+
expect(get.data.ok).toBe(true)
34+
expect(get.data.payment.recipient).toBe("octocat")
35+
36+
const completed = await axios.post("/payments/update_status", {
37+
payment_id: "0",
38+
status: "completed",
39+
})
40+
expect(completed.data.ok).toBe(true)
41+
expect(completed.data.payment.status).toBe("completed")
42+
43+
const filtered = await axios.get("/payments/list?status=completed")
44+
expect(filtered.data.payments).toHaveLength(1)
45+
})

0 commit comments

Comments
 (0)