Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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>
66 changes: 66 additions & 0 deletions lib/payments/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 paymentListRouteResponseSchema = z.union([
paymentListResponseSchema,
errorResponseSchema,
])

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 })
})
36 changes: 36 additions & 0 deletions routes/payments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
paymentListRouteResponseSchema,
paymentStatusSchema,
} from "lib/payments/schemas"

export default withRouteSpec({
methods: ["GET"],
jsonResponse: paymentListRouteResponseSchema,
})((req, ctx) => {
const url = new URL(req.url)
const statusParam = url.searchParams.get("status")
const parsedStatus =
statusParam === null
? { success: true as const, data: null }
: paymentStatusSchema.safeParse(statusParam)

if (!parsedStatus.success) {
return ctx.json(
{
error: {
status: `Invalid payment status: ${statusParam}`,
expected: paymentStatusSchema.options,
},
},
{ status: 400 },
)
}

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

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