W3 web settle up: W-7 settle up and W-8 settlement detail
Build Kvitt images / build (push) Failing after 14m12s
Build Kvitt images / build (push) Failing after 14m12s
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
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user