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

## Payment API

The fake payment lifecycle supports the core bounty-payment flow:

- `POST /payments/send` creates a pending payment record. Include an optional
`idempotency_key` to make retries return the original payment instead of
creating duplicates.
- `GET /payments/list` returns payments, with optional `recipient`, `status`,
and `repository` query filters.
- `GET /payments/get?payment_id=<id>` returns a single payment.
- `POST /payments/complete` marks a payment as completed.
- `POST /payments/cancel` marks a payment as canceled.
Binary file modified bun.lockb
Binary file not shown.
104 changes: 100 additions & 4 deletions lib/db/db-client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
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 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) => ({
type PaymentInput = Omit<
Payment,
| "payment_id"
| "status"
| "created_at"
| "updated_at"
| "completed_at"
| "canceled_at"
| "failed_at"
>

const statusTimestampField = {
completed: "completed_at",
canceled: "canceled_at",
failed: "failed_at",
pending: undefined,
} as const satisfies Record<PaymentStatus, keyof Payment | undefined>

const initializer = combine(databaseSchema.parse({}), (set, get) => ({
addThing: (thing: Omit<Thing, "thing_id">) => {
set((state) => ({
things: [
Expand All @@ -21,4 +44,77 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},
addPayment: (payment: PaymentInput): Payment => {
const existingPayment = payment.idempotency_key
? get().payments.find(
(storedPayment) =>
storedPayment.idempotency_key === payment.idempotency_key,
)
: undefined

if (existingPayment) {
return existingPayment
}

const now = new Date().toISOString()
const newPayment: Payment = {
...payment,
payment_id: get().idCounter.toString(),
status: "pending",
created_at: now,
updated_at: now,
}

set((state) => ({
payments: [...state.payments, newPayment],
idCounter: state.idCounter + 1,
}))

return newPayment
},
getPayment: (payment_id: string): Payment | undefined => {
return get().payments.find((payment) => payment.payment_id === payment_id)
},
listPayments: (
filters: Partial<Pick<Payment, "recipient" | "status" | "repository">>,
): Payment[] => {
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
}
return true
})
},
updatePaymentStatus: (
payment_id: string,
status: Exclude<PaymentStatus, "pending">,
): Payment | undefined => {
const now = new Date().toISOString()
let updatedPayment: Payment | undefined

set((state) => ({
payments: state.payments.map((payment) => {
if (payment.payment_id !== payment_id) {
return payment
}

const timestampField = statusTimestampField[status]
updatedPayment = {
...payment,
status,
updated_at: now,
...(timestampField ? { [timestampField]: now } : {}),
}
return updatedPayment
}),
}))

return updatedPayment
},
}))
27 changes: 27 additions & 0 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,35 @@ 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().positive(),
currency: z.string(),
status: paymentStatusSchema,
bounty_id: z.string().optional(),
issue_number: z.number().int().positive().optional(),
repository: z.string().optional(),
idempotency_key: z.string().optional(),
created_at: z.string(),
updated_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),
things: z.array(thingSchema).default([]),
payments: z.array(paymentSchema).default([]),
})
export type DatabaseSchema = z.infer<typeof databaseSchema>
27 changes: 27 additions & 0 deletions routes/payments/cancel.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"

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string(),
}),
jsonResponse: z.union([
z.object({
payment: paymentSchema,
}),
z.object({
error: z.string(),
}),
]),
})(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 })
})
27 changes: 27 additions & 0 deletions routes/payments/complete.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"

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string(),
}),
jsonResponse: z.union([
z.object({
payment: paymentSchema,
}),
z.object({
error: z.string(),
}),
]),
})(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 })
})
25 changes: 25 additions & 0 deletions routes/payments/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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.union([
z.object({
payment: paymentSchema,
}),
z.object({
error: z.string(),
}),
]),
})((req, ctx) => {
const url = new URL(req.url)
const paymentId = url.searchParams.get("payment_id")
const payment = paymentId ? ctx.db.getPayment(paymentId) : undefined

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

return ctx.json({ payment })
})
24 changes: 24 additions & 0 deletions routes/payments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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 url = new URL(req.url)
const statusParam = url.searchParams.get("status")
const status = statusParam
? paymentStatusSchema.parse(statusParam)
: undefined

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

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

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

export default withRouteSpec({
methods: ["POST"],
jsonBody: paymentRequestSchema,
jsonResponse: z.object({
payment: paymentSchema,
}),
})(async (req, ctx) => {
const paymentRequest = await req.json()
const payment = ctx.db.addPayment(paymentRequest)

return ctx.json({ payment })
})
72 changes: 72 additions & 0 deletions tests/routes/payments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { expect, test } from "bun:test"
import { getTestServer } from "tests/fixtures/get-test-server"

test("send payment is idempotent and queryable", async () => {
const { axios } = await getTestServer()

const paymentRequest = {
recipient: "richboyneedcash",
amount: 10,
currency: "USD",
repository: "tscircuit/fake-algora",
issue_number: 1,
bounty_id: "fake-algora-1",
idempotency_key: "issue-1-richboyneedcash",
}

const firstResponse = await axios.post("/payments/send", paymentRequest)
const replayResponse = await axios.post("/payments/send", paymentRequest)

expect(replayResponse.data.payment.payment_id).toBe(
firstResponse.data.payment.payment_id,
)

const { data: listData } = await axios.get(
"/payments/list?recipient=richboyneedcash&status=pending",
)
expect(listData.payments).toHaveLength(1)
expect(listData.payments[0]).toMatchObject({
recipient: "richboyneedcash",
amount: 10,
currency: "USD",
repository: "tscircuit/fake-algora",
issue_number: 1,
status: "pending",
})
})

test("payment lifecycle can be completed or canceled", async () => {
const { axios } = await getTestServer()

const { data: sentData } = await axios.post("/payments/send", {
recipient: "first-contributor",
amount: 15,
currency: "USD",
repository: "tscircuit/fake-algora",
issue_number: 1,
})

const paymentId = sentData.payment.payment_id

const { data: getData } = await axios.get(
`/payments/get?payment_id=${paymentId}`,
)
expect(getData.payment.status).toBe("pending")

const { data: completeData } = await axios.post("/payments/complete", {
payment_id: paymentId,
})
expect(completeData.payment.status).toBe("completed")
expect(completeData.payment.completed_at).toBeString()

const { data: cancelSentData } = await axios.post("/payments/send", {
recipient: "second-contributor",
amount: 20,
currency: "USD",
})
const { data: cancelData } = await axios.post("/payments/cancel", {
payment_id: cancelSentData.payment.payment_id,
})
expect(cancelData.payment.status).toBe("canceled")
expect(cancelData.payment.canceled_at).toBeString()
})