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
78 changes: 76 additions & 2 deletions lib/db/db-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { createStore, type StoreApi } from "zustand/vanilla"
import { immer } from "zustand/middleware/immer"
import { hoist, type HoistedStoreApi } from "zustand-hoist"

import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts"
import {
databaseSchema,
type DatabaseSchema,
type Payment,
type PaymentStatus,
type Thing,
} from "./schema.ts"
import { combine } from "zustand/middleware"

export const createDatabase = () => {
Expand All @@ -11,7 +17,14 @@ export const createDatabase = () => {

export type DbClient = ReturnType<typeof createDatabase>

const initializer = combine(databaseSchema.parse({}), (set) => ({
type AddPaymentInput = Omit<
Payment,
"payment_id" | "status" | "created_at" | "updated_at"
> & {
status?: PaymentStatus
}

const initializer = combine(databaseSchema.parse({}), (set, get) => ({
addThing: (thing: Omit<Thing, "thing_id">) => {
set((state) => ({
things: [
Expand All @@ -21,4 +34,65 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},

/**
* Create a new payment. If an `idempotency_key` is provided and a payment
* already exists with that key, that existing payment is returned without
* creating a duplicate — this lets clients retry sends safely.
*/
addPayment: (input: AddPaymentInput): Payment => {
if (input.idempotency_key) {
const existing = get().payments.find(
(p) => p.idempotency_key === input.idempotency_key,
)
if (existing) return existing
}
const now = new Date().toISOString()
const state = get()
const payment: Payment = {
payment_id: state.paymentIdCounter.toString(),
recipient: input.recipient,
amount: input.amount,
currency: input.currency ?? "USD",
status: input.status ?? "pending",
bounty_id: input.bounty_id ?? null,
issue_number: input.issue_number ?? null,
repository: input.repository ?? null,
idempotency_key: input.idempotency_key ?? null,
created_at: now,
updated_at: now,
}
set((s) => ({
payments: [...s.payments, payment],
paymentIdCounter: s.paymentIdCounter + 1,
}))
return payment
},

/**
* Transition a payment to a new status (e.g. "completed" or "canceled").
* Refuses to mutate payments already in a terminal state — this mirrors
* how real payment processors guard against retroactive edits.
*/
updatePaymentStatus: (payment_id: string, status: PaymentStatus): Payment | null => {
const state = get()
const target = state.payments.find((p) => p.payment_id === payment_id)
if (!target) return null
if (target.status === "completed" || target.status === "canceled") {
// Terminal — return unchanged to keep history immutable.
return target
}
const now = new Date().toISOString()
const updated: Payment = { ...target, status, updated_at: now }
set((s) => ({
payments: s.payments.map((p) =>
p.payment_id === payment_id ? updated : p,
),
}))
return updated
},

getPayment: (payment_id: string): Payment | null => {
return get().payments.find((p) => p.payment_id === payment_id) ?? null
},
}))
25 changes: 25 additions & 0 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,33 @@ export const thingSchema = z.object({
})
export type Thing = z.infer<typeof thingSchema>

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

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

/**
* POST /payments/cancel
*
* Cancel a pending payment. No-op if the payment is already in a terminal
* state — the existing record is returned unchanged. Returns
* `{ payment: null }` if the id doesn't exist.
*/
export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})(async (req, ctx) => {
const { payment_id } = await req.json()
const updated = ctx.db.updatePaymentStatus(payment_id, "canceled")
return ctx.json({ payment: updated })
})
24 changes: 24 additions & 0 deletions routes/payments/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"
import { paymentSchema } from "lib/db/schema.ts"

/**
* POST /payments/complete
*
* Mark a pending payment as `completed`. No-op if the payment is already in a
* terminal state — the existing record is returned unchanged. Returns
* `{ payment: null }` if the id doesn't exist.
*/
export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})(async (req, ctx) => {
const { payment_id } = await req.json()
const updated = ctx.db.updatePaymentStatus(payment_id, "completed")
return ctx.json({ payment: updated })
})
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 { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"
import { paymentSchema } from "lib/db/schema.ts"

/**
* GET /payments/get
*
* Fetch a single payment by its `payment_id`. Returns `{ payment: null }` if
* not found rather than a 404 — keeps the response schema simple and lets
* clients branch on the body instead of HTTP status.
*/
export default withRouteSpec({
methods: ["GET"],
queryParams: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})((req, ctx) => {
const payment = ctx.db.getPayment(req.query.payment_id)
return ctx.json({ payment })
})
30 changes: 30 additions & 0 deletions routes/payments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"
import { paymentSchema } from "lib/db/schema.ts"

/**
* GET /payments/list
*
* Lists payments. Filter by `status`, `recipient`, or `repository`. With no
* filters set, returns all payments in creation order.
*/
export default withRouteSpec({
methods: ["GET"],
queryParams: z.object({
status: z
.enum(["pending", "completed", "canceled", "failed"])
.optional(),
recipient: z.string().optional(),
repository: z.string().optional(),
}),
jsonResponse: z.object({
payments: z.array(paymentSchema),
}),
})((req, ctx) => {
const { status, recipient, repository } = req.query
let payments = ctx.db.payments
if (status) payments = payments.filter((p) => p.status === status)
if (recipient) payments = payments.filter((p) => p.recipient === recipient)
if (repository) payments = payments.filter((p) => p.repository === repository)
return ctx.json({ payments })
})
38 changes: 38 additions & 0 deletions routes/payments/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"
import { paymentSchema } from "lib/db/schema.ts"

/**
* POST /payments/send
*
* Creates a pending payment. Provide an `idempotency_key` to make the call
* retry-safe — repeated sends with the same key return the same payment
* record rather than creating duplicates.
*/
export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
recipient: z.string().min(1),
amount: z.number().positive(),
currency: z.string().default("USD"),
bounty_id: z.string().optional(),
issue_number: z.number().optional(),
repository: z.string().optional(),
idempotency_key: z.string().optional(),
}),
jsonResponse: z.object({
payment: paymentSchema,
}),
})(async (req, ctx) => {
const body = await req.json()
const payment = ctx.db.addPayment({
recipient: body.recipient,
amount: body.amount,
currency: body.currency,
bounty_id: body.bounty_id ?? null,
issue_number: body.issue_number ?? null,
repository: body.repository ?? null,
idempotency_key: body.idempotency_key ?? null,
})
return ctx.json({ payment })
})
113 changes: 113 additions & 0 deletions tests/routes/payments/send.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { getTestServer } from "tests/fixtures/get-test-server"
import { test, expect } from "bun:test"

test("POST /payments/send creates a pending payment", async () => {
const { axios } = await getTestServer()
const { data } = await axios.post("/payments/send", {
recipient: "alice",
amount: 100,
bounty_id: "algora-12345",
issue_number: 42,
repository: "example/repo",
})
expect(data.payment.recipient).toBe("alice")
expect(data.payment.amount).toBe(100)
expect(data.payment.currency).toBe("USD")
expect(data.payment.status).toBe("pending")
expect(data.payment.bounty_id).toBe("algora-12345")
})

test("POST /payments/send with idempotency_key dedupes retries", async () => {
const { axios } = await getTestServer()
const body = {
recipient: "bob",
amount: 50,
idempotency_key: "retry-once",
}
const a = await axios.post("/payments/send", body)
const b = await axios.post("/payments/send", body)
expect(a.data.payment.payment_id).toBe(b.data.payment.payment_id)

const list = await axios.get("/payments/list", { params: { recipient: "bob" } })
expect(list.data.payments).toHaveLength(1)
})

test("GET /payments/get returns the payment", async () => {
const { axios } = await getTestServer()
const { data: created } = await axios.post("/payments/send", {
recipient: "carol",
amount: 25,
})
const { data: fetched } = await axios.get("/payments/get", {
params: { payment_id: created.payment.payment_id },
})
expect(fetched.payment.payment_id).toBe(created.payment.payment_id)
expect(fetched.payment.recipient).toBe("carol")
})

test("GET /payments/get returns { payment: null } for unknown id", async () => {
const { axios } = await getTestServer()
const { data } = await axios.get("/payments/get", {
params: { payment_id: "does-not-exist" },
})
expect(data.payment).toBeNull()
})

test("POST /payments/complete moves pending → completed", async () => {
const { axios } = await getTestServer()
const { data: created } = await axios.post("/payments/send", {
recipient: "dave",
amount: 75,
})
const { data: completed } = await axios.post("/payments/complete", {
payment_id: created.payment.payment_id,
})
expect(completed.payment.status).toBe("completed")
})

test("POST /payments/cancel moves pending → canceled", async () => {
const { axios } = await getTestServer()
const { data: created } = await axios.post("/payments/send", {
recipient: "eve",
amount: 25,
})
const { data: canceled } = await axios.post("/payments/cancel", {
payment_id: created.payment.payment_id,
})
expect(canceled.payment.status).toBe("canceled")
})

test("completed payments stay completed (terminal state)", async () => {
const { axios } = await getTestServer()
const { data: created } = await axios.post("/payments/send", {
recipient: "frank",
amount: 10,
})
await axios.post("/payments/complete", {
payment_id: created.payment.payment_id,
})
// Try to cancel — should be a no-op since payment is terminal.
const { data: canceled } = await axios.post("/payments/cancel", {
payment_id: created.payment.payment_id,
})
expect(canceled.payment.status).toBe("completed")
})

test("GET /payments/list filters by status", async () => {
const { axios } = await getTestServer()
const { data: p1 } = await axios.post("/payments/send", {
recipient: "g",
amount: 1,
})
await axios.post("/payments/send", { recipient: "h", amount: 2 })
await axios.post("/payments/complete", { payment_id: p1.payment.payment_id })

const pending = await axios.get("/payments/list", {
params: { status: "pending" },
})
const completed = await axios.get("/payments/list", {
params: { status: "completed" },
})
expect(pending.data.payments).toHaveLength(1)
expect(completed.data.payments).toHaveLength(1)
})