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 payment API

The API includes an in-memory payment flow for testing bounty-like payment
lifecycle behavior.

- `POST /payments/send` creates a pending payment. The request accepts
`recipient`, `amount`, optional `currency`, optional bounty metadata, and an
optional `idempotency_key` for retry-safe replay.
- `GET /payments/list` returns payments and supports `recipient`, `status`,
`repository`, `bounty_id`, and `issue_number` query filters.
- `GET /payments/get?payment_id=...` returns one payment or
`payment_not_found`.
- `POST /payments/complete` marks a pending payment completed.
- `POST /payments/cancel` marks a pending payment canceled.

Completed and canceled payments cannot be transitioned again.
114 changes: 110 additions & 4 deletions lib/db/db-client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { createStore, type StoreApi } from "zustand/vanilla"
import { type HoistedStoreApi, hoist } from "zustand-hoist"
import { immer } from "zustand/middleware/immer"
import { hoist, type HoistedStoreApi } from "zustand-hoist"
import { type StoreApi, createStore } from "zustand/vanilla"

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

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 +27,104 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},
createPayment: (
payment: Omit<
Payment,
"payment_id" | "status" | "created_at" | "updated_at"
>,
) => {
const now = new Date().toISOString()
const matchingPayment = payment.idempotency_key
? get().payments.find(
(existingPayment) =>
existingPayment.idempotency_key === payment.idempotency_key,
)
: undefined

if (matchingPayment) {
return matchingPayment
}

let createdPayment!: Payment
set((state) => {
createdPayment = {
...payment,
payment_id: state.paymentIdCounter.toString(),
status: "pending",
created_at: now,
updated_at: now,
}

return {
payments: [...state.payments, createdPayment],
paymentIdCounter: state.paymentIdCounter + 1,
}
})

return createdPayment
},
listPayments: (filters?: {
recipient?: string
status?: PaymentStatus
repository?: string
bounty_id?: string
issue_number?: string
}) => {
return get().payments.filter((payment) => {
if (filters?.recipient && payment.recipient !== filters.recipient) {
return false
}
if (filters?.status && payment.status !== filters.status) {
return false
}
if (filters?.repository && payment.repository !== filters.repository) {
return false
}
if (filters?.bounty_id && payment.bounty_id !== filters.bounty_id) {
return false
}
if (
filters?.issue_number &&
payment.issue_number !== filters.issue_number
) {
return false
}
return true
})
},
getPayment: (payment_id: string) => {
return get().payments.find((payment) => payment.payment_id === payment_id)
},
updatePaymentStatus: (payment_id: string, status: PaymentStatus) => {
let updatedPayment: Payment | undefined
set((state) => {
const existingPayment = state.payments.find(
(payment) => payment.payment_id === payment_id,
)

if (!existingPayment) {
return state
}

if (existingPayment.status !== "pending") {
updatedPayment = existingPayment
return state
}

const now = new Date().toISOString()
updatedPayment = {
...existingPayment,
status,
updated_at: now,
}

return {
payments: state.payments.map((payment) =>
payment.payment_id === payment_id ? updatedPayment! : payment,
),
}
})

return updatedPayment
},
}))
20 changes: 20 additions & 0 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,28 @@ export const thingSchema = z.object({
})
export type Thing = z.infer<typeof thingSchema>

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

export const paymentSchema = z.object({
payment_id: z.string(),
recipient: z.string(),
amount: z.number().positive(),
currency: z.string(),
status: paymentStatusSchema,
bounty_id: z.string().optional(),
issue_number: z.string().optional(),
repository: z.string().optional(),
idempotency_key: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
})
export type Payment = z.infer<typeof paymentSchema>

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<typeof databaseSchema>
54 changes: 54 additions & 0 deletions routes/payments/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

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

const responseSchema = z.union([
z.object({
ok: z.literal(true),
payment: paymentSchema,
}),
z.object({
ok: z.literal(false),
error: z.literal("payment_not_found"),
}),
z.object({
ok: z.literal(false),
error: z.literal("payment_not_pending"),
payment: paymentSchema,
}),
])

const jsonResponse = (body: z.infer<typeof responseSchema>, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json",
},
})

export default withRouteSpec({
methods: ["POST"],
jsonBody: statusBodySchema,
jsonResponse: responseSchema,
})(async (req, ctx) => {
const { payment_id } = statusBodySchema.parse(await req.json())
const existingPayment = ctx.db.getPayment(payment_id)

if (!existingPayment) {
return jsonResponse({ ok: false, error: "payment_not_found" }, 404)
}

if (existingPayment.status !== "pending") {
return jsonResponse(
{ ok: false, error: "payment_not_pending", payment: existingPayment },
409,
)
}

const payment = ctx.db.updatePaymentStatus(payment_id, "canceled")
return jsonResponse({ ok: true, payment: payment! })
})
54 changes: 54 additions & 0 deletions routes/payments/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

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

const responseSchema = z.union([
z.object({
ok: z.literal(true),
payment: paymentSchema,
}),
z.object({
ok: z.literal(false),
error: z.literal("payment_not_found"),
}),
z.object({
ok: z.literal(false),
error: z.literal("payment_not_pending"),
payment: paymentSchema,
}),
])

const jsonResponse = (body: z.infer<typeof responseSchema>, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json",
},
})

export default withRouteSpec({
methods: ["POST"],
jsonBody: statusBodySchema,
jsonResponse: responseSchema,
})(async (req, ctx) => {
const { payment_id } = statusBodySchema.parse(await req.json())
const existingPayment = ctx.db.getPayment(payment_id)

if (!existingPayment) {
return jsonResponse({ ok: false, error: "payment_not_found" }, 404)
}

if (existingPayment.status !== "pending") {
return jsonResponse(
{ ok: false, error: "payment_not_pending", payment: existingPayment },
409,
)
}

const payment = ctx.db.updatePaymentStatus(payment_id, "completed")
return jsonResponse({ ok: true, payment: payment! })
})
36 changes: 36 additions & 0 deletions routes/payments/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

const responseSchema = z.union([
z.object({
ok: z.literal(true),
payment: paymentSchema,
}),
z.object({
ok: z.literal(false),
error: z.literal("payment_not_found"),
}),
])

const jsonResponse = (body: z.infer<typeof responseSchema>, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json",
},
})

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

if (!payment) {
return jsonResponse({ ok: false, error: "payment_not_found" }, 404)
}

return jsonResponse({ ok: true, payment })
})
26 changes: 26 additions & 0 deletions routes/payments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { paymentSchema, paymentStatusSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

export default withRouteSpec({
methods: ["GET"],
jsonResponse: z.object({
payments: z.array(paymentSchema),
}),
})((req, ctx) => {
const searchParams = new URL(req.url).searchParams
const statusParam = searchParams.get("status")
const status = statusParam
? paymentStatusSchema.parse(statusParam)
: undefined

const payments = ctx.db.listPayments({
recipient: searchParams.get("recipient") ?? undefined,
status,
repository: searchParams.get("repository") ?? undefined,
bounty_id: searchParams.get("bounty_id") ?? undefined,
issue_number: searchParams.get("issue_number") ?? undefined,
})

return ctx.json({ payments })
})
27 changes: 27 additions & 0 deletions routes/payments/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

const sendPaymentBodySchema = z.object({
recipient: z.string().min(1),
amount: z.number().positive(),
currency: z.string().min(1).default("USD"),
bounty_id: z.string().min(1).optional(),
issue_number: z.string().min(1).optional(),
repository: z.string().min(1).optional(),
idempotency_key: z.string().min(1).optional(),
})

export default withRouteSpec({
methods: ["POST"],
jsonBody: sendPaymentBodySchema,
jsonResponse: z.object({
ok: z.boolean(),
payment: paymentSchema,
}),
})(async (req, ctx) => {
const body = sendPaymentBodySchema.parse(await req.json())
const payment = ctx.db.createPayment(body)

return ctx.json({ ok: true, payment })
})
Loading