From 8d2039faa6f5578e547c58e5e57c5b2da22a5c05 Mon Sep 17 00:00:00 2001 From: jynbil1 <117147271+jynbil1@users.noreply.github.com> Date: Wed, 13 May 2026 15:35:45 +0900 Subject: [PATCH 1/3] Add fake payment sending API --- lib/db/db-client.ts | 35 +++++++++++++++- lib/db/schema.ts | 17 ++++++++ routes/payments/get.ts | 16 ++++++++ routes/payments/list.ts | 12 ++++++ routes/payments/send.ts | 22 ++++++++++ tests/routes/payments/send.test.ts | 65 ++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 2 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 tests/routes/payments/send.test.ts diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..92aec0e 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -2,7 +2,7 @@ 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 Thing } from "./schema.ts" import { combine } from "zustand/middleware" export const createDatabase = () => { @@ -11,7 +11,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 +21,35 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: ( + payment: Omit, + ) => { + const existingPayment = + payment.idempotency_key && + get().payments.find( + (item) => item.idempotency_key === payment.idempotency_key, + ) + + if (existingPayment) return existingPayment + + const timestamp = new Date().toISOString() + const paymentToCreate: Payment = { + ...payment, + currency: payment.currency.toUpperCase(), + payment_id: get().paymentIdCounter.toString(), + status: "sent", + created_at: timestamp, + updated_at: timestamp, + } + + set((state) => ({ + payments: [...state.payments, paymentToCreate], + paymentIdCounter: state.paymentIdCounter + 1, + })) + + return paymentToCreate + }, + getPayment: (payment_id: string) => { + return get().payments.find((payment) => payment.payment_id === payment_id) + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..e278a03 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,25 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum(["sent", "completed", "canceled"]) + +export const paymentSchema = z.object({ + payment_id: z.string(), + recipient: z.string(), + amount_cents: z.number().int().positive(), + currency: z.string(), + memo: z.string().optional(), + idempotency_key: z.string().optional(), + status: paymentStatusSchema, + 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/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..58aed61 --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,16 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentSchema } from "lib/db/schema" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + queryParams: z.object({ + payment_id: z.string(), + }), + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})((req, ctx) => { + const payment_id = new URL(req.url).searchParams.get("payment_id") ?? "" + return ctx.json({ payment: ctx.db.getPayment(payment_id) ?? null }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..1b9f3bd --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,12 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentSchema } from "lib/db/schema" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payments: z.array(paymentSchema), + }), +})((req, ctx) => { + return ctx.json({ payments: ctx.db.payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..4bf8879 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,22 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { paymentSchema } from "lib/db/schema" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + recipient: z.string().min(1), + amount_cents: z.number().int().positive(), + currency: z.string().length(3).default("USD"), + memo: z.string().optional(), + idempotency_key: z.string().optional(), + }), + jsonResponse: z.object({ + payment: paymentSchema, + }), +})(async (req, ctx) => { + const paymentRequest = await req.json() + const payment = ctx.db.sendPayment(paymentRequest) + + 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..d7cad3c --- /dev/null +++ b/tests/routes/payments/send.test.ts @@ -0,0 +1,65 @@ +import { getTestServer } from "tests/fixtures/get-test-server" +import { test, expect } from "bun:test" + +test("send a payment", async () => { + const { axios } = await getTestServer() + + const { data } = await axios.post("/payments/send", { + recipient: "github:user", + amount_cents: 500, + currency: "usd", + memo: "Test bounty payout", + }) + + expect(data.payment).toMatchObject({ + payment_id: "0", + recipient: "github:user", + amount_cents: 500, + currency: "USD", + memo: "Test bounty payout", + status: "sent", + }) + + const listResponse = await axios.get("/payments/list") + expect(listResponse.data.payments).toHaveLength(1) +}) + +test("deduplicates payments by idempotency key", async () => { + const { axios } = await getTestServer() + + const payload = { + recipient: "github:user", + amount_cents: 1000, + currency: "USD", + idempotency_key: "github-issue-1", + } + + const firstResponse = await axios.post("/payments/send", payload) + const secondResponse = await axios.post("/payments/send", payload) + + expect(secondResponse.data.payment.payment_id).toBe( + firstResponse.data.payment.payment_id, + ) + + const listResponse = await axios.get("/payments/list") + expect(listResponse.data.payments).toHaveLength(1) +}) + +test("get a payment by id", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient: "github:user", + amount_cents: 750, + currency: "USD", + }) + + const { data } = await axios.get("/payments/get", { + params: { payment_id: "0" }, + }) + + expect(data.payment).toMatchObject({ + payment_id: "0", + amount_cents: 750, + }) +}) From cc06f00b879799544c76c4090e169c7e05c37217 Mon Sep 17 00:00:00 2001 From: jynbil1 <117147271+jynbil1@users.noreply.github.com> Date: Wed, 13 May 2026 16:12:21 +0900 Subject: [PATCH 2/3] fix: default payment currency in send route --- routes/payments/send.ts | 5 ++++- tests/routes/payments/send.test.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/routes/payments/send.ts b/routes/payments/send.ts index 4bf8879..3b5290c 100644 --- a/routes/payments/send.ts +++ b/routes/payments/send.ts @@ -16,7 +16,10 @@ export default withRouteSpec({ }), })(async (req, ctx) => { const paymentRequest = await req.json() - const payment = ctx.db.sendPayment(paymentRequest) + const payment = ctx.db.sendPayment({ + ...paymentRequest, + currency: paymentRequest.currency ?? "USD", + }) return ctx.json({ payment }) }) diff --git a/tests/routes/payments/send.test.ts b/tests/routes/payments/send.test.ts index d7cad3c..728a514 100644 --- a/tests/routes/payments/send.test.ts +++ b/tests/routes/payments/send.test.ts @@ -45,6 +45,17 @@ test("deduplicates payments by idempotency key", async () => { expect(listResponse.data.payments).toHaveLength(1) }) +test("defaults payment currency to USD", async () => { + const { axios } = await getTestServer() + + const { data } = await axios.post("/payments/send", { + recipient: "github:user", + amount_cents: 500, + }) + + expect(data.payment.currency).toBe("USD") +}) + test("get a payment by id", async () => { const { axios } = await getTestServer() From 2c120518876fce47eb0ddfc3f1165f05051cbc42 Mon Sep 17 00:00:00 2001 From: jynbil1 Date: Wed, 13 May 2026 23:41:45 +0900 Subject: [PATCH 3/3] feat: add fake payment status updates --- README.md | 52 +++++++++++++++++++++++++++--- lib/db/db-client.ts | 36 ++++++++++++++++++--- lib/db/schema.ts | 8 ++++- routes/payments/update-status.ts | 19 +++++++++++ tests/routes/payments/send.test.ts | 38 +++++++++++++++++++++- 5 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 routes/payments/update-status.ts diff --git a/README.md b/README.md index 824427a..77b6842 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,48 @@ -# Template API Project +# Fake Algora API -This is a template project with best-practice modules: -- Winterspec for defining the API -- bun testing -- Zustand store with zod definition for database state +Small Winterspec API used to simulate Algora-style payments in tests and +development. + +## Payments + +### Send a payment + +`POST /payments/send` + +```json +{ + "recipient": "github:octocat", + "amount_cents": 500, + "currency": "USD", + "memo": "Bounty payout", + "idempotency_key": "issue-123" +} +``` + +Returns the created payment. If an `idempotency_key` has already been used, the +existing payment is returned instead of creating a duplicate. + +### List payments + +`GET /payments/list` + +Returns all fake payments currently stored in memory. + +### Get a payment + +`GET /payments/get?payment_id=0` + +Returns a single payment or `null` if it does not exist. + +### Update payment status + +`POST /payments/update-status` + +```json +{ + "payment_id": "0", + "status": "completed" +} +``` + +Valid statuses are `sent`, `completed`, `canceled`, and `failed`. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index 92aec0e..2f4e4d4 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 { type HoistedStoreApi, hoist } from "zustand-hoist" import { immer } from "zustand/middleware/immer" -import { hoist, type HoistedStoreApi } from "zustand-hoist" +import { type StoreApi, createStore } from "zustand/vanilla" -import { databaseSchema, type DatabaseSchema, type Payment, 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)) @@ -22,7 +28,10 @@ const initializer = combine(databaseSchema.parse({}), (set, get) => ({ })) }, sendPayment: ( - payment: Omit, + payment: Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" + >, ) => { const existingPayment = payment.idempotency_key && @@ -52,4 +61,23 @@ const initializer = combine(databaseSchema.parse({}), (set, get) => ({ getPayment: (payment_id: string) => { return get().payments.find((payment) => payment.payment_id === payment_id) }, + updatePaymentStatus: (payment_id: string, status: PaymentStatus) => { + const timestamp = 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: timestamp, + } + return updatedPayment + }), + })) + + return updatedPayment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index e278a03..b3aa739 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,7 +9,13 @@ export const thingSchema = z.object({ }) export type Thing = z.infer -export const paymentStatusSchema = z.enum(["sent", "completed", "canceled"]) +export const paymentStatusSchema = z.enum([ + "sent", + "completed", + "canceled", + "failed", +]) +export type PaymentStatus = z.infer export const paymentSchema = z.object({ payment_id: z.string(), diff --git a/routes/payments/update-status.ts b/routes/payments/update-status.ts new file mode 100644 index 0000000..97c6e2b --- /dev/null +++ b/routes/payments/update-status.ts @@ -0,0 +1,19 @@ +import { paymentSchema, paymentStatusSchema } 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(), + status: paymentStatusSchema, + }), + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id, status } = await req.json() + return ctx.json({ + payment: ctx.db.updatePaymentStatus(payment_id, status) ?? null, + }) +}) diff --git a/tests/routes/payments/send.test.ts b/tests/routes/payments/send.test.ts index 728a514..11b1920 100644 --- a/tests/routes/payments/send.test.ts +++ b/tests/routes/payments/send.test.ts @@ -1,5 +1,5 @@ +import { expect, test } from "bun:test" import { getTestServer } from "tests/fixtures/get-test-server" -import { test, expect } from "bun:test" test("send a payment", async () => { const { axios } = await getTestServer() @@ -74,3 +74,39 @@ test("get a payment by id", async () => { amount_cents: 750, }) }) + +test("updates a payment status", async () => { + const { axios } = await getTestServer() + + await axios.post("/payments/send", { + recipient: "github:user", + amount_cents: 500, + currency: "USD", + }) + + const { data } = await axios.post("/payments/update-status", { + payment_id: "0", + status: "completed", + }) + + expect(data.payment).toMatchObject({ + payment_id: "0", + status: "completed", + }) + + const getResponse = await axios.get("/payments/get", { + params: { payment_id: "0" }, + }) + expect(getResponse.data.payment.status).toBe("completed") +}) + +test("returns null when updating a missing payment", async () => { + const { axios } = await getTestServer() + + const { data } = await axios.post("/payments/update-status", { + payment_id: "missing", + status: "failed", + }) + + expect(data.payment).toBeNull() +})