From 69d36509cc44cee042618502012997be7e91252e Mon Sep 17 00:00:00 2001 From: Damilola Ogunrotimi <98775983+Fury03@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:24:25 +0000 Subject: [PATCH] feat: add paginated group settlements list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #196 Adds `GET /groups/:id/settlements` with cursor-based pagination and deterministic (createdAt, id) ordering, matching the shared pagination contract used by every other list endpoint. - New route scoped to authenticated group membership - Uses existing pagination primitives (cursorFilter, cursorOrderBy, takeForPage, buildPage) for bounded, consistent queries - Enforces maximum page size server-side - Membership checked before any row is read - 3 contract tests added to shared pagination suite - 5 dedicated settlement-list tests (first page, cursor resume, bounded query, non-member rejection, cursor-scoping) - API contract docs updated 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- docs/api-contract.md | 2 +- src/routes/settlements.ts | 25 ++++++++ tests/pagination-contract.test.ts | 95 +++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/docs/api-contract.md b/docs/api-contract.md index e3ac0b9..7cdfcfc 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -52,7 +52,7 @@ leaks only the existence of an opaque identifier — never any content. ## Pagination -Applies to `GET /groups`, `/groups/:id/expenses`, `/groups/:id/ledger`, +Applies to `GET /groups`, `/groups/:id/expenses`, `/groups/:id/settlements`, `/groups/:id/ledger`, `/groups/:id/treasury/history`, `/anchors/sessions`, and `/history`. Defined in [../src/lib/pagination.ts](../src/lib/pagination.ts). diff --git a/src/routes/settlements.ts b/src/routes/settlements.ts index dbb23a7..4dd0a4b 100644 --- a/src/routes/settlements.ts +++ b/src/routes/settlements.ts @@ -800,6 +800,31 @@ export default async function settlementRoutes(app: FastifyInstance) { }; }); + // -- list settlements for a group ------------------------------------------ + // + // Returns settlements scoped to the caller's group membership, ordered by + // the same deterministic (createdAt, id) pair every other list endpoint + // uses. The cursor carries no membership authority — the groupId filter + // always scopa the query independently. + app.get("/groups/:id/settlements", async (req) => { + const auth = requireUser(req); + const { id: groupId } = idParamSchema.parse(req.params); + const { cursor, limit, order } = paginationQuerySchema.parse(req.query ?? {}); + await requireMembership(groupId, auth.id); + + const position = requireCursor(cursor); + + const settlements = await prisma.settlement.findMany({ + where: { groupId, ...cursorFilter(position, order) }, + include: settlementInclude, + orderBy: cursorOrderBy(order), + take: takeForPage(limit), + }); + + const { items, meta } = buildPage(settlements, limit, order); + return { settlements: items.map(serializeSettlement), meta }; + }); + // -- balances + suggestions ------------------------------------------------- app.get("/groups/:id/balances", async (req) => { const auth = requireUser(req); diff --git a/tests/pagination-contract.test.ts b/tests/pagination-contract.test.ts index ae03505..f64c18d 100644 --- a/tests/pagination-contract.test.ts +++ b/tests/pagination-contract.test.ts @@ -185,6 +185,7 @@ describe("pagination primitives", () => { describe("list endpoints share one pagination contract", () => { const listRoutes: { name: string; url: string; key: string }[] = [ { name: "group expenses", url: `/groups/${GROUP_ID}/expenses`, key: "expenses" }, + { name: "group settlements", url: `/groups/${GROUP_ID}/settlements`, key: "settlements" }, { name: "group ledger", url: `/groups/${GROUP_ID}/ledger`, key: "entries" }, { name: "treasury history", @@ -480,3 +481,97 @@ describe("group ledger pagination", () => { expect(body.meta.hasMore).toBe(false); }); }); + +describe("group settlements pagination", () => { + function tiedSettlements(ids: string[], at = "2026-02-02T00:00:00Z") { + return ids.map((id) => ({ + id, + groupId: GROUP_ID, + fromUserId: USER_ID, + toUserId: "user_2", + createdAt: new Date(at), + amount: "5", + assetCode: "XLM", + assetIssuer: null, + status: "pending", + from: fakeUser(), + to: fakeUser("user_2"), + statusHistory: [], + })); + } + + it("returns the first page and a cursor pointing at its last row", async () => { + const rows = tiedSettlements(["3", "2", "1"], "2026-02-03T00:00:00Z"); + prisma.settlement.findMany.mockResolvedValueOnce(rows as any); + + const res = await app.inject({ + method: "GET", + url: `/groups/${GROUP_ID}/settlements?limit=2`, + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.settlements.map((s: any) => s.id)).toEqual(["3", "2"]); + expect(body.meta.hasMore).toBe(true); + expect(body.meta.nextCursor).toBe(encodeCursor(rows[1].createdAt, "2")); + }); + + it("resumes strictly after the cursor, breaking ties on id", async () => { + const at = new Date("2026-02-02T00:00:00Z"); + const cursor = encodeCursor(at, "2"); + + await app.inject({ + method: "GET", + url: `/groups/${GROUP_ID}/settlements?limit=2&cursor=${cursor}`, + headers: authHeader(), + }); + + const args = prisma.settlement.findMany.mock.calls[0][0] as any; + expect(args.where.OR).toEqual([ + { createdAt: { lt: at } }, + { createdAt: at, id: { lt: "2" } }, + ]); + expect(args.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]); + }); + + it("keeps the query bounded rather than loading the result set", async () => { + await app.inject({ + method: "GET", + url: `/groups/${GROUP_ID}/settlements?limit=25`, + headers: authHeader(), + }); + + const args = prisma.settlement.findMany.mock.calls[0][0] as any; + expect(args.take).toBe(26); + expect(args.skip).toBeUndefined(); + }); + + it("rejects a non-member even when they present a valid-looking cursor", async () => { + prisma.groupMember.findUnique.mockResolvedValueOnce(null as any); + prisma.group.findUnique.mockResolvedValueOnce({ id: GROUP_ID } as any); + + const res = await app.inject({ + method: "GET", + url: `/groups/${GROUP_ID}/settlements`, + headers: authHeader("outsider"), + }); + + expect(res.statusCode).toBe(403); + expect(prisma.settlement.findMany).not.toHaveBeenCalled(); + }); + + it("scopes the query to the group, not the cursor", async () => { + const at = new Date("2026-02-02T00:00:00Z"); + const foreignCursor = encodeCursor(at, "row_from_another_group"); + + await app.inject({ + method: "GET", + url: `/groups/${GROUP_ID}/settlements?cursor=${foreignCursor}`, + headers: authHeader(), + }); + + const args = prisma.settlement.findMany.mock.calls[0][0] as any; + expect(args.where.groupId).toBe(GROUP_ID); + }); +});