diff --git a/README.md b/README.md index 824427a..16b2d96 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,16 @@ This is a template project with best-practice modules: - Winterspec for defining the API - bun testing - Zustand store with zod definition for database state + +## Payment API + +The fake Algora payment flow is exposed through small Winterspec routes backed +by the in-memory Zustand database. + +- `POST /payments/send` creates a pending payment and supports + `idempotency_key` replay protection. +- `GET /payments/list` returns payments, optionally filtered by `recipient` or + `status`. +- `GET /payments/get?payment_id=...` returns one payment or `null`. +- `POST /payments/complete` marks a pending payment as completed. +- `POST /payments/cancel` marks a pending payment as canceled. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..3437f39 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,14 @@ export const createDatabase = () => { export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +type SendPaymentInput = Omit< + Payment, + "payment_id" | "status" | "created_at" | "completed_at" | "canceled_at" +> + +const terminalStatuses = new Set(["completed", "canceled"]) + +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -21,4 +32,90 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: (input: SendPaymentInput) => { + const state = get() + const existingPaymentId = input.idempotency_key + ? state.paymentIdByIdempotencyKey[input.idempotency_key] + : undefined + const existingPayment = existingPaymentId + ? state.payments.find( + (payment) => payment.payment_id === existingPaymentId, + ) + : undefined + + if (existingPayment) { + return { payment: existingPayment, replayed: true } + } + + const payment: Payment = { + ...input, + payment_id: state.idCounter.toString(), + status: "pending", + created_at: new Date().toISOString(), + } + + set((currentState) => ({ + payments: [...currentState.payments, payment], + paymentIdByIdempotencyKey: input.idempotency_key + ? { + ...currentState.paymentIdByIdempotencyKey, + [input.idempotency_key]: payment.payment_id, + } + : currentState.paymentIdByIdempotencyKey, + idCounter: currentState.idCounter + 1, + })) + + return { payment, replayed: false } + }, + getPayment: (paymentId: string) => { + return ( + get().payments.find((payment) => payment.payment_id === paymentId) ?? null + ) + }, + listPayments: (filters?: { + recipient?: string + status?: PaymentStatus + }) => { + return get().payments.filter((payment) => { + if (filters?.recipient && payment.recipient !== filters.recipient) { + return false + } + if (filters?.status && payment.status !== filters.status) { + return false + } + return true + }) + }, + transitionPayment: ( + paymentId: string, + status: Extract, + ) => { + const payment = get().payments.find( + (candidate) => candidate.payment_id === paymentId, + ) + + if (!payment) { + return { ok: false, error: "payment_not_found" } + } + + if (terminalStatuses.has(payment.status)) { + return { ok: false, payment, error: "payment_already_terminal" } + } + + const timestampField = + status === "completed" ? "completed_at" : "canceled_at" + const updatedPayment: Payment = { + ...payment, + status, + [timestampField]: new Date().toISOString(), + } + + set((currentState) => ({ + payments: currentState.payments.map((candidate) => + candidate.payment_id === paymentId ? updatedPayment : candidate, + ), + })) + + return { ok: true, payment: updatedPayment } + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..b35d81a 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,28 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum(["pending", "completed", "canceled"]) +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(), + status: paymentStatusSchema, + bounty_issue_url: z.string().optional(), + memo: z.string().optional(), + idempotency_key: z.string().optional(), + created_at: z.string(), + completed_at: z.string().optional(), + canceled_at: z.string().optional(), +}) +export type Payment = z.infer + export const databaseSchema = z.object({ idCounter: z.number().default(0), things: z.array(thingSchema).default([]), + payments: z.array(paymentSchema).default([]), + paymentIdByIdempotencyKey: z.record(z.string()).default({}), }) export type DatabaseSchema = z.infer diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..a2b217a --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,22 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const transitionPaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: transitionPaymentBodySchema, + jsonResponse: z.object({ + ok: z.boolean(), + payment: paymentSchema.optional(), + error: z.string().optional(), + }), +})(async (req, ctx) => { + const body = transitionPaymentBodySchema.parse(await req.json()) + const result = ctx.db.transitionPayment(body.payment_id, "canceled") + + return ctx.json(result) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..7642d13 --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,22 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const transitionPaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: transitionPaymentBodySchema, + jsonResponse: z.object({ + ok: z.boolean(), + payment: paymentSchema.optional(), + error: z.string().optional(), + }), +})(async (req, ctx) => { + const body = transitionPaymentBodySchema.parse(await req.json()) + const result = ctx.db.transitionPayment(body.payment_id, "completed") + + return ctx.json(result) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..4d7227e --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,21 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const getPaymentQuerySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})((req, ctx) => { + const url = new URL(req.url) + const query = getPaymentQuerySchema.parse({ + payment_id: url.searchParams.get("payment_id") ?? undefined, + }) + + return ctx.json({ payment: ctx.db.getPayment(query.payment_id) }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..1a6e265 --- /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" + +const listPaymentsQuerySchema = z.object({ + recipient: z.string().optional(), + status: paymentStatusSchema.optional(), +}) + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payments: z.array(paymentSchema), + }), +})((req, ctx) => { + const url = new URL(req.url) + const query = listPaymentsQuerySchema.parse({ + recipient: url.searchParams.get("recipient") ?? undefined, + status: url.searchParams.get("status") ?? undefined, + }) + + return ctx.json({ payments: ctx.db.listPayments(query) }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..0f184c5 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,26 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const sendPaymentBodySchema = z.object({ + recipient: z.string().min(1), + amount_cents: z.number().int().positive(), + currency: z.string().length(3).default("USD"), + bounty_issue_url: z.string().url().optional(), + memo: z.string().optional(), + idempotency_key: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: sendPaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema, + replayed: z.boolean(), + }), +})(async (req, ctx) => { + const body = sendPaymentBodySchema.parse(await req.json()) + const result = ctx.db.sendPayment(body) + + return ctx.json(result) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..8047019 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send, replay, list, get, and complete a payment", async () => { + const { axios } = await getTestServer() + + const firstResponse = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount_cents: 1000, + currency: "USD", + bounty_issue_url: "https://github.com/tscircuit/fake-algora/issues/1", + memo: "fake bounty payout", + idempotency_key: "issue-1-pr-123", + }) + + expect(firstResponse.data.replayed).toBe(false) + expect(firstResponse.data.payment.status).toBe("pending") + expect(typeof firstResponse.data.payment.payment_id).toBe("string") + + const paymentId = firstResponse.data.payment.payment_id + const replayResponse = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount_cents: 1000, + currency: "USD", + idempotency_key: "issue-1-pr-123", + }) + + expect(replayResponse.data.replayed).toBe(true) + expect(replayResponse.data.payment.payment_id).toBe(paymentId) + + const pendingPaymentsResponse = await axios.get( + "/payments/list?status=pending", + ) + expect(pendingPaymentsResponse.data.payments).toHaveLength(1) + + const paymentResponse = await axios.get( + `/payments/get?payment_id=${paymentId}`, + ) + expect(paymentResponse.data.payment.payment_id).toBe(paymentId) + + const completeResponse = await axios.post("/payments/complete", { + payment_id: paymentId, + }) + + expect(completeResponse.data.ok).toBe(true) + expect(completeResponse.data.payment.status).toBe("completed") + expect(typeof completeResponse.data.payment.completed_at).toBe("string") + + const completedPaymentsResponse = await axios.get( + "/payments/list?status=completed", + ) + expect(completedPaymentsResponse.data.payments).toHaveLength(1) +}) + +test("cancel rejects missing and terminal payments safely", async () => { + const { axios } = await getTestServer() + + const missingResponse = await axios.post("/payments/cancel", { + payment_id: "missing-payment", + }) + + expect(missingResponse.data.ok).toBe(false) + expect(missingResponse.data.error).toBe("payment_not_found") + + const { data } = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount_cents: 500, + currency: "USD", + }) + + const paymentId = data.payment.payment_id + const cancelResponse = await axios.post("/payments/cancel", { + payment_id: paymentId, + }) + + expect(cancelResponse.data.ok).toBe(true) + expect(cancelResponse.data.payment.status).toBe("canceled") + + const completeCanceledResponse = await axios.post("/payments/complete", { + payment_id: paymentId, + }) + + expect(completeCanceledResponse.data.ok).toBe(false) + expect(completeCanceledResponse.data.error).toBe("payment_already_terminal") + expect(completeCanceledResponse.data.payment.status).toBe("canceled") +})