import { describe, expect, it } from 'vitest'; import { applyPage, emptyLedger, type Ledger } from '../../api/ledger'; import type { SyncPullResponse } from '../../api/types'; import { ApiError } from '../../api/client'; import { ANNA, ANNA_ID, JACOB_ID, page } from '../../test/fixtures'; import { allocationErrorKey, apiErrorKey, rejectionMessage, type RejectedResult } from './messages'; import { dateInRange, deletedSettlement, householdOf, initialForm, ownRows, pushedSettlement, settleView, type Household, type SettleContext, type SettleForm, } from './model'; import { HUSET_ID, JAPAN_ID, SAVED, VARDAG, VARDAG_EXPENSE, VARDAG_ID, rows } from './testScenario'; function ledgerOf(content: Partial = rows()): Ledger { return applyPage(emptyLedger(), page(0, content)); } function contextOf(ledger: Ledger, extra: Partial = {}): SettleContext { const household = householdOf(ledger, JACOB_ID) as Household; return { mode: 'global', ledger, household, editing: null, fixedAccount: null, ...extra }; } const TODAY = '2026-10-21'; describe('opening form (AC-11.3, AC-11.4, AC-11.6)', () => { it('global: the user who owes pays the absolute global balance; rows follow the proposal', () => { const context = contextOf(ledgerOf()); const form = initialForm(context, TODAY, 'sv'); expect(form).toEqual({ payerId: JACOB_ID, amountText: '2065,50', rows: null, date: TODAY, note: '' }); const view = settleView(context, form, 'sv'); expect(view.payer.id).toBe(JACOB_ID); expect(view.lines.map((l) => [l.account.name, l.ore, l.payerNetOre, l.overSettles])).toEqual([ ['Huset', 206_550, -308_450, false], ]); expect(view.remainingOre).toBe(0); expect(view.canConfirm).toBe(true); expect(view.untouched.map((u) => [u.account.name, u.payerNetOre])).toEqual([['Vardag', 118_450]]); expect(view.rowsEdited).toBe(false); }); it('global, the other direction: the partner pays when the partner owes', () => { const context = contextOf(ledgerOf(rows({ expenses: [VARDAG_EXPENSE] }))); const form = initialForm(context, TODAY, 'en'); expect(form.payerId).toBe(ANNA_ID); expect(form.amountText).toBe('1184.50'); expect(settleView(context, form, 'en').lines.map((l) => [l.account.id, l.ore])).toEqual([[VARDAG_ID, 118_450]]); }); it('global, all settled: empty amount, no rows, nothing to confirm', () => { const context = contextOf(ledgerOf(rows({ expenses: [] }))); const form = initialForm(context, TODAY, 'sv'); expect(form.amountText).toBe(''); const view = settleView(context, form, 'sv'); expect(view.lines).toEqual([]); expect(view.error).toBe('required'); expect(view.canConfirm).toBe(false); expect(view.untouched).toEqual([]); }); it('per account: direction and amount from that account; one fixed row that follows the amount', () => { const context = contextOf(ledgerOf(), { mode: 'account', fixedAccount: VARDAG }); const form = initialForm(context, TODAY, 'sv'); expect(form.payerId).toBe(ANNA_ID); expect(form.amountText).toBe('1184,50'); const view = settleView(context, { ...form, amountText: '2000' }, 'sv'); expect(view.lines.map((l) => [l.account.id, l.ore])).toEqual([[VARDAG_ID, 200_000]]); expect(view.lines[0]?.overSettles).toBe(true); expect(view.untouched).toEqual([]); expect(view.addable).toEqual([]); expect(view.rowsEdited).toBe(false); }); it('editing: saved rows count as edited; nets leave the edited settlement out', () => { const ledger = ledgerOf(rows({ settlements: [SAVED] })); const context = contextOf(ledger, { mode: 'edit', editing: SAVED }); const form = initialForm(context, TODAY, 'sv'); expect(form).toEqual({ payerId: JACOB_ID, amountText: '500,00', rows: [{ accountId: JAPAN_ID, text: '500,00' }], date: '2026-10-16', note: 'Swish', }); const view = settleView(context, form, 'sv'); expect(view.lines[0]?.payerNetOre).toBe(-16_550); expect(view.lines[0]?.overSettles).toBe(true); expect(view.rowsEdited).toBe(true); // "Föreslå igen": the proposal for 500,00 goes to the oldest owing account. expect(settleView(context, { ...form, rows: null }, 'sv').lines.map((l) => [l.account.id, l.ore])).toEqual([ [HUSET_ID, 50_000], ]); }); }); describe('derived view', () => { const context = contextOf(ledgerOf()); const base: SettleForm = { payerId: JACOB_ID, amountText: '3250', rows: null, date: TODAY, note: '' }; it('re-proposes for a new amount while no row is edited', () => { expect(settleView(context, base, 'sv').lines.map((l) => [l.account.name, l.ore])).toEqual([ ['Huset', 308_450], ['Japan 2027', 16_550], ]); }); it('swapping proposes for the other payer and lists the accounts the payer is owed in', () => { const view = settleView(context, { ...base, payerId: ANNA_ID, amountText: '2065,50' }, 'sv'); expect(view.lines.map((l) => [l.account.name, l.ore, l.overSettles])).toEqual([['Vardag', 206_550, true]]); expect(view.untouched.map((u) => u.account.name)).toEqual(['Huset', 'Japan 2027']); }); it('keeps edited rows, warns per row and reports the remaining amount', () => { const view = settleView(context, base, 'sv'); const edited = ownRows(view).map((r) => (r.accountId === HUSET_ID ? { ...r, text: '1750' } : r)); const mismatch = settleView(context, { ...base, rows: edited }, 'sv'); expect(mismatch.remainingOre).toBe(325_000 - 175_000 - 16_550); expect(mismatch.error).toBe('allocation_sum_mismatch'); expect(mismatch.canConfirm).toBe(false); expect(mismatch.rowsEdited).toBe(true); const balanced = edited.map((r) => (r.accountId === JAPAN_ID ? { ...r, text: '1500' } : r)); const over = settleView(context, { ...base, rows: balanced }, 'sv'); expect(over.remainingOre).toBe(0); expect(over.lines.filter((l) => l.overSettles).map((l) => l.account.id)).toEqual([JAPAN_ID]); expect(over.canConfirm).toBe(true); const tooMuch = settleView(context, { ...base, amountText: '3000', rows: balanced }, 'sv'); expect(tooMuch.remainingOre).toBe(-25_000); }); it('reports the §14.5.3 token of the rows', () => { const zero = settleView(context, { ...base, rows: [{ accountId: HUSET_ID, text: '0' }] }, 'sv'); expect(zero.error).toBe('allocation_not_positive'); const none = settleView(context, { ...base, rows: [] }, 'sv'); expect(none.error).toBe('no_allocations'); expect(none.untouched).toEqual([]); expect(none.addable.map((a) => a.name)).toEqual(['Vardag', 'Huset', 'Japan 2027']); }); it('builds the pushed entity with allocations sorted by account id and the note trimmed', () => { const form = { ...base, note: ' Swish ' }; const entity = pushedSettlement(settleView(context, form, 'sv'), form, null, 'new-id', '2026-10-21T10:00:00.000Z'); expect(entity).toEqual({ id: 'new-id', from_user_id: JACOB_ID, to_user_id: ANNA_ID, amount_ore: 325_000, settlement_date: TODAY, note: 'Swish', allocations: [ { account_id: HUSET_ID, amount_ore: 308_450 }, { account_id: JAPAN_ID, amount_ore: 16_550 }, ], created_at: '2026-10-21T10:00:00.000Z', updated_at: '2026-10-21T10:00:00.000Z', deleted_at: null, }); expect(deletedSettlement(SAVED, '2026-10-21T10:00:00.000Z')).toMatchObject({ id: SAVED.id, created_at: SAVED.created_at, deleted_at: '2026-10-21T10:00:00.000Z', allocations: SAVED.allocations, }); }); it('accepts dates from 2000-01-01 to today + 365 days (SPEC §9.6)', () => { expect(dateInRange('2000-01-01', TODAY)).toBe(true); expect(dateInRange('1999-12-31', TODAY)).toBe(false); expect(dateInRange('2027-10-21', TODAY)).toBe(true); expect(dateInRange('2027-10-22', TODAY)).toBe(false); expect(dateInRange('2026-02-30', TODAY)).toBe(false); expect(dateInRange('', TODAY)).toBe(false); }); }); describe('messages', () => { it('maps every settlement token on the allocations (§14.5.3)', () => { expect(allocationErrorKey('allocation_sum_mismatch')).toBe('settle_error_allocation_sum'); expect(allocationErrorKey('allocation_not_positive')).toBe('settle_error_allocation_positive'); expect(allocationErrorKey('no_allocations')).toBe('settle_error_no_allocations'); expect(allocationErrorKey('too_many_allocations')).toBe('settle_error_too_many_allocations'); for (const token of ['duplicate_account', 'same_user', 'user_unknown', 'amount_out_of_range']) { expect(allocationErrorKey(token)).toBe('error_generic_invalid'); } }); it('maps every rejection reason (§14.4.6)', () => { const users = new Map([[ANNA_ID, ANNA]]); const rejected = (reason: RejectedResult['reason'], message = ''): RejectedResult => ({ mutation_id: 'm', entity_type: 'settlement', entity_id: SAVED.id, status: 'rejected', reason, message, current: { ...SAVED, updated_by_user_id: ANNA_ID }, }); expect(rejectionMessage(rejected('stale'), users, ANNA)).toEqual({ key: 'home_sync_reject_stale', args: ['Anna'] }); expect(rejectionMessage(rejected('deleted'), users, ANNA).key).toBe('home_sync_reject_deleted'); expect(rejectionMessage(rejected('reference_not_found'), users, ANNA).key).toBe('home_sync_reject_reference'); expect(rejectionMessage(rejected('clock_skew'), users, ANNA).key).toBe('web_settle_reject_clock_skew'); expect(rejectionMessage(rejected('validation_failed', 'allocation_sum_mismatch'), users, ANNA).key).toBe( 'settle_error_allocation_sum', ); expect(rejectionMessage(rejected('validation_failed', 'same_user'), users, ANNA).key).toBe( 'home_sync_reject_validation', ); }); it('maps request failures', () => { expect(apiErrorKey(new ApiError(0, 'network', null, 'x'))).toBe('common_could_not_reach_server'); expect(apiErrorKey(new ApiError(409, 'household_incomplete', null, 'x'))).toBe('error_household_incomplete'); expect(apiErrorKey(new ApiError(500, 'internal', null, 'x'))).toBe('error_server'); expect(apiErrorKey(new Error('x'))).toBe('error_server'); }); });