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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,17 @@ 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 payment routes provide a small fake payment lifecycle for bounty payouts:

- `POST /payments/send` creates a pending payment. The body accepts `recipient`,
`amount`, optional `currency`, optional bounty metadata, and an optional
`idempotency_key` for retry-safe sends.
- `GET /payments/list` lists payments. Optional query filters: `recipient`,
`repository`, and `status`.
- `GET /payments/get?payment_id=pay_1` returns one payment.
- `POST /payments/complete` marks a pending payment as completed.
- `POST /payments/cancel` marks a pending payment as canceled.
- `POST /payments/fail` marks a pending payment as failed.
72 changes: 67 additions & 5 deletions lib/db/db-client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
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 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 +25,62 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},
sendPayment: (
payment: Omit<
Payment,
"payment_id" | "status" | "created_at" | "updated_at"
>,
) => {
const now = new Date().toISOString()
const existingPayment = payment.idempotency_key
? get().payments.find(
(existing) => existing.idempotency_key === payment.idempotency_key,
)
: undefined

if (existingPayment) {
return existingPayment
}

let createdPayment: Payment | undefined
set((state) => {
createdPayment = {
...payment,
payment_id: `pay_${state.idCounter}`,
status: "pending",
created_at: now,
updated_at: now,
}

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

return createdPayment!
},
updatePaymentStatus: (paymentId: string, status: PaymentStatus) => {
const payment = get().payments.find(
(existing) => existing.payment_id === paymentId,
)

if (!payment) {
return undefined
}

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

set((state) => ({
payments: state.payments.map((existing) =>
existing.payment_id === paymentId ? updatedPayment : existing,
),
}))

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

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

export const paymentSchema = z.object({
payment_id: z.string(),
recipient: z.string(),
amount: z.number(),
currency: z.string(),
status: paymentStatusSchema,
bounty_id: z.string().optional(),
issue_number: z.number().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([]),
payments: z.array(paymentSchema).default([]),
})
export type DatabaseSchema = z.infer<typeof databaseSchema>
32 changes: 32 additions & 0 deletions routes/payments/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
ok: z.boolean(),
error: z.string().optional(),
payment: paymentSchema.optional(),
}),
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.payments.find(
(existing) => existing.payment_id === payment_id,
)

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

if (payment.status !== "pending") {
return ctx.json({ ok: false, error: "Only pending payments can cancel" })
}

const updatedPayment = ctx.db.updatePaymentStatus(payment_id, "canceled")

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

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
ok: z.boolean(),
error: z.string().optional(),
payment: paymentSchema.optional(),
}),
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.payments.find(
(existing) => existing.payment_id === payment_id,
)

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

if (payment.status !== "pending") {
return ctx.json({ ok: false, error: "Only pending payments can complete" })
}

const updatedPayment = ctx.db.updatePaymentStatus(payment_id, "completed")

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

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
ok: z.boolean(),
error: z.string().optional(),
payment: paymentSchema.optional(),
}),
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.payments.find(
(existing) => existing.payment_id === payment_id,
)

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

if (payment.status !== "pending") {
return ctx.json({ ok: false, error: "Only pending payments can fail" })
}

const updatedPayment = ctx.db.updatePaymentStatus(payment_id, "failed")

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

export default withRouteSpec({
methods: ["GET"],
jsonResponse: z.object({
ok: z.boolean(),
error: z.string().optional(),
payment: paymentSchema.optional(),
}),
})((req, ctx) => {
const paymentId = new URL(req.url).searchParams.get("payment_id")
const payment = ctx.db.payments.find(
(existing) => existing.payment_id === paymentId,
)

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

return ctx.json({ ok: true, payment })
})
25 changes: 25 additions & 0 deletions routes/payments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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 recipient = searchParams.get("recipient")
const repository = searchParams.get("repository")
const statusResult = paymentStatusSchema.safeParse(searchParams.get("status"))
const status = statusResult.success ? statusResult.data : undefined

const payments = ctx.db.payments.filter((payment) => {
if (recipient && payment.recipient !== recipient) return false
if (repository && payment.repository !== repository) return false
if (status && payment.status !== status) return false
return true
})

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

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
recipient: z.string().min(1),
amount: z.number().positive(),
currency: z.string().min(1).default("USD"),
bounty_id: z.string().optional(),
issue_number: z.number().int().positive().optional(),
repository: z.string().optional(),
idempotency_key: z.string().optional(),
}),
jsonResponse: z.object({
ok: z.boolean(),
payment: paymentSchema,
}),
})(async (req, ctx) => {
const {
recipient,
amount,
currency = "USD",
bounty_id,
issue_number,
repository,
idempotency_key,
} = await req.json()

const payment = ctx.db.sendPayment({
recipient,
amount,
currency,
bounty_id,
issue_number,
repository,
idempotency_key,
})

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