diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..2c55287 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,15 @@ -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" export const createDatabase = () => { return hoist(createStore(initializer)) @@ -11,7 +17,7 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -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, + } + return updatedPayment + }), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..0d9896d 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,30 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum(["pending", "completed", "cancelled"]) +export type PaymentStatus = z.infer + +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 + 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 diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..f0306c0 --- /dev/null +++ b/routes/payments/cancel.ts @@ -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 }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..8c0fe1d --- /dev/null +++ b/routes/payments/complete.ts @@ -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 }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..5d3511b --- /dev/null +++ b/routes/payments/get.ts @@ -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 }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..616b6c8 --- /dev/null +++ b/routes/payments/list.ts @@ -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 + + 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 }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..fa5b3cb --- /dev/null +++ b/routes/payments/send.ts @@ -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), + }) +}) diff --git a/tests/routes/payments/lifecycle.test.ts b/tests/routes/payments/lifecycle.test.ts new file mode 100644 index 0000000..45196fc --- /dev/null +++ b/tests/routes/payments/lifecycle.test.ts @@ -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, + 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") +})