From 35a55aeccf72b2cea99f09a09b49e780bfc0fc16 Mon Sep 17 00:00:00 2001 From: autochamchikim-pixel Date: Wed, 13 May 2026 04:50:18 +0000 Subject: [PATCH] feat: add fake payment API --- lib/db/db-client.ts | 70 ++++++++++++++++++++++-- lib/db/schema.ts | 21 +++++++ routes/payments/get.ts | 32 +++++++++++ routes/payments/list.ts | 28 ++++++++++ routes/payments/send.ts | 56 +++++++++++++++++++ routes/payments/update-status.ts | 33 +++++++++++ tests/routes/payments/send.test.ts | 88 ++++++++++++++++++++++++++++++ 7 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 routes/payments/get.ts create mode 100644 routes/payments/list.ts create mode 100644 routes/payments/send.ts create mode 100644 routes/payments/update-status.ts create mode 100644 tests/routes/payments/send.test.ts diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..49a20a2 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,13 @@ -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)) @@ -11,7 +15,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 +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 + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..8c94ea7 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,29 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum(["pending", "sent", "completed", "canceled", "failed"]) +export type PaymentStatus = z.infer + +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 + 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 diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..3d18965 --- /dev/null +++ b/routes/payments/get.ts @@ -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 }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..520472a --- /dev/null +++ b/routes/payments/list.ts @@ -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 }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..d94c14b --- /dev/null +++ b/routes/payments/send.ts @@ -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 }) +}) diff --git a/routes/payments/update-status.ts b/routes/payments/update-status.ts new file mode 100644 index 0000000..f01f2ba --- /dev/null +++ b/routes/payments/update-status.ts @@ -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 }) +}) diff --git a/tests/routes/payments/send.test.ts b/tests/routes/payments/send.test.ts new file mode 100644 index 0000000..9a800fe --- /dev/null +++ b/tests/routes/payments/send.test.ts @@ -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) +})