diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..9751c48 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -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 = () => { @@ -11,7 +17,14 @@ export const createDatabase = () => { export type DbClient = ReturnType -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) => { set((state) => ({ things: [ @@ -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 + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..b65708c 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,33 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusEnum = z.enum([ + "pending", + "completed", + "canceled", + "failed", +]) +export type PaymentStatus = z.infer + +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 + 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 diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..0d9d8b0 --- /dev/null +++ b/routes/payments/cancel.ts @@ -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 }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..a5a9893 --- /dev/null +++ b/routes/payments/complete.ts @@ -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 }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..20be721 --- /dev/null +++ b/routes/payments/get.ts @@ -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 }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..315d999 --- /dev/null +++ b/routes/payments/list.ts @@ -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 }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..df0fb5a --- /dev/null +++ b/routes/payments/send.ts @@ -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 }) +}) diff --git a/tests/routes/payments/send.test.ts b/tests/routes/payments/send.test.ts new file mode 100644 index 0000000..a582a21 --- /dev/null +++ b/tests/routes/payments/send.test.ts @@ -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) +})