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

set((state) => {
createdPayment = {
...payment,
payment_id: `pay_${state.paymentCounter}`,
status: "sent",
created_at: now,
updated_at: now,
}

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

return createdPayment as Payment
},
getPaymentById: (paymentId: string) => {
return get().payments.find((payment) => payment.payment_id === paymentId)
},
getPaymentByIdempotencyKey: (idempotencyKey: string) => {
return get().payments.find(
(payment) => payment.idempotency_key === idempotencyKey,
)
},
updatePaymentStatus: (paymentId: string, status: PaymentStatus) => {
let updatedPayment: Payment | undefined
const now = new Date().toISOString()

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

updatedPayment = {
...payment,
status,
updated_at: now,
}

return updatedPayment
})

return { payments }
})

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

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

export const paymentSchema = z.object({
payment_id: z.string(),
recipient: z.string(),
amount_cents: z.number().int().positive(),
currency: z.string().length(3),
status: paymentStatusSchema,
description: z.string().optional(),
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(),
})
export type Payment = z.infer<typeof paymentSchema>

export const databaseSchema = z.object({
idCounter: z.number().default(0),
things: z.array(thingSchema).default([]),
paymentCounter: z.number().default(0),
payments: z.array(paymentSchema).default([]),
})
export type DatabaseSchema = z.infer<typeof databaseSchema>
32 changes: 32 additions & 0 deletions routes/payments/get.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"

const getPaymentResponseSchema = z.union([
z.object({
payment: paymentSchema,
}),
z.object({
error: z.string(),
}),
])

export default withRouteSpec({
methods: ["GET"],
jsonResponse: getPaymentResponseSchema,
})((req, ctx) => {
const url = new URL(req.url)
const paymentId = url.searchParams.get("payment_id")

if (!paymentId) {
return ctx.json({ error: "payment_id is required" }, { status: 400 })
}

const payment = ctx.db.getPaymentById(paymentId)

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

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

const listPaymentsResponseSchema = z.object({
payments: z.array(paymentSchema),
})

export default withRouteSpec({
methods: ["GET"],
jsonResponse: listPaymentsResponseSchema,
})((req, ctx) => {
const url = new URL(req.url)
const recipient = url.searchParams.get("recipient") ?? undefined
const status = url.searchParams.get("status") ?? undefined

const statusResult = status
? paymentStatusSchema.safeParse(status)
: undefined
const payments = ctx.db.payments.filter((payment) => {
if (recipient && payment.recipient !== recipient) return false
if (statusResult?.success && payment.status !== statusResult.data)
return false
return true
})

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

const sendPaymentRequestSchema = z
.object({
recipient: z.string().min(1),
amount_cents: z.number().int().positive().optional(),
amount: z.number().positive().optional(),
currency: z
.string()
.length(3)
.default("USD")
.transform((currency) => currency.toUpperCase()),
description: z.string().optional(),
bounty_id: z.string().optional(),
issue_number: z.number().int().positive().optional(),
repository: z.string().optional(),
idempotency_key: z.string().min(1).optional(),
})
.refine(
(body) => body.amount_cents !== undefined || body.amount !== undefined,
"amount_cents or amount is required",
)

const sendPaymentResponseSchema = z.object({
ok: z.boolean(),
payment: paymentSchema,
duplicate: z.boolean(),
})

export default withRouteSpec({
methods: ["POST"],
jsonBody: sendPaymentRequestSchema,
jsonResponse: sendPaymentResponseSchema,
})(async (req, ctx) => {
const body = sendPaymentRequestSchema.parse(await req.json())

if (body.idempotency_key) {
const existingPayment = ctx.db.getPaymentByIdempotencyKey(
body.idempotency_key,
)

if (existingPayment) {
return ctx.json({ ok: true, payment: existingPayment, duplicate: true })
}
}

const { amount, ...paymentInput } = body
const payment = ctx.db.addPayment({
...paymentInput,
amount_cents: paymentInput.amount_cents ?? Math.round(amount! * 100),
})

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

const updatePaymentStatusRequestSchema = z.object({
payment_id: z.string().min(1),
status: paymentStatusSchema,
})

const updatePaymentStatusResponseSchema = z.union([
z.object({
ok: z.boolean(),
payment: paymentSchema,
}),
z.object({
error: z.string(),
}),
])

export default withRouteSpec({
methods: ["POST"],
jsonBody: updatePaymentStatusRequestSchema,
jsonResponse: updatePaymentStatusResponseSchema,
})(async (req, ctx) => {
const body = updatePaymentStatusRequestSchema.parse(await req.json())
const payment = ctx.db.updatePaymentStatus(body.payment_id, body.status)

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

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

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

const { data } = await axios.post("/payments/send", {
recipient: "octocat",
amount_cents: 1000,
currency: "usd",
description: "Issue #1 bounty payout",
bounty_id: "bounty_1",
issue_number: 1,
repository: "tscircuit/fake-algora",
})

expect(data).toMatchObject({
ok: true,
duplicate: false,
payment: {
payment_id: "pay_0",
recipient: "octocat",
amount_cents: 1000,
currency: "USD",
status: "sent",
description: "Issue #1 bounty payout",
bounty_id: "bounty_1",
issue_number: 1,
repository: "tscircuit/fake-algora",
},
})
expect(data.payment.created_at).toBeString()
expect(data.payment.updated_at).toBeString()

const getResponse = await axios.get(
`/payments/get?payment_id=${data.payment.payment_id}`,
)
expect(getResponse.data.payment).toEqual(data.payment)

const updateResponse = await axios.post("/payments/update-status", {
payment_id: data.payment.payment_id,
status: "completed",
})
expect(updateResponse.data).toMatchObject({
ok: true,
payment: {
payment_id: data.payment.payment_id,
status: "completed",
},
})
expect(updateResponse.data.payment.updated_at).toBeString()
})

test("send payment supports amount dollars and idempotency keys", async () => {
const { axios } = await getTestServer()

const firstResponse = await axios.post("/payments/send", {
recipient: "maintainer",
amount: 12.34,
idempotency_key: "issue-1-maintainer-12-34",
})
const secondResponse = await axios.post("/payments/send", {
recipient: "maintainer",
amount: 12.34,
idempotency_key: "issue-1-maintainer-12-34",
})

expect(firstResponse.data).toMatchObject({
ok: true,
duplicate: false,
payment: {
recipient: "maintainer",
amount_cents: 1234,
currency: "USD",
status: "sent",
idempotency_key: "issue-1-maintainer-12-34",
},
})
expect(secondResponse.data).toEqual({
ok: true,
duplicate: true,
payment: firstResponse.data.payment,
})

const listResponse = await axios.get("/payments/list?recipient=maintainer")

expect(listResponse.data.payments).toHaveLength(1)
})