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
103 changes: 98 additions & 5 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 { hoist } from "zustand-hoist"
import { combine } from "zustand/middleware"
import { immer } from "zustand/middleware/immer"
import { hoist, type HoistedStoreApi } from "zustand-hoist"
import { 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"
Comment on lines +1 to +12

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,91 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},
sendPayment: (
payment: Omit<
Payment,
| "payment_id"
| "status"
| "created_at"
| "updated_at"
| "completed_at"
| "cancelled_at"
>,
) => {
const now = new Date().toISOString()
let createdPayment: Payment | undefined

set((state) => {
const existingPayment = payment.idempotency_key
? state.payments.find(
(existing) => existing.idempotency_key === payment.idempotency_key,
)
: undefined

if (existingPayment) {
createdPayment = existingPayment
return {}
}

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?: {
status?: PaymentStatus
recipient?: string
repository?: string
bounty_id?: string
}) => {
const state = get()
return state.payments.filter((payment) => {
if (filters?.status && payment.status !== filters.status) return false
if (filters?.recipient && payment.recipient !== filters.recipient) {
return false
}
if (filters?.repository && payment.repository !== filters.repository) {
return false
}
if (filters?.bounty_id && payment.bounty_id !== filters.bounty_id) {
return false
}
return true
})
},
getPayment: (payment_id: string) => {
return get().payments.find((payment) => payment.payment_id === payment_id)
},
updatePaymentStatus: (payment_id: string, status: PaymentStatus) => {
const now = new Date().toISOString()
let updatedPayment: Payment | undefined

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

updatedPayment = {
...payment,
status,
updated_at: now,
completed_at: status === "completed" ? now : payment.completed_at,
cancelled_at: status === "cancelled" ? now : payment.cancelled_at,
Comment on lines +108 to +109
}
return updatedPayment
}),
}))

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

export const paymentStatusSchema = z.enum(["pending", "completed", "cancelled"])
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(),
completed_at: z.string().optional(),
cancelled_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>
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().min(1),
}),
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, "cancelled")

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().min(1),
}),
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 })
})
27 changes: 27 additions & 0 deletions routes/payments/get.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: ["GET"],
jsonResponse: z.union([
z.object({
payment: paymentSchema,
}),
z.object({
error: z.string(),
}),
]),
})((req, ctx) => {
const paymentId = new URL(req.url).searchParams.get("payment_id")
if (!paymentId) {
return ctx.json({ error: "payment_id is required" }, { status: 400 })
}

const payment = ctx.db.getPayment(paymentId)
if (!payment) {
return ctx.json({ error: "payment not found" }, { status: 404 })
}

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

Comment on lines +13 to +14
const payments = ctx.db.listPayments({
status: parsedStatus,
recipient: searchParams.get("recipient") ?? undefined,
repository: searchParams.get("repository") ?? undefined,
bounty_id: searchParams.get("bounty_id") ?? undefined,
})

return ctx.json({ payments })
})
37 changes: 37 additions & 0 deletions routes/payments/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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().min(1).optional(),
issue_number: z.number().int().positive().optional(),
repository: z.string().min(1).optional(),
idempotency_key: z.string().min(1).optional(),
}),
jsonResponse: z.object({
payment: paymentSchema,
reused: z.boolean(),
}),
})(async (req, ctx) => {
const body = await req.json()
const existingPayment = body.idempotency_key
? ctx.db.payments.find(
(payment) => payment.idempotency_key === body.idempotency_key,
)
: undefined

const payment = ctx.db.sendPayment({
...body,
currency: body.currency ?? "USD",
})

return ctx.json({
payment,
reused: Boolean(existingPayment),
Comment on lines +22 to +35
})
})
91 changes: 91 additions & 0 deletions tests/routes/payments/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { expect, test } from "bun:test"
import { getTestServer } from "tests/fixtures/get-test-server"

test("sends, retrieves, lists, and completes a payment", async () => {
const { axios } = await getTestServer()

const { data: sendData } = await axios.post("/payments/send", {
recipient: "octocat",
amount: 10,
currency: "USD",
bounty_id: "bounty-1",
issue_number: 1,
repository: "tscircuit/fake-algora",
})

expect(sendData.reused).toBe(false)
expect(sendData.payment).toMatchObject({
payment_id: "0",
recipient: "octocat",
amount: 10,
Comment on lines +16 to +20
currency: "USD",
bounty_id: "bounty-1",
issue_number: 1,
repository: "tscircuit/fake-algora",
status: "pending",
})

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

const { data: listData } = await axios.get(
"/payments/list?status=pending&repository=tscircuit/fake-algora",
)
expect(listData.payments).toHaveLength(1)

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

test("reuses a payment when the idempotency key matches", async () => {
const { axios } = await getTestServer()

const payload = {
recipient: "octocat",
amount: 25,
currency: "USD",
idempotency_key: "github-delivery-1",
}

const { data: firstSend } = await axios.post("/payments/send", payload)
const { data: retrySend } = await axios.post("/payments/send", payload)
const { data: listData } = await axios.get("/payments/list")

expect(firstSend.reused).toBe(false)
expect(retrySend.reused).toBe(true)
expect(retrySend.payment.payment_id).toBe(firstSend.payment.payment_id)
expect(listData.payments).toHaveLength(1)
})

test("filters payments and cancels pending payments", async () => {
const { axios } = await getTestServer()

await axios.post("/payments/send", {
recipient: "alice",
amount: 5,
repository: "owner/repo-a",
})
const { data: secondSend } = await axios.post("/payments/send", {
recipient: "bob",
amount: 15,
repository: "owner/repo-b",
})

const { data: filteredData } = await axios.get(
"/payments/list?recipient=alice",
)
expect(filteredData.payments).toHaveLength(1)
expect(filteredData.payments[0].recipient).toBe("alice")

const { data: cancelData } = await axios.post("/payments/cancel", {
payment_id: secondSend.payment.payment_id,
})

expect(cancelData.payment.status).toBe("cancelled")
expect(typeof cancelData.payment.cancelled_at).toBe("string")
})
Loading