Files
kvitt/web/src/features/settle/model.test.ts
T
jacobh2 f9a9bfb472
Build Kvitt images / build (push) Failing after 14m12s
W3 web settle up: W-7 settle up and W-8 settlement detail
W-7 (/app/settle, mirrors S-9): global, per account (?account=<id>) and
editing (?settlement=<id>). Defaults per AC-11.3/11.4/11.6, proposal from
proposeAllocation over all non-deleted accounts with nets excluding the
edited payment, rows follow the proposal until one is edited, "Föreslå
igen", swap, over-settle warnings and confirm dialog (AC-11.5), remaining
indicator, add account, untouched info line (D-093), date and note,
§14.5.3 errors live, one POST /sync mutation, §14.4.6 rejections shown
with a re-pull, discard dialog on leaving with changes (D-080).

W-8 (/app/settlements/:id, mirrors S-10): hero, date, note, allocations,
audit timeline (AC-12.4), deleted and missing states, edit link and soft
delete with confirmation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-21 21:28:43 +02:00

222 lines
10 KiB
TypeScript

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<SyncPullResponse> = rows()): Ledger {
return applyPage(emptyLedger(), page(0, content));
}
function contextOf(ledger: Ledger, extra: Partial<SettleContext> = {}): 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');
});
});