Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
105 changes: 105 additions & 0 deletions src/__tests__/payout.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
18 changes: 14 additions & 4 deletions src/services/payout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
const group = await prisma.savingsGroup.findUnique({
Expand All @@ -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 },
Expand Down