mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(budget): make a settle-up payment's date editable
The settle-up form only exposed from/to/amount and silently dated the payment by created_at, unlike a regular expense which has an editable date. Add a settled_at column (independent of created_at, same split as expense_date) through the schema, migration, service, REST and MCP surfaces, and the desktop + mobile forms; the ledger groups a payment by settled_at, falling back to created_at for rows recorded before the column existed.
This commit is contained in:
@@ -4576,6 +4576,20 @@ function runMigrations(db: Database.Database): void {
|
||||
const inherit = db.prepare('INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL');
|
||||
for (const key of ROADTRIP_PREFERENCE_KEYS) inherit.run(key);
|
||||
},
|
||||
|
||||
// A settle-up payment's date was silently `created_at` (when it was recorded),
|
||||
// not editable like a regular expense's `expense_date`. Add the same split:
|
||||
// settled_at is the calendar day the transfer actually happened, independent
|
||||
// of when someone got around to logging it. NULL on legacy rows and rows
|
||||
// whose caller didn't set it; the read side falls back to created_at's date.
|
||||
//
|
||||
// Appended LAST: the array is index-addressed against schema_version.
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('budget_settlements')").all() as Array<{ name: string }>;
|
||||
if (!cols.some(c => c.name === 'settled_at')) {
|
||||
db.exec('ALTER TABLE budget_settlements ADD COLUMN settled_at TEXT');
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -91,7 +91,7 @@ export class BudgetController {
|
||||
) {
|
||||
const settlement = await this.budget.createSettlement(
|
||||
tripId,
|
||||
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency },
|
||||
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency, settled_at: body.settled_at },
|
||||
user.id,
|
||||
);
|
||||
// A party who is not on this trip gets the same answer as a settlement that
|
||||
@@ -117,6 +117,7 @@ export class BudgetController {
|
||||
to_user_id: body.to_user_id,
|
||||
amount: body.amount,
|
||||
currency: body.currency,
|
||||
settled_at: body.settled_at,
|
||||
});
|
||||
if (!settlement) {
|
||||
throw new HttpException({ error: 'Settlement not found' }, 404);
|
||||
|
||||
@@ -433,13 +433,14 @@ export class BudgetMcp {
|
||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
||||
amount: z.number().positive().describe('Amount paid, in `currency`'),
|
||||
currency: z.string().max(10).nullable().optional().describe("ISO currency code the payment was made in (e.g. \"USD\"); defaults to the trip currency. Its FX rate is frozen now, so the transfer keeps cancelling its expense when live rates drift."),
|
||||
settled_at: z.string().max(40).nullable().optional().describe('Date the payment actually happened, YYYY-MM-DD; defaults to today when omitted.'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async createSettlement(
|
||||
{ tripId, from_user_id, to_user_id, amount, currency }: { tripId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null },
|
||||
{ tripId, from_user_id, to_user_id, amount, currency, settled_at }: { tripId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
@@ -447,7 +448,7 @@ export class BudgetMcp {
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
// Freeze-then-write composite, same as the REST path: the rate for the display
|
||||
// currency is frozen at entry time (#1445).
|
||||
const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency }, ctx.userId);
|
||||
const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency, settled_at }, ctx.userId);
|
||||
if (!settlement) return errorResult('Settlement not found.');
|
||||
this.guards.safeBroadcast(tripId, 'budget:settlement-created', { settlement });
|
||||
return ok({ settlement });
|
||||
@@ -463,13 +464,14 @@ export class BudgetMcp {
|
||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
||||
amount: z.number().positive().describe('Amount paid, in `currency`'),
|
||||
currency: z.string().max(10).nullable().optional().describe('ISO currency code the payment was made in (e.g. "USD"); null puts it back in the trip currency. Omit to leave it as recorded, which also keeps the rate frozen at settle time.'),
|
||||
settled_at: z.string().max(40).nullable().optional().describe('Date the payment actually happened, YYYY-MM-DD. Omit to leave it as recorded.'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async updateSettlement(
|
||||
{ tripId, settlementId, from_user_id, to_user_id, amount, currency }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null },
|
||||
{ tripId, settlementId, from_user_id, to_user_id, amount, currency, settled_at }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
@@ -477,7 +479,7 @@ export class BudgetMcp {
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
// Freeze-then-write composite, same as the REST path: an edit that leaves the
|
||||
// currency alone keeps the rate frozen at settle time.
|
||||
const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency });
|
||||
const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency, settled_at });
|
||||
if (!settlement) return errorResult('Settlement not found.');
|
||||
this.guards.safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
|
||||
return ok({ settlement });
|
||||
|
||||
@@ -12,7 +12,7 @@ type Trip = TripAccess;
|
||||
type SettlementRow = {
|
||||
id: number; trip_id: string; from_user_id: number; to_user_id: number;
|
||||
amount: number; currency: string | null; exchange_rate: number | null;
|
||||
created_at: string; created_by_user_id: number | null;
|
||||
created_at: string; settled_at: string | null; created_by_user_id: number | null;
|
||||
from_username: string; from_avatar: string | null;
|
||||
to_username: string; to_avatar: string | null;
|
||||
};
|
||||
@@ -1052,7 +1052,7 @@ export class BudgetService {
|
||||
// Settlement usernames use COALESCE(display_name, username) like every item
|
||||
// query (the legacy raw fu.username was the odd one out).
|
||||
private static readonly SETTLEMENT_SELECT = `
|
||||
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.created_by_user_id,
|
||||
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.settled_at, s.created_by_user_id,
|
||||
COALESCE(fu.display_name, fu.username) AS from_username, fu.avatar AS from_avatar,
|
||||
COALESCE(tu.display_name, tu.username) AS to_username, tu.avatar AS to_avatar
|
||||
FROM budget_settlements s
|
||||
@@ -1065,7 +1065,7 @@ export class BudgetService {
|
||||
id: r.id, trip_id: r.trip_id,
|
||||
from_user_id: r.from_user_id, to_user_id: r.to_user_id,
|
||||
amount: r.amount, currency: r.currency ?? null, exchange_rate: r.exchange_rate ?? 1,
|
||||
created_at: r.created_at, created_by_user_id: r.created_by_user_id,
|
||||
created_at: r.created_at, settled_at: r.settled_at ?? null, created_by_user_id: r.created_by_user_id,
|
||||
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
|
||||
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
|
||||
};
|
||||
@@ -1092,14 +1092,15 @@ export class BudgetService {
|
||||
/** Raw settlement insert (no FX freeze) — the REST path wraps it in createSettlement. */
|
||||
insertSettlement(
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null },
|
||||
createdByUserId?: number,
|
||||
) {
|
||||
const result = this.db.run(
|
||||
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, settled_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
|
||||
data.currency ? data.currency.toUpperCase() : null,
|
||||
data.exchange_rate != null ? data.exchange_rate : 1,
|
||||
data.settled_at || null,
|
||||
createdByUserId ?? null,
|
||||
);
|
||||
return this.getSettlement(Number(result.lastInsertRowid), tripId);
|
||||
@@ -1109,7 +1110,7 @@ export class BudgetService {
|
||||
applySettlementUpdate(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null },
|
||||
) {
|
||||
const row = this.db.get('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!row) return null;
|
||||
@@ -1117,12 +1118,14 @@ export class BudgetService {
|
||||
UPDATE budget_settlements SET
|
||||
from_user_id = ?, to_user_id = ?, amount = ?,
|
||||
currency = CASE WHEN ? THEN ? ELSE currency END,
|
||||
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END
|
||||
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END,
|
||||
settled_at = CASE WHEN ? THEN ? ELSE settled_at END
|
||||
WHERE id = ?
|
||||
`,
|
||||
data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
|
||||
data.currency !== undefined ? 1 : 0, data.currency ? data.currency.toUpperCase() : null,
|
||||
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
|
||||
data.settled_at !== undefined ? 1 : 0, data.settled_at || null,
|
||||
id,
|
||||
);
|
||||
return this.getSettlement(id, tripId);
|
||||
@@ -1184,7 +1187,7 @@ export class BudgetService {
|
||||
return roster.has(data.from_user_id) && roster.has(data.to_user_id);
|
||||
}
|
||||
|
||||
async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }, userId: number) {
|
||||
async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, userId: number) {
|
||||
if (!this.settlementPartiesOnTrip(tripId, data)) return null;
|
||||
// Freeze the FX rate for the display currency the amount was entered in so the
|
||||
// transfer keeps cancelling its expense when live rates drift (#1445).
|
||||
@@ -1192,7 +1195,7 @@ export class BudgetService {
|
||||
return this.insertSettlement(tripId, data, userId);
|
||||
}
|
||||
|
||||
async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }) {
|
||||
async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }) {
|
||||
// Pass the settlement's stored currency so an edit that doesn't change it keeps
|
||||
// the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a
|
||||
// settled position on an unrelated edit.
|
||||
|
||||
@@ -82,13 +82,13 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
// produced by the global ZodValidationPipe (budget.dto.ts) before the handler
|
||||
// runs — covered by the integration suite, not constructible here.
|
||||
|
||||
it('POST /settlements creates and broadcasts (amount 0 is allowed), forwarding the display currency', async () => {
|
||||
it('POST /settlements creates and broadcasts (amount 0 is allowed), forwarding the display currency and the settled day', async () => {
|
||||
const createSettlement = vi.fn().mockResolvedValue({ id: 3, amount: 0 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ createSettlement, broadcast } as Partial<BudgetService>);
|
||||
const res = await new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD' }, 'sock');
|
||||
const res = await new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD', settled_at: '2026-01-05' }, 'sock');
|
||||
expect(res).toEqual({ settlement: { id: 3, amount: 0 } });
|
||||
expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD' }, user.id);
|
||||
expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD', settled_at: '2026-01-05' }, user.id);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock');
|
||||
});
|
||||
|
||||
@@ -114,13 +114,13 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id updates and broadcasts, forwarding the display currency', async () => {
|
||||
it('PUT /settlements/:id updates and broadcasts, forwarding the display currency and the settled day', async () => {
|
||||
const updateSettlement = vi.fn().mockResolvedValue({ 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 = await new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD' }, 'sock');
|
||||
const res = await new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD', settled_at: '2026-01-06' }, '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, currency: 'USD' });
|
||||
expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD', settled_at: '2026-01-06' });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-updated', { settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -769,6 +769,27 @@ describe('post-fold quirk fixes', () => {
|
||||
expect(budget.listSettlements(trip.id)[0].from_username).toBe('Alice Displayed');
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-042: a settle-up payment carries its own settled_at, independent of created_at', () => {
|
||||
const { user: alice } = createUser(testDb, { username: 'alice' });
|
||||
const { user: bob } = createUser(testDb, { username: 'bob' });
|
||||
const trip = createTrip(testDb, alice.id);
|
||||
|
||||
const noDate = budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, alice.id);
|
||||
expect(noDate!.settled_at).toBeNull();
|
||||
|
||||
const dated = budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-05' }, alice.id);
|
||||
expect(dated!.settled_at).toBe('2026-01-05');
|
||||
expect(budget.getSettlement(dated!.id, trip.id)!.settled_at).toBe('2026-01-05');
|
||||
|
||||
const moved = budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-09' });
|
||||
expect(moved!.settled_at).toBe('2026-01-09');
|
||||
|
||||
// An update that omits settled_at (undefined) leaves the stored day alone,
|
||||
// the same CASE WHEN pattern currency/exchange_rate already follow.
|
||||
const untouched = budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 25 });
|
||||
expect(untouched!.settled_at).toBe('2026-01-09');
|
||||
});
|
||||
|
||||
// ── Notes vs. itemized receipts (#1658) ────────────────────────────────────
|
||||
|
||||
it('BUDGET-SVC-DB-021: a note and a receipt are stored in their own columns', () => {
|
||||
|
||||
Reference in New Issue
Block a user