mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(costs): rework the cost panel UX wise and apply prettier on the shared package
This commit is contained in:
@@ -94,6 +94,31 @@ export class BudgetController {
|
||||
return { settlement };
|
||||
}
|
||||
|
||||
@Put('settlements/:settlementId')
|
||||
updateSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('settlementId') settlementId: string,
|
||||
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
|
||||
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
|
||||
}
|
||||
const settlement = this.budget.updateSettlement(settlementId, tripId, {
|
||||
from_user_id: body.from_user_id,
|
||||
to_user_id: body.to_user_id,
|
||||
amount: body.amount,
|
||||
});
|
||||
if (!settlement) {
|
||||
throw new HttpException({ error: 'Settlement not found' }, 404);
|
||||
}
|
||||
this.budget.broadcast(tripId, 'budget:settlement-updated', { settlement }, socketId);
|
||||
return { settlement };
|
||||
}
|
||||
|
||||
@Delete('settlements/:settlementId')
|
||||
deleteSettlement(
|
||||
@CurrentUser() user: User,
|
||||
|
||||
@@ -73,6 +73,10 @@ export class BudgetService {
|
||||
return svc.createSettlement(tripId, data, userId);
|
||||
}
|
||||
|
||||
updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }) {
|
||||
return svc.updateSettlement(id, tripId, data);
|
||||
}
|
||||
|
||||
deleteSettlement(id: string, tripId: string): boolean {
|
||||
return svc.deleteSettlement(id, tripId);
|
||||
}
|
||||
|
||||
@@ -385,11 +385,18 @@ export function calculateSettlement(
|
||||
}
|
||||
|
||||
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
||||
// the receiver's credit shrinks, so the corresponding flow disappears.
|
||||
// the receiver's credit shrinks, so the corresponding flow disappears. A transfer
|
||||
// counts even when neither user has an expense-derived balance yet — a manual
|
||||
// payment, or one left behind after its expense was deleted, then correctly
|
||||
// surfaces as an amount still to square up instead of silently vanishing.
|
||||
const settlements = listSettlements(tripId);
|
||||
const ensureSettled = (id: number, username: string | undefined, avatar_url: string | null | undefined) => {
|
||||
if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, balance: 0 };
|
||||
return balances[id];
|
||||
};
|
||||
for (const s of settlements) {
|
||||
if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
|
||||
if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += s.amount;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= s.amount;
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
@@ -461,6 +468,19 @@ export function createSettlement(
|
||||
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
||||
}
|
||||
|
||||
export function updateSettlement(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number },
|
||||
) {
|
||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!row) return null;
|
||||
db.prepare(
|
||||
'UPDATE budget_settlements SET from_user_id = ?, to_user_id = ?, amount = ? WHERE id = ?'
|
||||
).run(data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, id);
|
||||
return listSettlements(tripId).find(s => s.id === Number(id)) || null;
|
||||
}
|
||||
|
||||
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
|
||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!row) return false;
|
||||
|
||||
@@ -31,6 +31,7 @@ const { svc } = vi.hoisted(() => ({
|
||||
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
|
||||
setItemPayers: vi.fn(), listSettlements: vi.fn(), createSettlement: vi.fn(), updateSettlement: vi.fn(), deleteSettlement: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/budgetService', () => svc);
|
||||
@@ -104,4 +105,18 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'user_ids must be an array' });
|
||||
});
|
||||
|
||||
it('200 on settlement update with permission', async () => {
|
||||
svc.updateSettlement.mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
||||
});
|
||||
|
||||
it('404 on settlement update when it does not exist', async () => {
|
||||
svc.updateSettlement.mockReturnValue(null);
|
||||
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Settlement not found' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,37 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 403 without budget_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 400 when a field is missing', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 404 when missing', () => {
|
||||
const svc = makeService({ updateSettlement: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 404, body: { error: 'Settlement not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id updates and broadcasts', () => {
|
||||
const updateSettlement = vi.fn().mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateSettlement, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15 }, 'sock');
|
||||
expect(res).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
||||
expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-updated', { settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ const mockDb = vi.hoisted(() => {
|
||||
|
||||
vi.mock('../../../src/db/database', () => mockDb);
|
||||
|
||||
import { calculateSettlement } from '../../../src/services/budgetService';
|
||||
import { calculateSettlement, updateSettlement } from '../../../src/services/budgetService';
|
||||
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -189,4 +189,60 @@ describe('calculateSettlement', () => {
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].amount).toBe(20);
|
||||
});
|
||||
|
||||
it('counts a settlement with no matching expense as an amount still to square up', () => {
|
||||
// bob paid alice 30 but every expense behind it was deleted: alice now owes bob.
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('FROM budget_settlements')) {
|
||||
return { all: vi.fn(() => [
|
||||
{ id: 1, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, from_username: 'bob', to_username: 'alice', from_avatar: null, to_avatar: null },
|
||||
]), get: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
||||
});
|
||||
const result = calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(bob.balance).toBe(30);
|
||||
expect(alice.balance).toBe(-30);
|
||||
expect(result.flows).toEqual([
|
||||
expect.objectContaining({ amount: 30, from: expect.objectContaining({ user_id: 1 }), to: expect.objectContaining({ user_id: 2 }) }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateSettlement ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateSettlement', () => {
|
||||
it('returns null when the settlement is not in the trip', () => {
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT id FROM budget_settlements')) {
|
||||
return { get: vi.fn(() => undefined), all: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
expect(updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull();
|
||||
});
|
||||
|
||||
it('updates the row (rounded to cents) and returns the refreshed settlement', () => {
|
||||
const run = vi.fn();
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT id FROM budget_settlements')) {
|
||||
return { get: vi.fn(() => ({ id: 7 })), all: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
if (sql.includes('UPDATE budget_settlements')) {
|
||||
return { get: vi.fn(), all: vi.fn(), run };
|
||||
}
|
||||
if (sql.includes('FROM budget_settlements')) {
|
||||
return { get: vi.fn(), all: vi.fn(() => [
|
||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 10.13, from_username: 'bob', to_username: 'alice', from_avatar: null, to_avatar: null },
|
||||
]), run: vi.fn() };
|
||||
}
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
|
||||
const res = updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
|
||||
expect(run).toHaveBeenCalledWith(2, 1, 10.13, 7);
|
||||
expect(res).toMatchObject({ id: 7, from_user_id: 2, to_user_id: 1, amount: 10.13 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user