Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,20 @@ 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

The fake payment API can be used by local tests or demos that need a payment
lifecycle without talking to a real provider.

- `POST /payments/send` creates a fake payment. Send
`recipient_email`, either `amount_cents` or `amount_usd`, and optional
`currency`, `bounty_issue_url`, `note`, and `idempotency_key`.
- `GET /payments/list` returns payments. Optional filters:
`recipient_email` and `status`.
- `GET /payments/get?payment_id=<id>` returns one payment.
- `POST /payments/complete`, `POST /payments/cancel`, and
`POST /payments/fail` move a payment into a terminal state.

Repeated `POST /payments/send` calls with the same `idempotency_key` return the
original payment, which makes retrying a fake send safe.
111 changes: 106 additions & 5 deletions lib/db/db-client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
import { createStore, type StoreApi } from "zustand/vanilla"
import { immer } from "zustand/middleware/immer"
import { hoist, type HoistedStoreApi } from "zustand-hoist"
import { hoist } from "zustand-hoist"
import { createStore } from "zustand/vanilla"

import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts"
import { combine } from "zustand/middleware"
import {
type Payment,
type PaymentStatus,
type Thing,
databaseSchema,
} from "./schema.ts"

type CreatePaymentInput = Omit<
Payment,
"payment_id" | "status" | "created_at" | "updated_at" | "sent_at"
>

const terminalPaymentStatuses = new Set<PaymentStatus>([
"completed",
"canceled",
"failed",
])

const nowIso = () => new Date().toISOString()

export const createDatabase = () => {
return hoist(createStore(initializer))
}

export type DbClient = ReturnType<typeof createDatabase>

const initializer = combine(databaseSchema.parse({}), (set) => ({
const initializer = combine(databaseSchema.parse({}), (set, get) => ({
addThing: (thing: Omit<Thing, "thing_id">) => {
set((state) => ({
things: [
Expand All @@ -21,4 +38,88 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},
sendPayment: (input: CreatePaymentInput) => {
let payment!: Payment
set((state) => {
if (input.idempotency_key) {
const existing = state.payments.find(
(item) => item.idempotency_key === input.idempotency_key,
)
if (existing) {
payment = existing
return state
}
}

const timestamp = nowIso()
payment = {
...input,
payment_id: state.paymentIdCounter.toString(),
currency: input.currency.toLowerCase(),
status: "sent",
created_at: timestamp,
updated_at: timestamp,
sent_at: timestamp,
}

return {
payments: [...state.payments, payment],
paymentIdCounter: state.paymentIdCounter + 1,
}
})
return payment
},
listPayments: (filters?: {
recipient_email?: string | null
status?: PaymentStatus | null
}) => {
return get().payments.filter((payment) => {
if (
filters?.recipient_email &&
payment.recipient_email !== filters.recipient_email
) {
return false
}
if (filters?.status && payment.status !== filters.status) {
return false
}
return true
})
},
getPayment: (payment_id: string) => {
return get().payments.find((payment) => payment.payment_id === payment_id)
},
updatePaymentStatus: (
payment_id: string,
status: Extract<PaymentStatus, "completed" | "canceled" | "failed">,
) => {
let payment: Payment | null = null
set((state) => {
const existing = state.payments.find(
(item) => item.payment_id === payment_id,
)
if (!existing) return state
if (terminalPaymentStatuses.has(existing.status)) {
payment = existing
return state
}

const timestamp = nowIso()
const updatedPayment: Payment = {
...existing,
status,
updated_at: timestamp,
...(status === "completed" ? { completed_at: timestamp } : {}),
...(status === "canceled" ? { canceled_at: timestamp } : {}),
...(status === "failed" ? { failed_at: timestamp } : {}),
}
payment = updatedPayment
return {
payments: state.payments.map((item) =>
item.payment_id === payment_id ? updatedPayment : item,
),
}
})
return payment
},
}))
28 changes: 28 additions & 0 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,36 @@ export const thingSchema = z.object({
})
export type Thing = z.infer<typeof thingSchema>

export const paymentStatusSchema = z.enum([
"sent",
"completed",
"canceled",
"failed",
])
export type PaymentStatus = z.infer<typeof paymentStatusSchema>

export const paymentSchema = z.object({
payment_id: z.string(),
recipient_email: z.string(),
amount_cents: z.number().int(),
currency: z.string(),
status: paymentStatusSchema,
bounty_issue_url: z.string().optional(),
note: z.string().optional(),
idempotency_key: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
sent_at: z.string(),
completed_at: z.string().optional(),
canceled_at: z.string().optional(),
failed_at: z.string().optional(),
})
export type Payment = z.infer<typeof paymentSchema>

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<typeof databaseSchema>
61 changes: 61 additions & 0 deletions lib/payments/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { paymentSchema, paymentStatusSchema } from "lib/db/schema"
import { z } from "zod"

export const sendPaymentRequestSchema = z
.object({
recipient_email: z.string().email(),
amount_cents: z.number().int().positive().optional(),
amount_usd: z.number().positive().optional(),
currency: z.string().min(1).default("usd"),
bounty_issue_url: z.string().url().optional(),
note: z.string().optional(),
idempotency_key: z.string().min(1).optional(),
})
.refine(
(body) => body.amount_cents !== undefined || body.amount_usd !== undefined,
"amount_cents or amount_usd is required",
)

export const paymentResponseSchema = z.object({
payment: paymentSchema,
})

export const errorResponseSchema = z.object({
error: z.unknown(),
})

export const paymentRouteResponseSchema = z.union([
paymentResponseSchema,
errorResponseSchema,
])

export const paymentListResponseSchema = z.object({
payments: z.array(paymentSchema),
})

export const getPaymentRequestSchema = z.object({
payment_id: z.string().min(1),
})

export const updatePaymentStatusRequestSchema = z.object({
payment_id: z.string().min(1),
})

export const updatePaymentStatusResponseSchema = z.object({
payment: paymentSchema,
})

export const updatePaymentStatusRouteResponseSchema = z.union([
updatePaymentStatusResponseSchema,
errorResponseSchema,
])

export type SendPaymentRequest = z.infer<typeof sendPaymentRequestSchema>
export type PaymentStatus = z.infer<typeof paymentStatusSchema>

export function toAmountCents(body: SendPaymentRequest): number {
if (body.amount_cents !== undefined) return body.amount_cents
return Math.round(body.amount_usd! * 100)
}

export { paymentStatusSchema }
20 changes: 20 additions & 0 deletions routes/payments/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
updatePaymentStatusRequestSchema,
updatePaymentStatusRouteResponseSchema,
} from "lib/payments/schemas"

export default withRouteSpec({
methods: ["POST"],
jsonBody: updatePaymentStatusRequestSchema,
jsonResponse: updatePaymentStatusRouteResponseSchema,
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.updatePaymentStatus(payment_id, "canceled")

if (!payment) {
return ctx.json({ error: "Payment not found" }, { status: 404 })
}

return ctx.json({ payment })
})
20 changes: 20 additions & 0 deletions routes/payments/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
updatePaymentStatusRequestSchema,
updatePaymentStatusRouteResponseSchema,
} from "lib/payments/schemas"

export default withRouteSpec({
methods: ["POST"],
jsonBody: updatePaymentStatusRequestSchema,
jsonResponse: updatePaymentStatusRouteResponseSchema,
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.updatePaymentStatus(payment_id, "completed")

if (!payment) {
return ctx.json({ error: "Payment not found" }, { status: 404 })
}

return ctx.json({ payment })
})
20 changes: 20 additions & 0 deletions routes/payments/fail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
updatePaymentStatusRequestSchema,
updatePaymentStatusRouteResponseSchema,
} from "lib/payments/schemas"

export default withRouteSpec({
methods: ["POST"],
jsonBody: updatePaymentStatusRequestSchema,
jsonResponse: updatePaymentStatusRouteResponseSchema,
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.updatePaymentStatus(payment_id, "failed")

if (!payment) {
return ctx.json({ error: "Payment not found" }, { status: 404 })
}

return ctx.json({ payment })
})
16 changes: 16 additions & 0 deletions routes/payments/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { paymentRouteResponseSchema } from "lib/payments/schemas"

export default withRouteSpec({
methods: ["GET"],
jsonResponse: paymentRouteResponseSchema,
})((req, ctx) => {
const url = new URL(req.url)
const payment = ctx.db.getPayment(url.searchParams.get("payment_id") ?? "")

if (!payment) {
return ctx.json({ error: "Payment not found" }, { status: 404 })
}

return ctx.json({ payment })
})
23 changes: 23 additions & 0 deletions routes/payments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
paymentListResponseSchema,
paymentStatusSchema,
} from "lib/payments/schemas"

export default withRouteSpec({
methods: ["GET"],
jsonResponse: paymentListResponseSchema,
})((req, ctx) => {
const url = new URL(req.url)
const status = paymentStatusSchema
.nullable()
.catch(null)
.parse(url.searchParams.get("status"))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unknown payment status values in list filter

Using paymentStatusSchema.nullable().catch(null) turns any invalid status query value into null, so requests like GET /payments/list?status=complete silently drop the filter and return all payments instead of surfacing input error. This can mislead callers and break workflows that depend on strict filtering; invalid enum values should be rejected (e.g., 400) rather than treated as “no filter.”

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e5a39dc by validating status with safeParse and returning 400 for unknown values. I also added a regression test for /payments/list?status=complete.


const payments = ctx.db.listPayments({
recipient_email: url.searchParams.get("recipient_email"),
status,
})

return ctx.json({ payments })
})
35 changes: 35 additions & 0 deletions routes/payments/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
paymentRouteResponseSchema,
sendPaymentRequestSchema,
toAmountCents,
} from "lib/payments/schemas"

export default withRouteSpec({
methods: ["POST"],
jsonBody: sendPaymentRequestSchema,
jsonResponse: paymentRouteResponseSchema,
})(async (req, ctx) => {
const body = await req.json()
const parsed = sendPaymentRequestSchema.safeParse(body)

if (!parsed.success) {
return ctx.json(
{ error: parsed.error.flatten() },
{
status: 400,
},
)
}

const payment = ctx.db.sendPayment({
recipient_email: parsed.data.recipient_email,
amount_cents: toAmountCents(parsed.data),
currency: parsed.data.currency,
bounty_issue_url: parsed.data.bounty_issue_url,
note: parsed.data.note,
idempotency_key: parsed.data.idempotency_key,
})

return ctx.json({ payment })
})
Loading
Loading