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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,16 @@ 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

- `POST /payments/send` creates a pending fake payment. The body accepts
`recipient_email`, `amount_cents`, optional `currency`, bounty metadata, and an
optional `idempotency_key` for retry-safe sends.
- `GET /payments/list` returns payments. It can filter by `status`,
`recipient_email`, and `repository`.
- `GET /payments/get?payment_id=...` returns one payment or `404`.
- `POST /payments/complete` marks a pending payment as completed.
- `POST /payments/cancel` marks a pending payment as canceled.

Completed and canceled payments are terminal; later status changes return `409`.
117 changes: 112 additions & 5 deletions lib/db/db-client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
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"

export interface CreatePaymentInput {
recipient_email: string
amount_cents: number
currency?: string
bounty_id?: string
issue_number?: number
repository?: string
idempotency_key?: string
}

export interface ListPaymentsInput {
status?: PaymentStatus
recipient_email?: string
repository?: string
}

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 +41,91 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},
createPayment: (input: CreatePaymentInput) => {
const now = new Date().toISOString()
let payment: Payment | undefined
let idempotentReplay = false

set((state) => {
if (input.idempotency_key) {
const existingPayment = state.payments.find(
(candidate) => candidate.idempotency_key === input.idempotency_key,
)
if (existingPayment) {
payment = existingPayment
idempotentReplay = true
return state
}
}

payment = {
payment_id: `payment_${state.paymentIdCounter}`,
recipient_email: input.recipient_email,
amount_cents: input.amount_cents,
currency: (input.currency ?? "usd").toLowerCase(),
bounty_id: input.bounty_id,
issue_number: input.issue_number,
repository: input.repository,
idempotency_key: input.idempotency_key,
status: "pending",
created_at: now,
updated_at: now,
}

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

return { payment: payment!, idempotent_replay: idempotentReplay }
},
listPayments: (filters: ListPaymentsInput = {}) => {
return get().payments.filter((payment) => {
if (filters.status && payment.status !== filters.status) return false
if (
filters.recipient_email &&
payment.recipient_email !== filters.recipient_email
) {
return false
}
if (filters.repository && payment.repository !== filters.repository) {
return false
}
return true
})
},
getPayment: (paymentId: string) => {
return get().payments.find((payment) => payment.payment_id === paymentId)
},
updatePaymentStatus: (paymentId: string, status: PaymentStatus) => {
let updatedPayment: Payment | undefined
let error: "not_found" | "terminal_status" | undefined
const now = new Date().toISOString()

set((state) => {
const payment = state.payments.find(
(candidate) => candidate.payment_id === paymentId,
)
if (!payment) {
error = "not_found"
return state
}
if (payment.status !== "pending") {
error = "terminal_status"
updatedPayment = payment
return state
}

const payments = state.payments.map((candidate) => {
if (candidate.payment_id !== paymentId) return candidate
updatedPayment = { ...candidate, status, updated_at: now }
return updatedPayment
})

return { payments }
})

return { payment: updatedPayment, error }
},
}))
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_email: z.string().email(),
amount_cents: z.number().int().positive(),
currency: z.string().length(3),
bounty_id: z.string().optional(),
issue_number: z.number().int().positive().optional(),
repository: z.string().optional(),
idempotency_key: z.string().optional(),
status: paymentStatusSchema,
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),
paymentIdCounter: z.number().default(0),
things: z.array(thingSchema).default([]),
payments: z.array(paymentSchema).default([]),
})
export type DatabaseSchema = z.infer<typeof databaseSchema>
13 changes: 12 additions & 1 deletion lib/middleware/with-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,25 @@ import type { DbClient } from "lib/db/db-client"
import { createDatabase } from "lib/db/db-client"
import type { Middleware } from "winterspec"

let defaultDb: DbClient | undefined

export const getDefaultDb = () => {
defaultDb ??= createDatabase()
return defaultDb
}

export const resetDefaultDbForTests = () => {
defaultDb = createDatabase()
}

export const withDb: Middleware<
{},
{
db: DbClient
}
> = async (req, ctx, next) => {
if (!ctx.db) {
ctx.db = createDatabase()
ctx.db = getDefaultDb()
}
return next(req, ctx)
}
50 changes: 50 additions & 0 deletions lib/payments/route-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { paymentSchema, paymentStatusSchema } from "lib/db/schema"
import { z } from "zod"

export const sendPaymentBodySchema = z.object({
recipient_email: z.string().email(),
amount_cents: z.number().int().positive(),
currency: z.string().length(3).default("usd"),
bounty_id: z.string().min(1).optional(),
issue_number: z.number().int().positive().optional(),
repository: z.string().min(1).optional(),
idempotency_key: z.string().min(1).optional(),
})

export const listPaymentsQuerySchema = z.object({
status: paymentStatusSchema.optional(),
recipient_email: z.string().email().optional(),
repository: z.string().min(1).optional(),
})

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

export const paymentIdQuerySchema = paymentIdBodySchema

export const paymentSuccessResponseSchema = z.object({
ok: z.literal(true),
payment: paymentSchema,
})

export const paymentErrorResponseSchema = z.object({
ok: z.literal(false),
error: z.string(),
})

export const paymentLookupResponseSchema = z.union([
paymentSuccessResponseSchema,
paymentErrorResponseSchema,
])

export const sendPaymentResponseSchema = z.object({
ok: z.literal(true),
payment: paymentSchema,
idempotent_replay: z.boolean(),
})

export const listPaymentsResponseSchema = z.object({
ok: z.literal(true),
payments: z.array(paymentSchema),
})
23 changes: 23 additions & 0 deletions routes/payments/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
paymentIdBodySchema,
paymentLookupResponseSchema,
} from "lib/payments/route-schemas"

export default withRouteSpec({
methods: ["POST"],
jsonBody: paymentIdBodySchema,
jsonResponse: paymentLookupResponseSchema,
})((req, ctx) => {
const { payment, error } = ctx.db.updatePaymentStatus(
req.jsonBody.payment_id,
"canceled",
)
if (error === "not_found") {
return ctx.json({ ok: false, error: "payment_not_found" }).status(404)
}
if (error === "terminal_status") {
return ctx.json({ ok: false, error: "payment_is_not_pending" }).status(409)
}
return ctx.json({ ok: true, payment: payment! })
})
23 changes: 23 additions & 0 deletions routes/payments/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
paymentIdBodySchema,
paymentLookupResponseSchema,
} from "lib/payments/route-schemas"

export default withRouteSpec({
methods: ["POST"],
jsonBody: paymentIdBodySchema,
jsonResponse: paymentLookupResponseSchema,
})((req, ctx) => {
const { payment, error } = ctx.db.updatePaymentStatus(
req.jsonBody.payment_id,
"completed",
)
if (error === "not_found") {
return ctx.json({ ok: false, error: "payment_not_found" }).status(404)
}
if (error === "terminal_status") {
return ctx.json({ ok: false, error: "payment_is_not_pending" }).status(409)
}
return ctx.json({ ok: true, payment: payment! })
})
17 changes: 17 additions & 0 deletions routes/payments/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
paymentIdQuerySchema,
paymentLookupResponseSchema,
} from "lib/payments/route-schemas"

export default withRouteSpec({
methods: ["GET"],
queryParams: paymentIdQuerySchema,
jsonResponse: paymentLookupResponseSchema,
})((req, ctx) => {
const payment = ctx.db.getPayment(req.query.payment_id)
if (!payment) {
return ctx.json({ ok: false, error: "payment_not_found" }).status(404)
}
return ctx.json({ ok: true, payment })
})
16 changes: 16 additions & 0 deletions routes/payments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
listPaymentsQuerySchema,
listPaymentsResponseSchema,
} from "lib/payments/route-schemas"

export default withRouteSpec({
methods: ["GET"],
queryParams: listPaymentsQuerySchema,
jsonResponse: listPaymentsResponseSchema,
})((req, ctx) => {
return ctx.json({
ok: true,
payments: ctx.db.listPayments(req.query),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the payment store across requests

When these endpoints run through the normal withRouteSpec middleware stack, withDb creates a fresh createDatabase() whenever the request context has no db, so this list call reads a new empty store rather than the payments created by earlier /payments/send requests. The payment tests inject a shared db in tests/fixtures/start-server.ts, which masks this production path; as a result /payments/list, /payments/get, /payments/complete, and /payments/cancel cannot observe prior sends unless the server is run with the test-only middleware.

Useful? React with 👍 / 👎.

})
})
14 changes: 14 additions & 0 deletions routes/payments/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import {
sendPaymentBodySchema,
sendPaymentResponseSchema,
} from "lib/payments/route-schemas"

export default withRouteSpec({
methods: ["POST"],
jsonBody: sendPaymentBodySchema,
jsonResponse: sendPaymentResponseSchema,
})(async (req, ctx) => {
const { payment, idempotent_replay } = ctx.db.createPayment(req.jsonBody)
return ctx.json({ ok: true, payment, idempotent_replay })
})
Loading