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 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 },