From 008016b947abd2874da1eef1a7ee8d684eae6db2 Mon Sep 17 00:00:00 2001 From: Femi <114834028+Toyosi5566@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:32:36 +0100 Subject: [PATCH 1/2] security: ignore local environment files .env is read by dotenv.config() at boot and holds ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY and DEPLOYER_SECRET_KEY. None of the .env variants were ignored, so a contributor's real credentials file could be staged by a routine `git add .` with nothing to warn anyone. Ignore the .env variants and keep the committed example file trackable. No .env file is tracked at HEAD and no .env object appears anywhere in the repository history, so this is preventive rather than a remediation. --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index bea4619..ac0c2db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,12 @@ coverage/ *.lcov coverage-final.json clover.xml + +# Local environment files. .env is loaded by dotenv at boot and holds live +# secrets (ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY, +# DEPLOYER_SECRET_KEY). The committed example file is the only environment +# file that belongs in git. +.env +.env.local +.env.*.local +!.env.example From 816e59041d35e09b43bb13c3cad04ceb43cf541f Mon Sep 17 00:00:00 2001 From: Oladipupo dev Date: Sat, 17 Oct 2026 15:52:36 +0100 Subject: [PATCH 2/2] fix: fix(payout): keep the rotation index correct when an earlier (#266) --- src/__tests__/payout.service.test.ts | 105 +++++++++++++++++++++++++++ src/services/payout.service.ts | 18 ++++- 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/__tests__/payout.service.test.ts b/src/__tests__/payout.service.test.ts index 14467f0..0b41a8f 100644 --- a/src/__tests__/payout.service.test.ts +++ b/src/__tests__/payout.service.test.ts @@ -527,6 +527,111 @@ describe('PayoutService', () => { }); }); + it('keeps the next member due when an earlier member leaves (departure before cursor)', async () => { + const group = { + id: 'g1', + payoutOrder: ['u1', 'u2', 'u3'], + currentPayoutIndex: 1, + members: [makeMember('u1', 'CREATOR'), makeMember('u2', 'MEMBER'), makeMember('u3', 'MEMBER')], + }; + prisma.savingsGroup.findUnique.mockResolvedValueOnce(group); + + const result = await payoutService.removeMemberFromOrder('g1', 'u1'); + + expect(result).toEqual(['u2', 'u3']); + // u2 was due at index 1; after u1 leaves, u2 is at index 0 and must stay due. + expect(prisma.savingsGroup.update).toHaveBeenCalledWith({ + where: { id: 'g1' }, + data: { payoutOrder: ['u2', 'u3'], currentPayoutIndex: 0 }, + }); + }); + + it('leaves the index pointing at the same member when the departure is after the cursor', async () => { + const group = { + id: 'g1', + payoutOrder: ['u1', 'u2', 'u3'], + currentPayoutIndex: 1, + members: [makeMember('u1', 'CREATOR'), makeMember('u2', 'MEMBER'), makeMember('u3', 'MEMBER')], + }; + prisma.savingsGroup.findUnique.mockResolvedValueOnce(group); + + const result = await payoutService.removeMemberFromOrder('g1', 'u3'); + + expect(result).toEqual(['u1', 'u2']); + // u2 was due at index 1 and remains at index 1. + expect(prisma.savingsGroup.update).toHaveBeenCalledWith({ + where: { id: 'g1' }, + data: { payoutOrder: ['u1', 'u2'], currentPayoutIndex: 1 }, + }); + }); + + it('keeps the index valid when the departing member is exactly at the cursor', async () => { + const group = { + id: 'g1', + payoutOrder: ['u1', 'u2', 'u3'], + currentPayoutIndex: 1, + members: [makeMember('u1', 'CREATOR'), makeMember('u2', 'MEMBER'), makeMember('u3', 'MEMBER')], + }; + prisma.savingsGroup.findUnique.mockResolvedValueOnce(group); + + const result = await payoutService.removeMemberFromOrder('g1', 'u2'); + + expect(result).toEqual(['u1', 'u3']); + // The member who was next (u3) shifts into index 1 and must remain due. + expect(prisma.savingsGroup.update).toHaveBeenCalledWith({ + where: { id: 'g1' }, + data: { payoutOrder: ['u1', 'u3'], currentPayoutIndex: 1 }, + }); + }); + + it('pays every remaining member exactly once across a rotation with a departure in each position', async () => { + const memberIds = ['u1', 'u2', 'u3', 'u4']; + const members = memberIds.map((id, i) => makeMember(id, i === 0 ? 'CREATOR' : 'MEMBER')); + + let state = { + id: 'g1', + name: 'Ajo Circle', + contributionAmount: 10, + currentPayoutIndex: 0, + totalCycles: 0, + payoutOrder: [...memberIds], + members, + }; + + prisma.savingsGroup.update.mockImplementation(({ data }: any) => { + state = { ...state, ...data }; + return Promise.resolve(state); + }); + + const paid: string[] = []; + prisma.payout.create.mockImplementation(({ data }: any) => { + paid.push(data.recipientId); + return Promise.resolve({ id: `payout-${paid.length}`, recipientId: data.recipientId }); + }); + + // Departure before the cursor: u1 leaves while u2 is due. + state.currentPayoutIndex = 1; + prisma.savingsGroup.findUnique.mockResolvedValueOnce({ ...state, members }); + await payoutService.removeMemberFromOrder('g1', 'u1'); + + // Departure at the cursor: u2 (now due) leaves. + prisma.savingsGroup.findUnique.mockResolvedValueOnce({ ...state, members }); + await payoutService.removeMemberFromOrder('g1', 'u2'); + + // Departure after the cursor: u4 leaves while u3 is due. + prisma.savingsGroup.findUnique.mockResolvedValueOnce({ ...state, members }); + await payoutService.removeMemberFromOrder('g1', 'u4'); + + // Only u3 remains; the rotation must pay them exactly once. + prisma.savingsGroup.findUnique.mockResolvedValueOnce({ ...state, members }); + prisma.payout.findFirst.mockResolvedValueOnce(null); + await payoutService.executePayout('g1'); + + expect(paid).toEqual(['u3']); + expect(mockSorobanPayout).toHaveBeenCalledTimes(1); + expect(mockSorobanPayout).toHaveBeenCalledWith('TEST_TREASURY_SECRET', 'PUB_u3', '10'); + }); + it('materializes the default join order before removing when payoutOrder is null', async () => { const group = { id: 'g1', diff --git a/src/services/payout.service.ts b/src/services/payout.service.ts index 7392b32..d36cdad 100644 --- a/src/services/payout.service.ts +++ b/src/services/payout.service.ts @@ -279,8 +279,8 @@ export class PayoutService { /** * Removes an exited member from the payout rotation, materializing the * default join order into an explicit payoutOrder first if none was set. - * The current payout index is clamped so it still points at a valid - * (or the last) position in the shortened order. + * The current payout index is shifted to account for the departing member's + * position so the member due next is unchanged. */ public async removeMemberFromOrder(groupId: string, exitedUserId: string): Promise { const group = await prisma.savingsGroup.findUnique({ @@ -292,9 +292,19 @@ export class PayoutService { const currentOrder = this.resolveOrder(group); if (!currentOrder.includes(exitedUserId)) return currentOrder; - const newOrder = currentOrder.filter((id) => id !== exitedUserId); const currentIndex = (group as any).currentPayoutIndex ?? 0; - const newIndex = newOrder.length === 0 ? 0 : Math.min(currentIndex, newOrder.length - 1); + const departedIndex = currentOrder.indexOf(exitedUserId); + const newOrder = currentOrder.filter((id) => id !== exitedUserId); + + // Removing an element before the cursor shifts every later position down + // by one, so the index must shift with them. Removing at or after the + // cursor leaves the member due next unchanged. Clamp only to keep the + // index within bounds of the shortened order. + let newIndex = currentIndex; + if (departedIndex < currentIndex) { + newIndex = currentIndex - 1; + } + newIndex = newOrder.length === 0 ? 0 : Math.min(newIndex, newOrder.length - 1); await prisma.savingsGroup.update({ where: { id: groupId },