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
Binary file modified bun.lockb
Binary file not shown.
114 changes: 110 additions & 4 deletions lib/db/db-client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
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 type CreatePaymentInput = Omit<
Payment,
"payment_id" | "status" | "created_at" | "updated_at"
>

export type PaymentFilters = {
recipient?: string
status?: PaymentStatus
owner?: string
repo?: string
issue_number?: number
}

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 +40,91 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},
createPayment: (input: CreatePaymentInput) => {
if (input.idempotency_key) {
const existingPayment = get().payments.find(
(payment) => payment.idempotency_key === input.idempotency_key,
)

if (existingPayment) {
return existingPayment
}
}

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

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

return payment
},
listPayments: (filters: PaymentFilters = {}) => {
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.owner && payment.owner !== filters.owner) {
return false
}
if (filters.repo && payment.repo !== filters.repo) {
return false
}
if (
filters.issue_number !== undefined &&
payment.issue_number !== filters.issue_number
) {
return false
}
return true
})
},
getPayment: (paymentId: string) => {
return get().payments.find((payment) => payment.payment_id === paymentId)
},
updatePaymentStatus: (
paymentId: string,
status: Exclude<PaymentStatus, "pending">,
options: { cancel_reason?: string } = {},
) => {
const existingPayment = get().payments.find(
(payment) => payment.payment_id === paymentId,
)

if (!existingPayment) {
return undefined
}

const now = new Date().toISOString()
const updatedPayment: Payment = {
...existingPayment,
status,
updated_at: now,
completed_at: status === "completed" ? now : existingPayment.completed_at,
canceled_at: status === "canceled" ? now : existingPayment.canceled_at,
cancel_reason:
status === "canceled"
? options.cancel_reason
: existingPayment.cancel_reason,
}

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

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"])
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,
owner: z.string().optional(),
repo: z.string().optional(),
issue_number: z.number().optional(),
bounty_id: z.string().optional(),
idempotency_key: z.string().optional(),
cancel_reason: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
completed_at: z.string().optional(),
canceled_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>
21 changes: 21 additions & 0 deletions routes/payments/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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(),
cancel_reason: z.string().optional(),
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})(async (req, ctx) => {
const { payment_id, cancel_reason } = await req.json()
const payment = ctx.db.updatePaymentStatus(payment_id, "canceled", {
cancel_reason,
})

return ctx.json({ payment: payment ?? null })
})
18 changes: 18 additions & 0 deletions routes/payments/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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.object({
payment: paymentSchema.nullable(),
}),
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.updatePaymentStatus(payment_id, "completed")

return ctx.json({ payment: payment ?? null })
})
19 changes: 19 additions & 0 deletions routes/payments/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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({
payment: paymentSchema.nullable(),
}),
})((req, ctx) => {
const url = new URL(req.url)
const paymentId = url.searchParams.get("payment_id")

if (!paymentId) {
return ctx.json({ payment: null })
}

return ctx.json({ payment: ctx.db.getPayment(paymentId) ?? null })
})
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 issueNumber = url.searchParams.get("issue_number")
const status = url.searchParams.get("status")

const payments = ctx.db.listPayments({
recipient: url.searchParams.get("recipient") ?? undefined,
status: status ? paymentStatusSchema.parse(status) : undefined,
owner: url.searchParams.get("owner") ?? undefined,
repo: url.searchParams.get("repo") ?? undefined,
issue_number: issueNumber ? Number(issueNumber) : undefined,
})

return ctx.json({ payments })
})
40 changes: 40 additions & 0 deletions routes/payments/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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"),
owner: z.string().optional(),
repo: 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: sendPaymentBodySchema,
jsonResponse: z.object({
payment: paymentSchema,
idempotent: 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.createPayment({
...body,
currency: body.currency ?? "USD",
})

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

test("send and fetch a fake payment", async () => {
const { axios } = await getTestServer()

const { data } = await axios.post("/payments/send", {
recipient: "octocat",
amount: 10,
owner: "tscircuit",
repo: "fake-algora",
issue_number: 1,
bounty_id: "fake-bounty-1",
idempotency_key: "retry-safe-payment",
})

expect(data.idempotent).toBe(false)
expect(data.payment.payment_id).toBe("0")
expect(data.payment.status).toBe("pending")

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

expect(fetchedData.payment.recipient).toBe("octocat")
expect(fetchedData.payment.amount).toBe(10)
})

test("send is idempotent when the same key is reused", async () => {
const { axios } = await getTestServer()

const payload = {
recipient: "octocat",
amount: 10,
idempotency_key: "same-request",
}

const { data: firstData } = await axios.post("/payments/send", payload)
const { data: secondData } = await axios.post("/payments/send", payload)

expect(secondData.idempotent).toBe(true)
expect(secondData.payment.payment_id).toBe(firstData.payment.payment_id)

const { data: listData } = await axios.get("/payments/list")

expect(listData.payments).toHaveLength(1)
})

test("list filters payments by recipient, status, and repository", async () => {
const { axios } = await getTestServer()

await axios.post("/payments/send", {
recipient: "alice",
amount: 5,
owner: "tscircuit",
repo: "fake-algora",
issue_number: 1,
})
await axios.post("/payments/send", {
recipient: "bob",
amount: 15,
owner: "tscircuit",
repo: "other-repo",
issue_number: 2,
})

const { data } = await axios.get(
"/payments/list?recipient=alice&status=pending&owner=tscircuit&repo=fake-algora&issue_number=1",
)

expect(data.payments).toHaveLength(1)
expect(data.payments[0].recipient).toBe("alice")
})

test("complete and cancel update payment status", async () => {
const { axios } = await getTestServer()

const { data: firstData } = await axios.post("/payments/send", {
recipient: "alice",
amount: 5,
})
const { data: completedData } = await axios.post("/payments/complete", {
payment_id: firstData.payment.payment_id,
})

expect(completedData.payment.status).toBe("completed")
expect(completedData.payment.completed_at).toBeTruthy()

const { data: secondData } = await axios.post("/payments/send", {
recipient: "bob",
amount: 15,
})
const { data: canceledData } = await axios.post("/payments/cancel", {
payment_id: secondData.payment.payment_id,
cancel_reason: "duplicate bounty claim",
})

expect(canceledData.payment.status).toBe("canceled")
expect(canceledData.payment.cancel_reason).toBe("duplicate bounty claim")
})