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,117 @@
|
||||
/* W-7 (design-spec S-9): one column, gap 10; a readable width on desktop. */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-detail);
|
||||
max-width: calc(var(--content-max-width) * 2 / 3);
|
||||
padding: 0 var(--gutter);
|
||||
}
|
||||
.status,
|
||||
.hint {
|
||||
margin: 0;
|
||||
padding: 0 var(--s2);
|
||||
color: var(--on-surface-variant);
|
||||
font: var(--type-body-medium);
|
||||
letter-spacing: var(--tracking-body-medium);
|
||||
}
|
||||
.amount input {
|
||||
font: var(--type-amount-large);
|
||||
}
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
padding: var(--s2) var(--s2) 0;
|
||||
font: var(--type-title-small);
|
||||
letter-spacing: var(--tracking-title-small);
|
||||
}
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s2);
|
||||
}
|
||||
/* The bottom bar (surface, outline-variant top line, padding 10 / 16) stays in view while the form scrolls. */
|
||||
.footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s2);
|
||||
margin: var(--s2) calc(-1 * var(--gutter)) 0;
|
||||
padding: var(--gap-detail) var(--gutter);
|
||||
border-top: 1px solid var(--outline-variant);
|
||||
background: var(--surface);
|
||||
}
|
||||
@media (max-width: 719.98px) {
|
||||
.footer {
|
||||
bottom: calc(var(--nav-height) + env(safe-area-inset-bottom));
|
||||
}
|
||||
.footer > button[type='submit'] {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.pickList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
width: 100%;
|
||||
min-height: var(--row-min);
|
||||
padding: var(--s2);
|
||||
border: none;
|
||||
border-radius: var(--radius-medium);
|
||||
background: var(--surface-container-low);
|
||||
color: var(--on-surface);
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pick:hover {
|
||||
background: color-mix(in srgb, var(--on-surface) 6%, var(--surface-container-low));
|
||||
}
|
||||
.pick:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.pickName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--type-title-medium);
|
||||
letter-spacing: var(--tracking-title-medium);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
padding: 2px var(--s2);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-container-high);
|
||||
color: var(--on-surface-variant);
|
||||
font: var(--type-label-small);
|
||||
letter-spacing: var(--tracking-label-small);
|
||||
}
|
||||
|
||||
.confirmBody {
|
||||
margin: 0;
|
||||
}
|
||||
.confirmLines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.confirmLines > li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--s3);
|
||||
font: var(--type-body-large);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { errorResponse } from '../../test/fakeServer';
|
||||
import { ANNA_ID, JACOB_ID } from '../../test/fixtures';
|
||||
import { renderApp } from '../../test/render';
|
||||
import { detailDate, parseDate, todayIso } from '../../lib/dates';
|
||||
import { settleAccountPath, settlementEditPath } from './links';
|
||||
import {
|
||||
HUSET_ID,
|
||||
JAPAN_ID,
|
||||
SAVED,
|
||||
VARDAG_EXPENSE,
|
||||
VARDAG_ID,
|
||||
acceptPush,
|
||||
ledgerServer,
|
||||
pushed,
|
||||
rejectPush,
|
||||
rows,
|
||||
} from './testScenario';
|
||||
|
||||
/** Money as Testing Library reads it: its matchers turn the no-break spaces of §9.8 into plain spaces. */
|
||||
const kr = (text: string) => `${text} kr`;
|
||||
|
||||
function rowField(account: string) {
|
||||
return screen.getByLabelText(`Belopp för ${account}`);
|
||||
}
|
||||
|
||||
async function openGlobal(server = acceptPush(ledgerServer())) {
|
||||
const rendered = renderApp('/settle', { server });
|
||||
await screen.findByText('Du betalar Anna');
|
||||
return rendered;
|
||||
}
|
||||
|
||||
async function setAmount(value: string) {
|
||||
const user = userEvent.setup();
|
||||
const field = screen.getByLabelText('Belopp');
|
||||
await user.clear(field);
|
||||
if (value !== '') await user.type(field, value);
|
||||
}
|
||||
|
||||
async function setRow(account: string, value: string) {
|
||||
const user = userEvent.setup();
|
||||
await user.clear(rowField(account));
|
||||
if (value !== '') await user.type(rowField(account), value);
|
||||
}
|
||||
|
||||
describe('W-7 settle up, global (AC-11.3)', () => {
|
||||
it('opens with the user who owes paying the global balance, proposed over the owing accounts', async () => {
|
||||
await openGlobal();
|
||||
expect(screen.getByRole('heading', { name: 'Gör upp' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Belopp')).toHaveValue('2065,50');
|
||||
expect(rowField('Huset')).toHaveValue('2065,50');
|
||||
expect(screen.getByText(`Skuld ${kr('3 084,50')}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('Kvar att fördela').parentElement).toHaveTextContent(`Kvar att fördela${kr('0,00')}`);
|
||||
expect(screen.getByText('Vardag lämnas som det är, eftersom Anna är skyldig dig där.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Föreslå igen' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Bekräfta' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('defaults to the partner paying when the partner owes', async () => {
|
||||
renderApp('/settle', { server: ledgerServer(rows({ expenses: [VARDAG_EXPENSE] })) });
|
||||
expect(await screen.findByText('Anna betalar dig')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Belopp')).toHaveValue('1184,50');
|
||||
expect(rowField('Vardag')).toHaveValue('1184,50');
|
||||
expect(screen.getByText(`Skuld ${kr('1 184,50')}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-proposes when the amount changes and when the direction is swapped', async () => {
|
||||
await openGlobal();
|
||||
await setAmount('3250');
|
||||
expect(rowField('Huset')).toHaveValue('3084,50');
|
||||
expect(rowField('Japan 2027')).toHaveValue('165,50');
|
||||
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Byt riktning' }));
|
||||
expect(screen.getByText('Anna betalar dig')).toBeInTheDocument();
|
||||
expect(rowField('Vardag')).toHaveValue('3250,00');
|
||||
expect(screen.queryByLabelText('Belopp för Huset')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Det här reglerar mer än som är skyldigt i Vardag')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Huset och Japan 2027 lämnas som det är, eftersom du är skyldig Anna där.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the remaining amount live and enables Confirm only when the rows add up', async () => {
|
||||
await openGlobal();
|
||||
await setAmount('3250');
|
||||
await setRow('Huset', '2000');
|
||||
await setRow('Japan 2027', '1000');
|
||||
const remaining = screen.getByText('Kvar att fördela').parentElement;
|
||||
expect(remaining).toHaveTextContent(kr('250,00'));
|
||||
expect(screen.getByRole('button', { name: 'Bekräfta' })).toBeDisabled();
|
||||
await setRow('Japan 2027', '1500');
|
||||
expect(remaining).toHaveTextContent(`${kr('250,00')} för mycket`);
|
||||
await setRow('Japan 2027', '1250');
|
||||
expect(remaining).toHaveTextContent(kr('0,00'));
|
||||
expect(screen.getByRole('button', { name: 'Bekräfta' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('keeps edited rows when the amount changes, and "Föreslå igen" proposes again', async () => {
|
||||
await openGlobal();
|
||||
await setRow('Huset', '1000');
|
||||
expect(screen.getByRole('button', { name: 'Föreslå igen' })).toBeInTheDocument();
|
||||
await setAmount('3250');
|
||||
expect(rowField('Huset')).toHaveValue('1000');
|
||||
expect(screen.queryByLabelText('Belopp för Japan 2027')).not.toBeInTheDocument();
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Föreslå igen' }));
|
||||
expect(rowField('Huset')).toHaveValue('3084,50');
|
||||
expect(rowField('Japan 2027')).toHaveValue('165,50');
|
||||
expect(screen.queryByRole('button', { name: 'Föreslå igen' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the §14.5.3 errors of the rows', async () => {
|
||||
await openGlobal();
|
||||
await setRow('Huset', '0');
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Varje konto behöver minst 0,01 kr');
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Ta bort Huset från fördelningen' }));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Lägg till minst ett konto');
|
||||
expect(screen.getByRole('button', { name: 'Bekräfta' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds any other account from "Lägg till konto", archived ones included', async () => {
|
||||
await openGlobal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole('button', { name: 'Lägg till konto' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Välj konto' });
|
||||
expect(within(dialog).getByRole('button', { name: 'Vardag' })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole('button', { name: 'Huset' })).not.toBeInTheDocument();
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Japan 2027' }));
|
||||
expect(rowField('Japan 2027')).toHaveValue('');
|
||||
expect(rowField('Japan 2027')).toHaveFocus();
|
||||
expect(screen.getByRole('button', { name: 'Föreslå igen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when nobody owes anything', async () => {
|
||||
renderApp('/settle', { server: ledgerServer(rows({ expenses: [] })) });
|
||||
expect(await screen.findByText('Skriv ett belopp så föreslår Kvitt en fördelning.')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Belopp')).toHaveValue('');
|
||||
expect(screen.queryByText('Kvar att fördela')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Bekräfta' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the loading line, then an error with retry when the ledger cannot be read', async () => {
|
||||
let answer: () => void = () => undefined;
|
||||
const answered = new Promise<void>((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
const server = ledgerServer().on('GET', '/sync', async () => {
|
||||
await answered;
|
||||
return errorResponse('internal', 500);
|
||||
});
|
||||
renderApp('/settle', { server });
|
||||
// The page's own loading line, beside the shell's status line.
|
||||
expect((await screen.findAllByText('Uppdaterar…')).some((element) => element.tagName === 'P')).toBe(true);
|
||||
answer();
|
||||
expect(await screen.findByText('Något gick fel på servern. Försök igen senare.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Försök igen' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('W-7 confirm and write (§15.5)', () => {
|
||||
it('saves directly without warnings: one POST /sync mutation, toast, back to Home', async () => {
|
||||
const { server, router } = await openGlobal();
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Bekräfta' }));
|
||||
expect(await screen.findByText('Betalningen sparades')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toMatch(/^\/app\/?$/);
|
||||
});
|
||||
const posts = server.calls('POST', '/sync');
|
||||
expect(posts).toHaveLength(1);
|
||||
const mutation = pushed(posts[0]);
|
||||
expect(mutation?.entity_type).toBe('settlement');
|
||||
expect(mutation?.mutation_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(mutation?.entity).toMatchObject({
|
||||
from_user_id: JACOB_ID,
|
||||
to_user_id: ANNA_ID,
|
||||
amount_ore: 206_550,
|
||||
settlement_date: todayIso(),
|
||||
note: '',
|
||||
allocations: [{ account_id: HUSET_ID, amount_ore: 206_550 }],
|
||||
deleted_at: null,
|
||||
});
|
||||
expect(mutation?.entity.created_at).toBe(mutation?.entity.updated_at);
|
||||
// The ledger is pulled again after the write (§15.5).
|
||||
expect(server.calls('GET', '/sync').length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('repeats the over-settle warnings in a confirm dialog before saving (AC-11.5)', async () => {
|
||||
const { server } = await openGlobal();
|
||||
await setAmount('3250');
|
||||
await setRow('Huset', '1750');
|
||||
await setRow('Japan 2027', '1500');
|
||||
expect(screen.getByText('Det här reglerar mer än som är skyldigt i Japan 2027')).toBeInTheDocument();
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText('Anteckning'), 'Swish');
|
||||
await user.click(screen.getByRole('button', { name: 'Bekräfta' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Bekräfta betalningen' });
|
||||
const today = detailDate(parseDate(todayIso()), 'sv');
|
||||
expect(dialog).toHaveTextContent(`Du betalar Anna ${kr('3 250,00')} idag, ${today}.`);
|
||||
expect(dialog).toHaveTextContent(`Huset${kr('1 750,00')}`);
|
||||
expect(dialog).toHaveTextContent('Det här reglerar mer än som är skyldigt i Japan 2027');
|
||||
expect(server.calls('POST', '/sync')).toHaveLength(0);
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Bekräfta' }));
|
||||
expect(await screen.findByText('Betalningen sparades')).toBeInTheDocument();
|
||||
expect(pushed(server.calls('POST', '/sync')[0])?.entity).toMatchObject({
|
||||
amount_ore: 325_000,
|
||||
note: 'Swish',
|
||||
allocations: [
|
||||
{ account_id: HUSET_ID, amount_ore: 175_000 },
|
||||
{ account_id: JAPAN_ID, amount_ore: 150_000 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a rejection with its reason, pulls again and keeps the form', async () => {
|
||||
const { server } = await openGlobal(rejectPush(ledgerServer(), 'stale', { ...SAVED, updated_by_user_id: ANNA_ID }));
|
||||
const pulls = server.calls('GET', '/sync').length;
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Bekräfta' }));
|
||||
expect(await screen.findByText('Din ändring ersattes av en nyare ändring från Anna')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Belopp')).toHaveValue('2065,50');
|
||||
await waitFor(() => {
|
||||
expect(server.calls('GET', '/sync').length).toBeGreaterThan(pulls);
|
||||
});
|
||||
});
|
||||
|
||||
it('says the server could not be reached on a network error and keeps the form', async () => {
|
||||
const server = ledgerServer().on('POST', '/sync', () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
});
|
||||
await openGlobal(server);
|
||||
await setAmount('100');
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Bekräfta' }));
|
||||
expect(await screen.findByText('Kunde inte nå servern')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Belopp')).toHaveValue('100');
|
||||
});
|
||||
|
||||
it('maps a validation rejection to the rule that broke', async () => {
|
||||
await openGlobal(rejectPush(ledgerServer(), 'validation_failed', null, 'allocation_sum_mismatch'));
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Bekräfta' }));
|
||||
expect(await screen.findByText('Kontona måste bli lika med beloppet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks before discarding changes (no body, D-080)', async () => {
|
||||
const { router } = await openGlobal();
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText('Anteckning'), 'Swish');
|
||||
await user.click(screen.getByRole('button', { name: 'Avbryt' }));
|
||||
const dialog = screen.getByRole('alertdialog', { name: 'Slänga ändringarna?' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Fortsätt redigera' }));
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Anteckning')).toHaveValue('Swish');
|
||||
await user.click(screen.getByRole('button', { name: 'Avbryt' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Släng' }));
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toMatch(/^\/app\/?$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves without asking when nothing changed', async () => {
|
||||
const { router } = await openGlobal();
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Avbryt' }));
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toMatch(/^\/app\/?$/);
|
||||
});
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('W-7 per account (AC-11.4, D-052)', () => {
|
||||
it('settles one account with a fixed row that follows the amount; the direction can be swapped', async () => {
|
||||
renderApp(settleAccountPath(VARDAG_ID), { server: ledgerServer() });
|
||||
expect(await screen.findByRole('heading', { name: 'Gör upp Vardag' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Anna betalar dig')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Belopp')).toHaveValue('1184,50');
|
||||
expect(screen.queryByLabelText('Belopp för Vardag')).not.toBeInTheDocument();
|
||||
const group = screen.getByRole('list', { name: 'Fördelning' });
|
||||
expect(group).toHaveTextContent(kr('1 184,50'));
|
||||
expect(screen.queryByText('Kvar att fördela')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Lägg till konto' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/lämnas som det är/)).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Byt riktning' }));
|
||||
expect(screen.getByText('Du betalar Anna')).toBeInTheDocument();
|
||||
await setAmount('200');
|
||||
expect(group).toHaveTextContent(kr('200,00'));
|
||||
expect(screen.getByText('Det här reglerar mer än som är skyldigt i Vardag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says the account does not exist for an unknown id', async () => {
|
||||
renderApp(settleAccountPath('01929c5e-0000-7000-8000-0000000000ff'), { server: ledgerServer() });
|
||||
expect(await screen.findByText('Det här finns inte längre')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('W-7 editing (AC-11.6, D-074, D-093)', () => {
|
||||
it('opens with the saved rows as edited and nets without the edited payment', async () => {
|
||||
const server = acceptPush(ledgerServer(rows({ settlements: [SAVED] })));
|
||||
renderApp(settlementEditPath(SAVED.id), { server });
|
||||
expect(await screen.findByLabelText('Belopp')).toHaveValue('500,00');
|
||||
expect(screen.getByRole('heading', { name: 'Ändra betalning' })).toBeInTheDocument();
|
||||
expect(rowField('Japan 2027')).toHaveValue('500,00');
|
||||
expect(screen.getByText(`Skuld ${kr('165,50')}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('Det här reglerar mer än som är skyldigt i Japan 2027')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Anteckning')).toHaveValue('Swish');
|
||||
expect(screen.getByLabelText('Datum')).toHaveValue('2026-10-16');
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole('button', { name: 'Föreslå igen' }));
|
||||
expect(rowField('Huset')).toHaveValue('500,00');
|
||||
expect(screen.queryByLabelText('Belopp för Japan 2027')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Bekräfta' }));
|
||||
expect(await screen.findByText('Betalningen sparades')).toBeInTheDocument();
|
||||
const entity = pushed(server.calls('POST', '/sync')[0])?.entity;
|
||||
expect(entity).toMatchObject({
|
||||
id: SAVED.id,
|
||||
created_at: SAVED.created_at,
|
||||
settlement_date: '2026-10-16',
|
||||
note: 'Swish',
|
||||
allocations: [{ account_id: HUSET_ID, amount_ore: 50_000 }],
|
||||
});
|
||||
expect(entity?.updated_at).not.toBe(SAVED.updated_at);
|
||||
});
|
||||
|
||||
it('says the payment does not exist when it was deleted', async () => {
|
||||
const deleted = { ...SAVED, deleted_at: '2026-10-17T09:12:00.000Z' };
|
||||
renderApp(settlementEditPath(SAVED.id), { server: ledgerServer(rows({ settlements: [deleted] })) });
|
||||
expect(await screen.findByText('Det här finns inte längre')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,631 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useBlocker, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useLedger, useLedgerWriter, type Ledger } from '../../api/ledger';
|
||||
import { pushMutations, rowsOfResults, settlementMutation } from '../../api/push';
|
||||
import type { Account, Settlement } from '../../api/types';
|
||||
import { useApi } from '../../api/useApi';
|
||||
import { paths } from '../../app/routes';
|
||||
import { TopBar } from '../../app/TopBar';
|
||||
import { useSession } from '../../app/useSession';
|
||||
import { AmountField } from '../../components/AmountField';
|
||||
import { Button, ButtonLink } from '../../components/Button';
|
||||
import { Dialog } from '../../components/Dialog';
|
||||
import { EmptyState } from '../../components/EmptyState';
|
||||
import { SegmentedGroup, SegmentedItem } from '../../components/LedgerRow';
|
||||
import { Money } from '../../components/Money';
|
||||
import { TextField } from '../../components/TextField';
|
||||
import { useToast } from '../../components/Toast';
|
||||
import { Icon } from '../../design/Icon';
|
||||
import type { StringKey } from '../../i18n/catalog';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
import { detailDate, parseDate, todayIso } from '../../lib/dates';
|
||||
import { timestamp, uuidv7 } from '../../lib/ids';
|
||||
import { AMOUNT_MAX_ORE, AMOUNT_MIN_ORE, amountInRange, formatMoney } from '../../lib/money';
|
||||
import { SETTLE_ACCOUNT_PARAM, SETTLE_SETTLEMENT_PARAM } from './links';
|
||||
import { allocationErrorKey, apiErrorKey, rejectionMessage, type Message } from './messages';
|
||||
import {
|
||||
codePoints,
|
||||
dateInRange,
|
||||
FIRST_DATE,
|
||||
householdOf,
|
||||
initialForm,
|
||||
lastDate,
|
||||
NOTE_MAX,
|
||||
orderedAccounts,
|
||||
ownRows,
|
||||
pushedSettlement,
|
||||
settleView,
|
||||
type AllocationLine,
|
||||
type Household,
|
||||
type SettleContext,
|
||||
type SettleForm,
|
||||
type SettleMode,
|
||||
type SettleView,
|
||||
} from './model';
|
||||
import {
|
||||
AccountTile,
|
||||
AllocationRow,
|
||||
DirectionCard,
|
||||
ErrorLine,
|
||||
InfoLine,
|
||||
RemainingIndicator,
|
||||
WarningStrip,
|
||||
} from './parts';
|
||||
import styles from './SettlePage.module.css';
|
||||
|
||||
/** S-9 Settle up, /app/settle. */
|
||||
interface Target {
|
||||
mode: SettleMode;
|
||||
accountId: string | null;
|
||||
settlementId: string | null;
|
||||
/** Where Cancel and a saved payment lead when there is no history entry to go back to. */
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
function targetOf(params: URLSearchParams): Target {
|
||||
const settlementId = params.get(SETTLE_SETTLEMENT_PARAM);
|
||||
const accountId = params.get(SETTLE_ACCOUNT_PARAM);
|
||||
if (settlementId !== null)
|
||||
return { mode: 'edit', accountId: null, settlementId, fallback: paths.settlement(settlementId) };
|
||||
if (accountId !== null) return { mode: 'account', accountId, settlementId: null, fallback: paths.account(accountId) };
|
||||
return { mode: 'global', accountId: null, settlementId: null, fallback: paths.home };
|
||||
}
|
||||
|
||||
/**
|
||||
* W-7 Settle up, `/app/settle` (mirrors S-9): global (from Home), `?account=<id>` for one account (S-5 "Settle this
|
||||
* account"), `?settlement=<id>` to edit a payment (W-8 "Edit"). Computed in the browser from the ledger as the phone
|
||||
* does (SPEC §15.5); Confirm writes one `POST /sync` mutation.
|
||||
*/
|
||||
export default function SettlePage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('settle_title')} back={paths.home} />;
|
||||
const [params] = useSearchParams();
|
||||
const target = targetOf(params);
|
||||
const ledger = useLedger();
|
||||
const session = useSession();
|
||||
if (ledger.data === undefined || session === null) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title={t(titleKey(target.mode))} back={target.fallback} />
|
||||
<div className={styles.body}>
|
||||
{ledger.isError ? (
|
||||
<EmptyState
|
||||
icon="sync_problem"
|
||||
title={t(apiErrorKey(ledger.error))}
|
||||
action={
|
||||
<Button variant="tonal" icon={<Icon name="refresh" size={20} />} onClick={() => void ledger.refetch()}>
|
||||
{t('common_retry')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.status} role="status">
|
||||
{t('web_updating')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <SettleLoaded key={params.toString()} ledger={ledger.data} viewerId={session.userId} target={target} />;
|
||||
}
|
||||
|
||||
function titleKey(mode: SettleMode): StringKey {
|
||||
return mode === 'edit' ? 'settle_title_edit' : 'settle_title';
|
||||
}
|
||||
|
||||
function liveSettlement(ledger: Ledger, id: string | null): Settlement | null {
|
||||
const row = id === null ? undefined : ledger.settlements.get(id);
|
||||
return row === undefined || row.deleted_at !== null ? null : row;
|
||||
}
|
||||
|
||||
function liveAccount(ledger: Ledger, id: string | null): Account | null {
|
||||
const row = id === null ? undefined : ledger.accounts.get(id);
|
||||
return row === undefined || row.deleted_at !== null ? null : row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes what the page edits once the ledger is there: the settlement or account named in the URL, today, and the
|
||||
* opening form. Later pulls update balances and names, never the user's input.
|
||||
*/
|
||||
function SettleLoaded({ ledger, viewerId, target }: { ledger: Ledger; viewerId: string; target: Target }) {
|
||||
const { t, language } = useI18n();
|
||||
const household = householdOf(ledger, viewerId);
|
||||
const [editing] = useState(() => liveSettlement(ledger, target.settlementId));
|
||||
const [fixed] = useState(() => liveAccount(ledger, target.accountId));
|
||||
const [today] = useState(() => todayIso());
|
||||
const missing = (target.mode === 'edit' && editing === null) || (target.mode === 'account' && fixed === null);
|
||||
const fixedAccount = fixed === null ? null : (ledger.accounts.get(fixed.id) ?? fixed);
|
||||
const [initial] = useState(() =>
|
||||
household === null || missing
|
||||
? null
|
||||
: initialForm({ mode: target.mode, ledger, household, editing, fixedAccount }, today, language),
|
||||
);
|
||||
|
||||
if (household === null || initial === null) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title={t(titleKey(target.mode))} back={target.fallback} />
|
||||
<div className={styles.body}>
|
||||
<EmptyState
|
||||
icon={household === null ? 'person_add' : 'sync_problem'}
|
||||
title={t(household === null ? 'error_household_incomplete' : 'error_not_found')}
|
||||
action={
|
||||
<ButtonLink to={target.fallback} variant="tonal">
|
||||
{t('common_back')}
|
||||
</ButtonLink>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettleEditor
|
||||
context={{ mode: target.mode, ledger, household, editing, fixedAccount }}
|
||||
initial={initial}
|
||||
today={today}
|
||||
fallback={target.fallback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type OpenDialog = 'confirm' | 'add' | null;
|
||||
|
||||
function SettleEditor({
|
||||
context,
|
||||
initial,
|
||||
today,
|
||||
fallback,
|
||||
}: {
|
||||
context: SettleContext;
|
||||
initial: SettleForm;
|
||||
today: string;
|
||||
fallback: string;
|
||||
}) {
|
||||
const { t, tp, language } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const api = useApi();
|
||||
const { apply, refresh } = useLedgerWriter();
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState(initial);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [dateError, setDateError] = useState(false);
|
||||
const [dialog, setDialog] = useState<OpenDialog>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [failure, setFailure] = useState<Message | null>(null);
|
||||
// A row added from "Lägg till konto" takes the focus once the dialog has closed and given its focus back.
|
||||
const [focusRequest, setFocusRequest] = useState<{ accountId: string } | null>(null);
|
||||
const saved = useRef(false);
|
||||
// Leaving with changes asks "Slänga ändringarna?" (design-spec S-9), whichever way the user leaves.
|
||||
const blocker = useBlocker(() => dirty && !saved.current);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequest !== null) document.getElementById(rowFieldId(focusRequest.accountId))?.focus();
|
||||
}, [focusRequest]);
|
||||
|
||||
const { mode, household } = context;
|
||||
const view = settleView(context, form, language);
|
||||
const noteTooLong = codePoints(form.note.trim()) > NOTE_MAX;
|
||||
|
||||
const change = (next: SettleForm) => {
|
||||
setForm(next);
|
||||
setDirty(true);
|
||||
setFailure(null);
|
||||
};
|
||||
|
||||
const leave = () => {
|
||||
const index = (window.history.state as { idx?: number } | null)?.idx ?? 0;
|
||||
if (index > 0) void navigate(-1);
|
||||
else void navigate(fallback, { replace: true });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setDialog(null);
|
||||
setSaving(true);
|
||||
setFailure(null);
|
||||
const entity = pushedSettlement(view, form, context.editing, uuidv7(), timestamp());
|
||||
try {
|
||||
const results = await pushMutations(api, [settlementMutation(entity)]);
|
||||
apply(rowsOfResults(results));
|
||||
void refresh();
|
||||
const result = results[0];
|
||||
if (result === undefined) return;
|
||||
if (result.status === 'accepted') {
|
||||
saved.current = true;
|
||||
toast({ message: t('snackbar_payment_saved') });
|
||||
leave();
|
||||
} else {
|
||||
setFailure(rejectionMessage(result, context.ledger.users, household.partner));
|
||||
}
|
||||
} catch (error) {
|
||||
setFailure({ key: apiErrorKey(error), args: [] });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
if (!view.canConfirm || saving || noteTooLong) return;
|
||||
if (!dateInRange(form.date, today)) {
|
||||
setDateError(true);
|
||||
return;
|
||||
}
|
||||
if (view.lines.some((line) => line.overSettles)) setDialog('confirm');
|
||||
else void save();
|
||||
};
|
||||
|
||||
const title =
|
||||
mode === 'edit'
|
||||
? t('settle_title_edit')
|
||||
: mode === 'account'
|
||||
? t('settle_title_account', context.fixedAccount?.name ?? '')
|
||||
: t('settle_title');
|
||||
const amountError =
|
||||
view.amountOre !== null && !amountInRange(view.amountOre)
|
||||
? t('error_amount_out_of_range', formatMoney(AMOUNT_MIN_ORE, language), formatMoney(AMOUNT_MAX_ORE, language))
|
||||
: null;
|
||||
const allocationError = liveAllocationError(view);
|
||||
const sentence =
|
||||
view.payer.id === household.me.id
|
||||
? t('settle_you_pay', view.recipient.display_name)
|
||||
: t('settle_partner_pays', view.payer.display_name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title={title} back={fallback} />
|
||||
<form
|
||||
className={styles.body}
|
||||
noValidate
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
{failure === null ? null : <ErrorLine>{t(failure.key, ...failure.args)}</ErrorLine>}
|
||||
<DirectionCard
|
||||
payer={view.payer}
|
||||
recipient={view.recipient}
|
||||
householdUserIds={household.userIds}
|
||||
sentence={sentence}
|
||||
onSwap={() => {
|
||||
change({ ...form, payerId: view.recipient.id, rows: null });
|
||||
}}
|
||||
/>
|
||||
<AmountField
|
||||
label={t('settle_amount')}
|
||||
value={form.amountText}
|
||||
className={styles.amount}
|
||||
error={amountError}
|
||||
onChange={(text) => {
|
||||
change({ ...form, amountText: text });
|
||||
}}
|
||||
/>
|
||||
<h2 className={styles.sectionTitle}>{t('settle_allocation')}</h2>
|
||||
{view.lines.length === 0 ? (
|
||||
view.amountOre === null ? (
|
||||
<p className={styles.hint}>{t('settle_empty_hint')}</p>
|
||||
) : null
|
||||
) : (
|
||||
<SegmentedGroup label={t('settle_allocation')}>
|
||||
{view.lines.map((line) => (
|
||||
<SegmentedItem key={line.account.id}>
|
||||
<AllocationRow
|
||||
line={line}
|
||||
position={positionText(line, view, household, t, language)}
|
||||
fixed={mode === 'account'}
|
||||
fieldId={rowFieldId(line.account.id)}
|
||||
onAmount={(text) => {
|
||||
change({
|
||||
...form,
|
||||
rows: ownRows(view).map((row) => (row.accountId === line.account.id ? { ...row, text } : row)),
|
||||
});
|
||||
}}
|
||||
onRemove={() => {
|
||||
change({ ...form, rows: ownRows(view).filter((row) => row.accountId !== line.account.id) });
|
||||
}}
|
||||
/>
|
||||
</SegmentedItem>
|
||||
))}
|
||||
</SegmentedGroup>
|
||||
)}
|
||||
{allocationError === null ? null : <ErrorLine>{t(allocationError)}</ErrorLine>}
|
||||
{mode !== 'account' && view.lines.length > 0 ? <RemainingIndicator remainingOre={view.remainingOre} /> : null}
|
||||
{view.addable.length > 0 || view.rowsEdited ? (
|
||||
<div className={styles.rowActions}>
|
||||
{view.addable.length > 0 ? (
|
||||
<Button
|
||||
variant="text"
|
||||
icon={<Icon name="add" size={20} />}
|
||||
onClick={() => {
|
||||
setDialog('add');
|
||||
}}
|
||||
>
|
||||
{t('settle_add_account')}
|
||||
</Button>
|
||||
) : null}
|
||||
{view.rowsEdited ? (
|
||||
<Button
|
||||
variant="text"
|
||||
icon={<Icon name="refresh" size={20} />}
|
||||
onClick={() => {
|
||||
change({ ...form, rows: null });
|
||||
}}
|
||||
>
|
||||
{t('settle_propose_again')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<UntouchedLine view={view} household={household} />
|
||||
<TextField
|
||||
label={t('settle_date')}
|
||||
type="date"
|
||||
value={form.date}
|
||||
min={FIRST_DATE}
|
||||
max={lastDate(today)}
|
||||
error={dateError ? t('error_date_out_of_range') : null}
|
||||
onChange={(date) => {
|
||||
setDateError(false);
|
||||
change({ ...form, date });
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label={t('settle_note')}
|
||||
value={form.note}
|
||||
placeholder={t('settle_note_hint')}
|
||||
error={noteTooLong ? tp('error_too_long', NOTE_MAX) : null}
|
||||
onChange={(note) => {
|
||||
change({ ...form, note });
|
||||
}}
|
||||
/>
|
||||
<div className={styles.footer}>
|
||||
<Button variant="text" onClick={leave}>
|
||||
{t('common_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="medium"
|
||||
icon={<Icon name="check" size={20} />}
|
||||
disabled={!view.canConfirm || saving || noteTooLong}
|
||||
>
|
||||
{t('settle_confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<AddAccountDialog
|
||||
open={dialog === 'add'}
|
||||
accounts={view.addable}
|
||||
colorIndex={(id) => orderedAccounts(context.ledger).findIndex((a) => a.id === id)}
|
||||
onClose={() => {
|
||||
setDialog(null);
|
||||
}}
|
||||
onPick={(accountId) => {
|
||||
setDialog(null);
|
||||
setFocusRequest({ accountId });
|
||||
change({ ...form, rows: [...ownRows(view), { accountId, text: '' }] });
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={dialog === 'confirm'}
|
||||
view={view}
|
||||
form={form}
|
||||
today={today}
|
||||
household={household}
|
||||
onClose={() => {
|
||||
setDialog(null);
|
||||
}}
|
||||
onConfirm={() => void save()}
|
||||
/>
|
||||
<Dialog
|
||||
open={blocker.state === 'blocked'}
|
||||
alert
|
||||
title={t('common_discard_title')}
|
||||
onClose={() => {
|
||||
if (blocker.state === 'blocked') blocker.reset();
|
||||
}}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
if (blocker.state === 'blocked') blocker.reset();
|
||||
}}
|
||||
>
|
||||
{t('common_keep_editing')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (blocker.state === 'blocked') blocker.proceed();
|
||||
}}
|
||||
>
|
||||
{t('common_discard')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function rowFieldId(accountId: string): string {
|
||||
return `settle-row-${accountId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A §14.5.3 token the user can fix in the rows, shown live (design-spec S-9 "Invalid → inline under the rows"). The
|
||||
* sum mismatch is the remaining indicator's job and an empty amount the field's.
|
||||
*/
|
||||
function liveAllocationError(view: SettleView): StringKey | null {
|
||||
if (view.amountOre === null || !amountInRange(view.amountOre)) return null;
|
||||
if (view.error === null || view.error === 'required' || view.error === 'allocation_sum_mismatch') return null;
|
||||
return allocationErrorKey(view.error);
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useI18n>['t'];
|
||||
|
||||
/**
|
||||
* The payer's position in a row's account (design-spec §3.8): "Skuld 3 084,50 kr" when the payer owes there, otherwise
|
||||
* the account balance from the viewer's side ("Anna är skyldig dig 1 184,50 kr", "Kvitt").
|
||||
*/
|
||||
function positionText(
|
||||
line: AllocationLine,
|
||||
view: SettleView,
|
||||
household: Household,
|
||||
t: Translate,
|
||||
language: 'sv' | 'en',
|
||||
): string {
|
||||
if (line.payerNetOre < 0) return t('settle_row_debt', formatMoney(-line.payerNetOre, language));
|
||||
const viewerNet = view.payer.id === household.me.id ? line.payerNetOre : -line.payerNetOre;
|
||||
const partner = household.partner.display_name;
|
||||
if (viewerNet > 0) return t('balance_partner_owes_you_amount', partner, formatMoney(viewerNet, language));
|
||||
if (viewerNet < 0) return t('balance_you_owe_partner_amount', partner, formatMoney(-viewerNet, language));
|
||||
return t('balance_account_settled');
|
||||
}
|
||||
|
||||
/** "A", "A och B", "A, B och C" (`common_and`, `common_separator_comma`). */
|
||||
function joinNames(names: string[], t: Translate): string {
|
||||
const last = names.at(-1) ?? '';
|
||||
if (names.length < 2) return last;
|
||||
return t('common_and', names.slice(0, -1).join(t('common_separator_comma')), last);
|
||||
}
|
||||
|
||||
/** §9.4 info line: "Vardag lämnas som det är, eftersom Anna är skyldig dig där." (D-093). */
|
||||
function UntouchedLine({ view, household }: { view: SettleView; household: Household }) {
|
||||
const { t } = useI18n();
|
||||
if (view.untouched.length === 0) return null;
|
||||
const names = joinNames(
|
||||
view.untouched.map((u) => u.account.name),
|
||||
t,
|
||||
);
|
||||
const partner = household.partner.display_name;
|
||||
// The payer is owed in every untouched account: the partner owes the viewer there when the viewer pays.
|
||||
return (
|
||||
<InfoLine>
|
||||
{view.payer.id === household.me.id
|
||||
? t('settle_untouched_partner_owes', names, partner)
|
||||
: t('settle_untouched_you_owe', names, partner)}
|
||||
</InfoLine>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Lägg till konto": every non-deleted account not yet listed, archived ones with the badge (AC-11.3). */
|
||||
function AddAccountDialog({
|
||||
open,
|
||||
accounts,
|
||||
colorIndex,
|
||||
onClose,
|
||||
onPick,
|
||||
}: {
|
||||
open: boolean;
|
||||
accounts: Account[];
|
||||
colorIndex: (id: string) => number;
|
||||
onClose: () => void;
|
||||
onPick: (accountId: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={t('editor_pick_account')}
|
||||
onClose={onClose}
|
||||
actions={
|
||||
<Button variant="text" onClick={onClose}>
|
||||
{t('common_cancel')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ul className={styles.pickList}>
|
||||
{accounts.map((account) => (
|
||||
<li key={account.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pick}
|
||||
onClick={() => {
|
||||
onPick(account.id);
|
||||
}}
|
||||
>
|
||||
<AccountTile
|
||||
name={account.name}
|
||||
colorIndex={colorIndex(account.id)}
|
||||
size={36}
|
||||
muted={account.archived_at !== null}
|
||||
/>
|
||||
<span className={styles.pickName}>{account.name}</span>
|
||||
{account.archived_at === null ? null : (
|
||||
<span className={styles.badge}>
|
||||
<Icon name="inventory_2" size={14} />
|
||||
{t('common_archived')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AC-11.5 confirm dialog (design-spec `S-9-confirm-dialog`): "Du betalar Anna 3 250,00 kr idag, lördag 17 oktober
|
||||
* 2026.", the allocations, and the warning per over-settled row.
|
||||
*/
|
||||
function ConfirmDialog({
|
||||
open,
|
||||
view,
|
||||
form,
|
||||
today,
|
||||
household,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
view: SettleView;
|
||||
form: SettleForm;
|
||||
today: string;
|
||||
household: Household;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t, language } = useI18n();
|
||||
if (!open) return null;
|
||||
const amount = formatMoney(view.amountOre ?? 0, language);
|
||||
const date = detailDate(parseDate(form.date), language);
|
||||
const when = form.date === today ? t('settle_confirm_date_today', date) : t('settle_confirm_date', date);
|
||||
const body =
|
||||
view.payer.id === household.me.id
|
||||
? t('settle_confirm_body_you_pay', view.recipient.display_name, amount, when)
|
||||
: t('settle_confirm_body_partner_pays', view.payer.display_name, amount, when);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={t('settle_confirm_dialog_title')}
|
||||
onClose={onClose}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="text" onClick={onClose}>
|
||||
{t('common_cancel')}
|
||||
</Button>
|
||||
<Button icon={<Icon name="check" size={20} />} onClick={onConfirm}>
|
||||
{t('settle_confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className={styles.confirmBody}>{body}</p>
|
||||
<ul className={styles.confirmLines}>
|
||||
{view.lines.map((line) => (
|
||||
<li key={line.account.id}>
|
||||
<span>{line.account.name}</span>
|
||||
<Money ore={line.ore ?? 0} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{view.lines
|
||||
.filter((line) => line.overSettles)
|
||||
.map((line) => (
|
||||
<WarningStrip key={line.account.id}>{t('settle_over_settle_warning', line.account.name)}</WarningStrip>
|
||||
))}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* W-8 (design-spec S-10): one column, gap 10. */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-detail);
|
||||
max-width: calc(var(--content-max-width) * 2 / 3);
|
||||
padding: 0 var(--gutter);
|
||||
}
|
||||
.content,
|
||||
.faded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-detail);
|
||||
}
|
||||
/* Deleted: content at 62 % opacity. */
|
||||
.faded {
|
||||
opacity: 0.62;
|
||||
}
|
||||
.status {
|
||||
margin: 0;
|
||||
color: var(--on-surface-variant);
|
||||
font: var(--type-body-medium);
|
||||
}
|
||||
|
||||
/* Neutral banner: who deleted the payment and that it no longer counts. */
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) var(--s4);
|
||||
border-radius: var(--radius-large);
|
||||
background: var(--surface-container-high);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.bannerIcon {
|
||||
flex: none;
|
||||
color: var(--error);
|
||||
}
|
||||
.bannerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
font: var(--type-body-medium);
|
||||
letter-spacing: var(--tracking-body-medium);
|
||||
}
|
||||
.bannerTitle {
|
||||
font: var(--type-title-small);
|
||||
letter-spacing: var(--tracking-title-small);
|
||||
}
|
||||
|
||||
/* Hero card: settlement-hero fill, padding 22 / 20, centred, gap 8. */
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: 22px var(--s5);
|
||||
border-radius: var(--radius-extra-large);
|
||||
background: var(--settlement-hero);
|
||||
color: var(--on-surface);
|
||||
text-align: center;
|
||||
}
|
||||
.people {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
}
|
||||
.arrow {
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.heroText {
|
||||
margin: 0;
|
||||
font: var(--type-title-medium);
|
||||
letter-spacing: var(--tracking-title-medium);
|
||||
}
|
||||
|
||||
.fact,
|
||||
.allocation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: var(--row-min);
|
||||
padding: var(--s2) var(--s4);
|
||||
}
|
||||
.factIcon {
|
||||
flex: none;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.factText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.factLabel {
|
||||
font: var(--type-label-medium);
|
||||
letter-spacing: var(--tracking-label);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.factValue {
|
||||
font: var(--type-body-large);
|
||||
letter-spacing: var(--tracking-body-large);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
padding: var(--s2) var(--s2) 0;
|
||||
font: var(--type-title-small);
|
||||
letter-spacing: var(--tracking-title-small);
|
||||
}
|
||||
.accountName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--type-title-medium);
|
||||
letter-spacing: var(--tracking-title-medium);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ANNA_ID, JACOB_ID } from '../../test/fixtures';
|
||||
import { renderApp } from '../../test/render';
|
||||
import { paths } from '../../app/routes';
|
||||
import { settlementEditPath } from './links';
|
||||
import { JAPAN_ID, SAVED, acceptPush, audit, ledgerServer, pushed, rejectPush, rows } from './testScenario';
|
||||
|
||||
const kr = (text: string) => `${text} kr`;
|
||||
|
||||
const CREATED = audit('01929c5e-0000-7000-8000-0000000000a1', 'create', JACOB_ID, '2026-10-16T12:00:00.000Z', 41);
|
||||
const CHANGED = audit('01929c5e-0000-7000-8000-0000000000a2', 'update', ANNA_ID, '2026-10-16T18:30:00.000Z', 42, [
|
||||
{ field: 'amount_ore', old: 40_000, new: 50_000 },
|
||||
{ field: 'note', old: '', new: 'Swish' },
|
||||
{
|
||||
field: 'allocations',
|
||||
old: [{ account_id: JAPAN_ID, amount_ore: 40_000 }],
|
||||
new: [{ account_id: JAPAN_ID, amount_ore: 50_000 }],
|
||||
},
|
||||
]);
|
||||
const DELETED = audit('01929c5e-0000-7000-8000-0000000000a3', 'delete', ANNA_ID, '2026-10-17T09:12:00.000Z', 43, [
|
||||
{ field: 'deleted_at', old: null, new: '2026-10-17T09:12:00.000Z' },
|
||||
]);
|
||||
|
||||
describe('W-8 settlement detail (S-10)', () => {
|
||||
it('shows payer, recipient, amount, date, note, allocations and the timeline', async () => {
|
||||
renderApp(paths.settlement(SAVED.id), {
|
||||
server: ledgerServer(rows({ settlements: [SAVED], audit_entries: [CREATED, CHANGED] })),
|
||||
});
|
||||
expect(await screen.findByText('Du betalade Anna')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Betalning' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Du betalade Anna')).toHaveTextContent(kr('500,00'));
|
||||
expect(screen.getByText('fredag 16 oktober 2026')).toBeInTheDocument();
|
||||
expect(screen.getByText('Swish')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: 'Fördelning' })).toHaveTextContent(`Japan 2027${kr('500,00')}`);
|
||||
|
||||
const timeline = screen.getByRole('heading', { name: 'Historik' }).nextElementSibling as HTMLElement;
|
||||
const items = within(timeline)
|
||||
.getAllByRole('listitem')
|
||||
.map((item) => item.textContent.replace(/\u00A0/g, ' '));
|
||||
expect(items).toHaveLength(4);
|
||||
expect(items[0]).toMatch(/^Du skapade den här · 16 okt\.( 2026)? \d\d:\d\d$/);
|
||||
expect(items[1]).toMatch(/^Anna ändrade beloppet från 400,00 kr till 500,00 kr · /);
|
||||
expect(items[2]).toMatch(/^Anna lade till anteckningen: Swish · /);
|
||||
expect(items[3]).toMatch(/^Anna ändrade fördelningen från Japan 2027 400,00 kr till Japan 2027 500,00 kr · /);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Ändra betalning' })).toHaveAttribute(
|
||||
'href',
|
||||
`/app${settlementEditPath(SAVED.id)}`,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Ta bort betalning' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says the partner paid when the partner did', async () => {
|
||||
const fromAnna = { ...SAVED, from_user_id: ANNA_ID, to_user_id: JACOB_ID, note: '' };
|
||||
renderApp(paths.settlement(SAVED.id), { server: ledgerServer(rows({ settlements: [fromAnna] })) });
|
||||
expect(await screen.findByText('Anna betalade dig')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Anteckning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows who deleted a payment, fades it and offers no actions', async () => {
|
||||
const deleted = { ...SAVED, deleted_at: '2026-10-17T09:12:00.000Z', updated_by_user_id: ANNA_ID };
|
||||
renderApp(paths.settlement(SAVED.id), {
|
||||
server: ledgerServer(rows({ settlements: [deleted], audit_entries: [CREATED, DELETED] })),
|
||||
});
|
||||
expect(await screen.findByText(/^Anna tog bort den här betalningen · 17 okt\./)).toBeInTheDocument();
|
||||
expect(screen.getByText('Den räknas inte längre i saldona.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Ändra betalning' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Ta bort betalning' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/^Anna tog bort den här · 17 okt\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the missing state for an id the ledger does not hold, with a way to update', async () => {
|
||||
const { server } = renderApp(paths.settlement('01929c5e-0000-7000-8000-0000000000ff'), {
|
||||
server: ledgerServer(),
|
||||
});
|
||||
expect(await screen.findByText('Betalningen hittades inte')).toBeInTheDocument();
|
||||
const pulls = server.calls('GET', '/sync').length;
|
||||
const missing = screen.getByText('Betalningen hittades inte').parentElement as HTMLElement;
|
||||
await userEvent.setup().click(within(missing).getByRole('button', { name: 'Uppdatera nu' }));
|
||||
await waitFor(() => {
|
||||
expect(server.calls('GET', '/sync').length).toBeGreaterThan(pulls);
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes after a confirmation: the full entity with deleted_at, a toast and back to Home', async () => {
|
||||
const { server, router } = renderApp(paths.settlement(SAVED.id), {
|
||||
server: acceptPush(ledgerServer(rows({ settlements: [SAVED] }))),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: 'Ta bort betalning' }));
|
||||
const dialog = screen.getByRole('alertdialog', { name: 'Ta bort betalningen?' });
|
||||
expect(dialog).toHaveTextContent('Saldona räknas om för er båda.');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Ta bort' }));
|
||||
expect(await screen.findByText('Betalningen togs bort')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toMatch(/^\/app\/?$/);
|
||||
});
|
||||
const entity = pushed(server.calls('POST', '/sync')[0])?.entity;
|
||||
expect(entity).toMatchObject({
|
||||
id: SAVED.id,
|
||||
amount_ore: SAVED.amount_ore,
|
||||
allocations: SAVED.allocations,
|
||||
created_at: SAVED.created_at,
|
||||
});
|
||||
expect(entity?.deleted_at).not.toBeNull();
|
||||
expect(entity?.updated_at).toBe(entity?.deleted_at);
|
||||
});
|
||||
|
||||
it('keeps the payment and shows the reason when the delete is rejected', async () => {
|
||||
renderApp(paths.settlement(SAVED.id), {
|
||||
server: rejectPush(ledgerServer(rows({ settlements: [SAVED] })), 'stale', {
|
||||
...SAVED,
|
||||
updated_by_user_id: ANNA_ID,
|
||||
}),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: 'Ta bort betalning' }));
|
||||
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Ta bort' }));
|
||||
expect(await screen.findByText('Din ändring ersattes av en nyare ändring från Anna')).toBeInTheDocument();
|
||||
expect(screen.getByText('Du betalade Anna')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,284 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useLedger, useLedgerWriter, type Ledger } from '../../api/ledger';
|
||||
import { pushMutations, rowsOfResults, settlementMutation } from '../../api/push';
|
||||
import type { Settlement } from '../../api/types';
|
||||
import { useApi } from '../../api/useApi';
|
||||
import { paths } from '../../app/routes';
|
||||
import { TopBar } from '../../app/TopBar';
|
||||
import { useSession } from '../../app/useSession';
|
||||
import { Button, ButtonLink } from '../../components/Button';
|
||||
import { Dialog } from '../../components/Dialog';
|
||||
import { EmptyState } from '../../components/EmptyState';
|
||||
import { SegmentedGroup, SegmentedItem } from '../../components/LedgerRow';
|
||||
import { Money } from '../../components/Money';
|
||||
import { useToast } from '../../components/Toast';
|
||||
import { Icon } from '../../design/Icon';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
import { detailDate, parseDate } from '../../lib/dates';
|
||||
import { timestamp } from '../../lib/ids';
|
||||
import { settlementEditPath } from './links';
|
||||
import { apiErrorKey, rejectionMessage, type Message } from './messages';
|
||||
import { deletedSettlement, householdOf, orderedAccounts, type Household } from './model';
|
||||
import { AccountTile, ErrorLine, PersonAvatar, Timeline } from './parts';
|
||||
import styles from './SettlementDetailPage.module.css';
|
||||
import { actorName, auditEntriesOf, entryTime, localToday, timelineLines } from './timeline';
|
||||
|
||||
/** S-10 Settlement detail, /app/settlements/:id. */
|
||||
/**
|
||||
* W-8 Settlement detail, `/app/settlements/:id` (mirrors S-10): payer, recipient, amount, date, note, allocations per
|
||||
* account and the audit timeline; Edit opens W-7 with the payment, Delete soft-deletes it after a confirmation. A
|
||||
* deleted payment shows who deleted it and no actions; an id the ledger does not hold shows the missing state.
|
||||
*/
|
||||
export default function SettlementDetailPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('settle_detail_title')} back={paths.home} />;
|
||||
const { id = '' } = useParams();
|
||||
const ledger = useLedger();
|
||||
const session = useSession();
|
||||
const { refresh } = useLedgerWriter();
|
||||
const settlement = ledger.data?.settlements.get(id);
|
||||
const household = ledger.data === undefined || session === null ? null : householdOf(ledger.data, session.userId);
|
||||
|
||||
if (ledger.data === undefined || session === null) {
|
||||
return (
|
||||
<Frame>
|
||||
{ledger.isError ? (
|
||||
<EmptyState
|
||||
icon="sync_problem"
|
||||
title={t(apiErrorKey(ledger.error))}
|
||||
action={
|
||||
<Button variant="tonal" icon={<Icon name="refresh" size={20} />} onClick={() => void ledger.refetch()}>
|
||||
{t('common_retry')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.status} role="status">
|
||||
{t('web_updating')}
|
||||
</p>
|
||||
)}
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
if (settlement === undefined || household === null) {
|
||||
return (
|
||||
<Frame>
|
||||
<EmptyState
|
||||
icon="sync_problem"
|
||||
title={t('web_settle_missing_title')}
|
||||
body={t('web_settle_missing_body')}
|
||||
action={
|
||||
<Button variant="tonal" icon={<Icon name="sync" size={20} />} onClick={() => void refresh()}>
|
||||
{t('web_update_now')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
return <SettlementDetail settlement={settlement} ledger={ledger.data} household={household} />;
|
||||
}
|
||||
|
||||
function Frame({ children, actions }: { children: ReactNode; actions?: ReactNode }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<>
|
||||
<TopBar title={t('settle_detail_title')} back={paths.home} actions={actions} />
|
||||
<div className={styles.body}>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettlementDetail({
|
||||
settlement,
|
||||
ledger,
|
||||
household,
|
||||
}: {
|
||||
settlement: Settlement;
|
||||
ledger: Ledger;
|
||||
household: Household;
|
||||
}) {
|
||||
const { t, language } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const api = useApi();
|
||||
const { apply, refresh } = useLedgerWriter();
|
||||
const toast = useToast();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [failure, setFailure] = useState<Message | null>(null);
|
||||
|
||||
const deleted = settlement.deleted_at !== null;
|
||||
const today = localToday();
|
||||
const context = { ledger, viewerId: household.me.id, t, language, today };
|
||||
const entries = auditEntriesOf(ledger, 'settlement', settlement.id);
|
||||
const from = ledger.users.get(settlement.from_user_id) ?? household.partner;
|
||||
const to = ledger.users.get(settlement.to_user_id) ?? household.me;
|
||||
const accounts = orderedAccounts(ledger);
|
||||
const allocations = [...settlement.allocations]
|
||||
.sort((a, b) => (a.account_id < b.account_id ? -1 : 1))
|
||||
.flatMap((a) => {
|
||||
const account = ledger.accounts.get(a.account_id);
|
||||
return account === undefined ? [] : [{ account, amountOre: a.amount_ore }];
|
||||
});
|
||||
const note = settlement.note.trim();
|
||||
const heroText =
|
||||
from.id === household.me.id
|
||||
? t('settle_detail_you_paid_partner', to.display_name)
|
||||
: t('settle_detail_partner_paid_you', from.display_name);
|
||||
|
||||
const remove = async () => {
|
||||
setConfirmDelete(false);
|
||||
setDeleting(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
const results = await pushMutations(api, [settlementMutation(deletedSettlement(settlement, timestamp()))]);
|
||||
apply(rowsOfResults(results));
|
||||
void refresh();
|
||||
const result = results[0];
|
||||
if (result === undefined) return;
|
||||
if (result.status === 'accepted') {
|
||||
toast({ message: t('snackbar_payment_deleted') });
|
||||
const index = (window.history.state as { idx?: number } | null)?.idx ?? 0;
|
||||
if (index > 0) void navigate(-1);
|
||||
else void navigate(paths.home, { replace: true });
|
||||
} else {
|
||||
setFailure(rejectionMessage(result, ledger.users, household.partner));
|
||||
}
|
||||
} catch (error) {
|
||||
setFailure({ key: apiErrorKey(error), args: [] });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletion = deleted ? entries.findLast((e) => e.action === 'delete') : undefined;
|
||||
|
||||
return (
|
||||
<Frame
|
||||
actions={
|
||||
deleted ? undefined : (
|
||||
<>
|
||||
<ButtonLink
|
||||
to={settlementEditPath(settlement.id)}
|
||||
variant="text"
|
||||
icon={<Icon name="edit" size={20} />}
|
||||
aria-label={t('settle_title_edit')}
|
||||
>
|
||||
{t('common_edit')}
|
||||
</ButtonLink>
|
||||
<Button
|
||||
variant="text"
|
||||
icon={<Icon name="delete" size={20} />}
|
||||
aria-label={t('settle_detail_delete')}
|
||||
disabled={deleting}
|
||||
onClick={() => {
|
||||
setConfirmDelete(true);
|
||||
}}
|
||||
>
|
||||
{t('common_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{failure === null ? null : <ErrorLine>{t(failure.key, ...failure.args)}</ErrorLine>}
|
||||
{deleted ? (
|
||||
<div className={styles.banner} role="note">
|
||||
<Icon name="delete" size={20} className={styles.bannerIcon} />
|
||||
<span className={styles.bannerText}>
|
||||
<span className={styles.bannerTitle}>
|
||||
{t(
|
||||
'settle_detail_deleted_banner',
|
||||
actorName(deletion?.actor_user_id ?? settlement.updated_by_user_id, context),
|
||||
entryTime(deletion?.client_at ?? settlement.deleted_at ?? settlement.updated_at, context),
|
||||
)}
|
||||
</span>
|
||||
<span>{t('settle_detail_deleted_body')}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={deleted ? styles.faded : styles.content}>
|
||||
<section className={styles.hero} aria-label={heroText}>
|
||||
<span className={styles.people}>
|
||||
<PersonAvatar user={from} householdUserIds={household.userIds} size={52} />
|
||||
<Icon name="arrow_forward" size={28} className={styles.arrow} />
|
||||
<PersonAvatar user={to} householdUserIds={household.userIds} size={52} />
|
||||
</span>
|
||||
<p className={styles.heroText}>{heroText}</p>
|
||||
<Money ore={settlement.amount_ore} amountStyle="large" />
|
||||
</section>
|
||||
<SegmentedGroup>
|
||||
<SegmentedItem>
|
||||
<Fact
|
||||
icon="event"
|
||||
label={t('settle_date')}
|
||||
value={detailDate(parseDate(settlement.settlement_date), language)}
|
||||
/>
|
||||
</SegmentedItem>
|
||||
{note === '' ? null : (
|
||||
<SegmentedItem>
|
||||
<Fact icon="notes" label={t('settle_note')} value={note} />
|
||||
</SegmentedItem>
|
||||
)}
|
||||
</SegmentedGroup>
|
||||
<h2 className={styles.sectionTitle}>{t('settle_allocation')}</h2>
|
||||
<SegmentedGroup label={t('settle_allocation')}>
|
||||
{allocations.map(({ account, amountOre }) => (
|
||||
<SegmentedItem key={account.id}>
|
||||
<div className={styles.allocation}>
|
||||
<AccountTile
|
||||
name={account.name}
|
||||
colorIndex={Math.max(
|
||||
0,
|
||||
accounts.findIndex((a) => a.id === account.id),
|
||||
)}
|
||||
size={36}
|
||||
muted={account.archived_at !== null}
|
||||
/>
|
||||
<span className={styles.accountName}>{account.name}</span>
|
||||
<Money ore={amountOre} />
|
||||
</div>
|
||||
</SegmentedItem>
|
||||
))}
|
||||
</SegmentedGroup>
|
||||
</div>
|
||||
<Timeline lines={timelineLines(entries, context)} />
|
||||
<Dialog
|
||||
open={confirmDelete}
|
||||
alert
|
||||
title={t('settle_detail_delete_title')}
|
||||
onClose={() => {
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
>
|
||||
{t('common_cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => void remove()}>
|
||||
{t('common_delete')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{t('settle_detail_delete_body')}
|
||||
</Dialog>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ icon, label, value }: { icon: 'event' | 'notes'; label: string; value: string }) {
|
||||
return (
|
||||
<div className={styles.fact}>
|
||||
<Icon name={icon} size={20} className={styles.factIcon} />
|
||||
<span className={styles.factText}>
|
||||
<span className={styles.factLabel}>{label}</span>
|
||||
<span className={styles.factValue}>{value}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"web_settle_reject_clock_skew": "This device's clock is wrong. Fix it and try again.",
|
||||
"web_settle_missing_title": "Payment not found",
|
||||
"web_settle_missing_body": "It may have been deleted, or added a moment ago on a phone. Update to check again."
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"web_settle_reject_clock_skew": "Enhetens klocka går fel. Rätta den och försök igen.",
|
||||
"web_settle_missing_title": "Betalningen hittades inte",
|
||||
"web_settle_missing_body": "Den kan ha tagits bort eller lagts till nyss på en telefon. Uppdatera för att se efter igen."
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Links into W-7 (SPEC §15.6: `/app/settle` with an optional account, or a settlement being edited). Query parameters
|
||||
* are W3's (§15.3: "sub-routes and query parameters are the owning plan's"): `?account=<id>` for a per-account
|
||||
* settle-up (S-5 "Settle this account"), `?settlement=<id>` to edit a payment (W-8 "Edit").
|
||||
*/
|
||||
import { paths } from '../../app/routes';
|
||||
|
||||
export const SETTLE_ACCOUNT_PARAM = 'account';
|
||||
export const SETTLE_SETTLEMENT_PARAM = 'settlement';
|
||||
|
||||
/** `/settle?account=<id>`: settle one account (AC-11.4). */
|
||||
export function settleAccountPath(accountId: string): string {
|
||||
return `${paths.settle}?${new URLSearchParams({ [SETTLE_ACCOUNT_PARAM]: accountId }).toString()}`;
|
||||
}
|
||||
|
||||
/** `/settle?settlement=<id>`: edit a saved payment (AC-11.6). */
|
||||
export function settlementEditPath(settlementId: string): string {
|
||||
return `${paths.settle}?${new URLSearchParams({ [SETTLE_SETTLEMENT_PARAM]: settlementId }).toString()}`;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* What W-7 and W-8 say when something is refused: §14.5.3 domain tokens, §14.4.6 rejection reasons and the API
|
||||
* error codes of §14.3.2, as string keys of the catalogs.
|
||||
*/
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { MutationResult, User } from '../../api/types';
|
||||
import type { FormatArg, StringKey } from '../../i18n/catalog';
|
||||
|
||||
export type RejectedResult = Extract<MutationResult, { status: 'rejected' }>;
|
||||
|
||||
export interface Message {
|
||||
key: StringKey;
|
||||
args: FormatArg[];
|
||||
}
|
||||
|
||||
/** A settlement-validity token (§14.5.3) on the allocations, as the app words it (design-spec S-9 "Invalid"). */
|
||||
export function allocationErrorKey(token: string): StringKey {
|
||||
switch (token) {
|
||||
case 'allocation_sum_mismatch':
|
||||
return 'settle_error_allocation_sum';
|
||||
case 'allocation_not_positive':
|
||||
return 'settle_error_allocation_positive';
|
||||
case 'no_allocations':
|
||||
return 'settle_error_no_allocations';
|
||||
case 'too_many_allocations':
|
||||
return 'settle_error_too_many_allocations';
|
||||
default:
|
||||
return 'error_generic_invalid';
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOCATION_TOKENS = new Set([
|
||||
'allocation_sum_mismatch',
|
||||
'allocation_not_positive',
|
||||
'no_allocations',
|
||||
'too_many_allocations',
|
||||
]);
|
||||
|
||||
/**
|
||||
* A rejected `POST /sync` result (§14.4.6). `stale` names who saved the newer version: the stored row's
|
||||
* `updated_by_user_id`, the partner when unknown (D-089). A `validation_failed` carrying a settlement token says which
|
||||
* rule broke.
|
||||
*/
|
||||
export function rejectionMessage(result: RejectedResult, users: ReadonlyMap<string, User>, partner: User): Message {
|
||||
switch (result.reason) {
|
||||
case 'stale': {
|
||||
const current = result.current;
|
||||
const actor = current === null ? undefined : users.get(current.updated_by_user_id);
|
||||
return { key: 'home_sync_reject_stale', args: [(actor ?? partner).display_name] };
|
||||
}
|
||||
case 'deleted':
|
||||
return { key: 'home_sync_reject_deleted', args: [] };
|
||||
case 'reference_not_found':
|
||||
return { key: 'home_sync_reject_reference', args: [] };
|
||||
case 'clock_skew':
|
||||
return { key: 'web_settle_reject_clock_skew', args: [] };
|
||||
case 'validation_failed':
|
||||
return ALLOCATION_TOKENS.has(result.message)
|
||||
? { key: allocationErrorKey(result.message), args: [] }
|
||||
: { key: 'home_sync_reject_validation', args: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed request: "Kunde inte nå servern" for a network error (§15.5), otherwise the code's string. */
|
||||
export function apiErrorKey(error: unknown): StringKey {
|
||||
if (!(error instanceof ApiError)) return 'error_server';
|
||||
switch (error.code) {
|
||||
case 'network':
|
||||
return 'common_could_not_reach_server';
|
||||
case 'household_incomplete':
|
||||
return 'error_household_incomplete';
|
||||
case 'rate_limited':
|
||||
return 'error_rate_limited';
|
||||
case 'protocol_unsupported':
|
||||
return 'error_app_too_old';
|
||||
case 'forbidden':
|
||||
return 'error_forbidden';
|
||||
case 'not_found':
|
||||
return 'error_not_found';
|
||||
case 'validation_failed':
|
||||
case 'invalid_request':
|
||||
case 'batch_too_large':
|
||||
case 'payload_too_large':
|
||||
return 'error_validation';
|
||||
default:
|
||||
return 'error_server';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* W-7 and W-8 rules, computed in the browser as the phone does (SPEC §15.5): a port of the app's
|
||||
* `SettlementRepositoryImpl.settleState` and `SettleViewModel` (`:data`, `:feature:settle`). Pure: the page keeps a
|
||||
* `SettleForm` (what the user typed) and derives everything else from it and the ledger with `settleView`.
|
||||
*/
|
||||
import type { Ledger } from '../../api/ledger';
|
||||
import { live } from '../../api/ledger';
|
||||
import type { Account, PushedSettlement, Settlement, User } from '../../api/types';
|
||||
import { computeBalances } from '../../lib/balances';
|
||||
import { formatDate, fromEpochDays, parseDate, toEpochDays } from '../../lib/dates';
|
||||
import type { Language } from '../../i18n/language';
|
||||
import { amountInputFromText, amountInputOf, amountInputOre, decimalSeparator } from '../../lib/money';
|
||||
import {
|
||||
overSettledAccounts,
|
||||
proposeAllocation,
|
||||
validateSettlement,
|
||||
type AccountPosition,
|
||||
type SettlementError,
|
||||
} from '../../lib/settlement';
|
||||
|
||||
/** Global (AC-11.3, from Home), one account (AC-11.4, from S-5) or editing a saved settlement (AC-11.6). */
|
||||
export type SettleMode = 'global' | 'account' | 'edit';
|
||||
|
||||
/** Longest settlement note in code points after trimming (SPEC §9.6). */
|
||||
export const NOTE_MAX = 200;
|
||||
/** Earliest settlement date (SPEC §9.6). */
|
||||
export const FIRST_DATE = '2000-01-01';
|
||||
/** Latest settlement date: today + 365 days (SPEC §9.6). */
|
||||
export const DAYS_AHEAD_MAX = 365;
|
||||
|
||||
export interface Household {
|
||||
me: User;
|
||||
partner: User;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
/** The viewer and the partner, or null before the partner has joined (S-9 is not offered then, D-111). */
|
||||
export function householdOf(ledger: Ledger, viewerId: string): Household | null {
|
||||
const me = ledger.users.get(viewerId);
|
||||
const partner = [...ledger.users.values()].find((u) => u.id !== viewerId);
|
||||
if (me === undefined || partner === undefined) return null;
|
||||
return { me, partner, userIds: [me.id, partner.id] };
|
||||
}
|
||||
|
||||
/** Non-deleted accounts in the app's order (`created_at`, then id); an account's colour is its position here. */
|
||||
export function orderedAccounts(ledger: Ledger): Account[] {
|
||||
return live(ledger.accounts).sort((a, b) =>
|
||||
a.created_at === b.created_at ? (a.id < b.id ? -1 : 1) : a.created_at < b.created_at ? -1 : 1,
|
||||
);
|
||||
}
|
||||
|
||||
/** Net per account and user over non-deleted rows, leaving out the settlement being edited (AC-11.6, §9.4). */
|
||||
export function netsExcluding(ledger: Ledger, household: Household, excludedSettlementId: string | null) {
|
||||
return computeBalances(
|
||||
household.userIds,
|
||||
orderedAccounts(ledger).map((a) => a.id),
|
||||
live(ledger.expenses).map((e) => ({
|
||||
accountId: e.account_id,
|
||||
amountOre: e.amount_ore,
|
||||
payerUserId: e.payer_user_id,
|
||||
shares: Object.fromEntries(e.splits.map((s) => [s.user_id, s.share_ore])),
|
||||
})),
|
||||
live(ledger.settlements)
|
||||
.filter((s) => s.id !== excludedSettlementId)
|
||||
.map((s) => ({
|
||||
fromUserId: s.from_user_id,
|
||||
toUserId: s.to_user_id,
|
||||
allocations: Object.fromEntries(s.allocations.map((a) => [a.account_id, a.amount_ore])),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** A row the user has typed: the account and the text of its amount field. */
|
||||
export interface RowInput {
|
||||
accountId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** What the user controls on W-7. Everything shown is derived from it with `settleView`. */
|
||||
export interface SettleForm {
|
||||
payerId: string;
|
||||
/** The amount field's text as typed. */
|
||||
amountText: string;
|
||||
/** Null while no row was edited: the rows follow `proposeAllocation` for the amount (design-spec S-9). */
|
||||
rows: RowInput[] | null;
|
||||
/** `YYYY-MM-DD`. */
|
||||
date: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface SettleContext {
|
||||
mode: SettleMode;
|
||||
ledger: Ledger;
|
||||
household: Household;
|
||||
/** The settlement being edited (mode `edit`). */
|
||||
editing: Settlement | null;
|
||||
/** The account of a per-account settle-up (mode `account`). */
|
||||
fixedAccount: Account | null;
|
||||
}
|
||||
|
||||
/** Öre of an amount field's text, or null when it is empty or unreadable. */
|
||||
export function oreOfText(text: string): number | null {
|
||||
const input = amountInputFromText(text);
|
||||
return input === null ? null : amountInputOre(input);
|
||||
}
|
||||
|
||||
/** A stored amount as an amount field first shows it, with both decimals and no grouping: `3250,00` / `3250.00`. */
|
||||
export function amountText(ore: number, language: Language): string {
|
||||
return amountInputOf(ore).raw.replace('.', decimalSeparator(language));
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening form (AC-11.3, AC-11.4, AC-11.6). A new settle-up: the user who owes pays (globally, or in the fixed
|
||||
* account), the amount is the absolute balance or empty when it is zero, the rows follow the proposal, today's date.
|
||||
* Editing: the saved direction, amount, date and note, and the saved rows counted as edited (D-074).
|
||||
*/
|
||||
export function initialForm(context: SettleContext, today: string, language: Language): SettleForm {
|
||||
const { editing, household, fixedAccount } = context;
|
||||
if (editing !== null) {
|
||||
return {
|
||||
payerId: editing.from_user_id,
|
||||
amountText: amountText(editing.amount_ore, language),
|
||||
rows: [...editing.allocations]
|
||||
.sort((a, b) => (a.account_id < b.account_id ? -1 : 1))
|
||||
.map((a) => ({ accountId: a.account_id, text: amountText(a.amount_ore, language) })),
|
||||
date: editing.settlement_date,
|
||||
note: editing.note,
|
||||
};
|
||||
}
|
||||
const balances = netsExcluding(context.ledger, household, null);
|
||||
const myNet =
|
||||
fixedAccount === null
|
||||
? (balances.global[household.me.id] ?? 0)
|
||||
: (balances.perAccount[fixedAccount.id]?.[household.me.id] ?? 0);
|
||||
return {
|
||||
payerId: myNet > 0 ? household.partner.id : household.me.id,
|
||||
amountText: myNet === 0 ? '' : amountText(Math.abs(myNet), language),
|
||||
rows: null,
|
||||
date: today,
|
||||
note: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** One allocation row as shown: the account, the payer's net there, the typed text and its öre, the AC-11.5 flag. */
|
||||
export interface AllocationLine {
|
||||
account: Account;
|
||||
/** Index in `orderedAccounts`, for the account colour (design-spec §2.4). */
|
||||
colorIndex: number;
|
||||
payerNetOre: number;
|
||||
text: string;
|
||||
/** Öre of `text`; null when the field is empty. */
|
||||
ore: number | null;
|
||||
overSettles: boolean;
|
||||
}
|
||||
|
||||
/** An account the rows leave out although the payer is owed there (§9.4 consequences, D-093). */
|
||||
export interface UntouchedAccount {
|
||||
account: Account;
|
||||
payerNetOre: number;
|
||||
}
|
||||
|
||||
export interface SettleView {
|
||||
payer: User;
|
||||
recipient: User;
|
||||
amountOre: number | null;
|
||||
lines: AllocationLine[];
|
||||
/** Amount minus what the rows allocate; negative when they allocate too much. */
|
||||
remainingOre: number;
|
||||
/** `validateSettlement`'s answer for the rows, or `required` while the amount is empty. */
|
||||
error: SettlementError | 'required' | null;
|
||||
canConfirm: boolean;
|
||||
untouched: UntouchedAccount[];
|
||||
/** Every non-deleted account not yet in the rows, archived included (AC-11.3). */
|
||||
addable: Account[];
|
||||
/** The rows are the user's own (an edited row, or a settlement being edited): "Föreslå igen" is offered. */
|
||||
rowsEdited: boolean;
|
||||
}
|
||||
|
||||
/** Everything W-7 shows for a form (`SettlementRepositoryImpl.settleState`, the web's `recompute`). */
|
||||
export function settleView(context: SettleContext, form: SettleForm, language: Language): SettleView {
|
||||
const { household, mode, fixedAccount, ledger } = context;
|
||||
const payer = form.payerId === household.me.id ? household.me : household.partner;
|
||||
const recipient = payer.id === household.me.id ? household.partner : household.me;
|
||||
const accounts = orderedAccounts(ledger);
|
||||
const balances = netsExcluding(ledger, household, context.editing?.id ?? null);
|
||||
const netOf = (accountId: string) => balances.perAccount[accountId]?.[payer.id] ?? 0;
|
||||
const positions: AccountPosition[] = accounts.map((a) => ({
|
||||
accountId: a.id,
|
||||
createdAt: a.created_at,
|
||||
archived: a.archived_at !== null,
|
||||
payerNetOre: netOf(a.id),
|
||||
}));
|
||||
const amountOre = oreOfText(form.amountText);
|
||||
|
||||
let rows: RowInput[];
|
||||
if (mode === 'account' && fixedAccount !== null) {
|
||||
rows = [{ accountId: fixedAccount.id, text: amountOre === null ? '' : amountText(amountOre, language) }];
|
||||
} else if (form.rows !== null) {
|
||||
rows = form.rows;
|
||||
} else if (amountOre === null) {
|
||||
rows = [];
|
||||
} else {
|
||||
const proposal = proposeAllocation(amountOre, positions);
|
||||
rows =
|
||||
'ok' in proposal
|
||||
? proposal.ok.allocations.map((a) => ({ accountId: a.accountId, text: amountText(a.amountOre, language) }))
|
||||
: [];
|
||||
}
|
||||
|
||||
const byId = new Map(accounts.map((a, index) => [a.id, { account: a, index }]));
|
||||
const present = rows.filter((row) => byId.has(row.accountId));
|
||||
const allocations = present.map((row) => ({ accountId: row.accountId, amountOre: oreOfText(row.text) ?? 0 }));
|
||||
const over = new Set(overSettledAccounts(allocations, positions));
|
||||
const lines = present.flatMap((row): AllocationLine[] => {
|
||||
const found = byId.get(row.accountId);
|
||||
if (found === undefined) return [];
|
||||
return [
|
||||
{
|
||||
account: found.account,
|
||||
colorIndex: found.index,
|
||||
payerNetOre: netOf(row.accountId),
|
||||
text: row.text,
|
||||
ore: oreOfText(row.text),
|
||||
overSettles: over.has(row.accountId),
|
||||
},
|
||||
];
|
||||
});
|
||||
const allocated = allocations.reduce((sum, a) => sum + a.amountOre, 0);
|
||||
const error =
|
||||
amountOre === null
|
||||
? 'required'
|
||||
: validateSettlement(amountOre, payer.id, recipient.id, household.userIds, allocations);
|
||||
const listed = new Set(lines.map((l) => l.account.id));
|
||||
const untouched =
|
||||
mode === 'account' || lines.length === 0
|
||||
? []
|
||||
: accounts
|
||||
.map((account) => ({ account, payerNetOre: netOf(account.id) }))
|
||||
.filter((u) => u.payerNetOre > 0 && !listed.has(u.account.id));
|
||||
return {
|
||||
payer,
|
||||
recipient,
|
||||
amountOre,
|
||||
lines,
|
||||
remainingOre: (amountOre ?? 0) - allocated,
|
||||
error,
|
||||
canConfirm: error === null,
|
||||
untouched,
|
||||
addable: mode === 'account' ? [] : accounts.filter((a) => !listed.has(a.id)),
|
||||
rowsEdited: mode !== 'account' && form.rows !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The rows as the user's own, so one of them can be changed (the first row edit stops following the proposal). */
|
||||
export function ownRows(view: SettleView): RowInput[] {
|
||||
return view.lines.map((line) => ({ accountId: line.account.id, text: line.text }));
|
||||
}
|
||||
|
||||
/** Length in Unicode code points (SPEC §9.6). */
|
||||
export function codePoints(text: string): number {
|
||||
return Array.from(text).length;
|
||||
}
|
||||
|
||||
/** A valid `YYYY-MM-DD` from 2000-01-01 to today + 365 days (SPEC §9.6). */
|
||||
export function dateInRange(date: string, today: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return false;
|
||||
const parsed = parseDate(date);
|
||||
if (formatDate(fromEpochDays(toEpochDays(parsed))) !== date) return false;
|
||||
return date >= FIRST_DATE && toEpochDays(parsed) <= toEpochDays(parseDate(today)) + DAYS_AHEAD_MAX;
|
||||
}
|
||||
|
||||
/** The last selectable date of the date field. */
|
||||
export function lastDate(today: string): string {
|
||||
return formatDate(fromEpochDays(toEpochDays(parseDate(today)) + DAYS_AHEAD_MAX));
|
||||
}
|
||||
|
||||
/**
|
||||
* The pushed entity of SPEC §14.4.3 for a confirmed form: a new UUIDv7 for a new settlement, the stored id and
|
||||
* `created_at` when editing; allocations sorted by account id; the note trimmed (D-034).
|
||||
*/
|
||||
export function pushedSettlement(
|
||||
view: SettleView,
|
||||
form: SettleForm,
|
||||
editing: Settlement | null,
|
||||
id: string,
|
||||
now: string,
|
||||
): PushedSettlement {
|
||||
return {
|
||||
id: editing?.id ?? id,
|
||||
from_user_id: view.payer.id,
|
||||
to_user_id: view.recipient.id,
|
||||
amount_ore: view.amountOre ?? 0,
|
||||
settlement_date: form.date,
|
||||
note: form.note.trim(),
|
||||
allocations: view.lines
|
||||
.map((line) => ({ account_id: line.account.id, amount_ore: line.ore ?? 0 }))
|
||||
.sort((a, b) => (a.account_id < b.account_id ? -1 : 1)),
|
||||
created_at: editing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
deleted_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A soft delete (SPEC §14.4.3: the full entity with `deleted_at` set). */
|
||||
export function deletedSettlement(settlement: Settlement, now: string): PushedSettlement {
|
||||
return {
|
||||
id: settlement.id,
|
||||
from_user_id: settlement.from_user_id,
|
||||
to_user_id: settlement.to_user_id,
|
||||
amount_ore: settlement.amount_ore,
|
||||
settlement_date: settlement.settlement_date,
|
||||
note: settlement.note,
|
||||
allocations: settlement.allocations,
|
||||
created_at: settlement.created_at,
|
||||
updated_at: now,
|
||||
deleted_at: now,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/* design-spec §3.4 AccountTile: chart-cat-(n mod 8 + 1), first letter in on-cat; fallback 16 px rounded. */
|
||||
.tile {
|
||||
display: inline-grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-large);
|
||||
color: var(--on-cat);
|
||||
font: var(--type-title-medium);
|
||||
font-weight: 700;
|
||||
}
|
||||
.cat1 {
|
||||
background: var(--chart-cat-1);
|
||||
}
|
||||
.cat2 {
|
||||
background: var(--chart-cat-2);
|
||||
}
|
||||
.cat3 {
|
||||
background: var(--chart-cat-3);
|
||||
}
|
||||
.cat4 {
|
||||
background: var(--chart-cat-4);
|
||||
}
|
||||
.cat5 {
|
||||
background: var(--chart-cat-5);
|
||||
}
|
||||
.cat6 {
|
||||
background: var(--chart-cat-6);
|
||||
}
|
||||
.cat7 {
|
||||
background: var(--chart-cat-7);
|
||||
}
|
||||
.cat8 {
|
||||
background: var(--chart-cat-8);
|
||||
}
|
||||
.muted {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* design-spec §3.8 DirectionCard: card, padding 14 / 14 / 14 / 18, gap 12. */
|
||||
.direction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: 14px 14px 14px 18px;
|
||||
border-radius: var(--radius-extra-large);
|
||||
background: var(--card);
|
||||
box-shadow: var(--elevation-card);
|
||||
}
|
||||
.people {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
}
|
||||
.arrow {
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.directionText {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.overline {
|
||||
font: var(--type-label-medium);
|
||||
letter-spacing: var(--tracking-label);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.sentence {
|
||||
font: var(--type-title-medium);
|
||||
letter-spacing: var(--tracking-title-medium);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.swap,
|
||||
.iconButton {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: var(--touch-target);
|
||||
height: var(--touch-target);
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
}
|
||||
.swap {
|
||||
background: var(--secondary-container);
|
||||
color: var(--on-secondary-container);
|
||||
}
|
||||
.swap:hover {
|
||||
background: color-mix(in srgb, var(--on-secondary-container) 8%, var(--secondary-container));
|
||||
}
|
||||
.iconButton {
|
||||
background: transparent;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.iconButton:hover {
|
||||
background: color-mix(in srgb, var(--on-surface) 8%, transparent);
|
||||
}
|
||||
.swap:focus-visible,
|
||||
.iconButton:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* design-spec §3.8 AllocationRow: SegmentedGroup item, padding 12 / 14, gap 8. */
|
||||
.allocation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding: var(--s3) 14px;
|
||||
}
|
||||
.allocationMain {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s2) var(--s3);
|
||||
}
|
||||
.allocationText {
|
||||
display: flex;
|
||||
flex: 1 1 10rem;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.accountName {
|
||||
font: var(--type-title-medium);
|
||||
letter-spacing: var(--tracking-title-medium);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.position {
|
||||
font: var(--type-body-small);
|
||||
letter-spacing: var(--tracking-body-small);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
/* The 148 px amount field; the label is for assistive technology only ("Belopp för Huset"). */
|
||||
.rowAmount {
|
||||
flex: 0 1 148px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rowAmount > label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rowAmount > div:first-of-type {
|
||||
min-height: var(--touch-target);
|
||||
border-radius: 14px;
|
||||
background: var(--surface-container-highest);
|
||||
}
|
||||
.rowAmount input {
|
||||
text-align: end;
|
||||
font: var(--type-amount-medium);
|
||||
}
|
||||
.rowAmountWarn > div:first-of-type {
|
||||
outline: 2px solid var(--warning);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.warning,
|
||||
.errorLine,
|
||||
.info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s2);
|
||||
margin: 0;
|
||||
}
|
||||
.warning {
|
||||
padding: var(--s2) var(--s3);
|
||||
border-radius: var(--radius-medium);
|
||||
background: var(--warning-container);
|
||||
color: var(--on-warning-container);
|
||||
font: var(--type-body-medium);
|
||||
}
|
||||
.warning > svg {
|
||||
flex: none;
|
||||
}
|
||||
.errorLine {
|
||||
padding: 0 var(--s4);
|
||||
color: var(--error);
|
||||
font: var(--type-body-small);
|
||||
letter-spacing: var(--tracking-body-small);
|
||||
}
|
||||
.info {
|
||||
padding: 0 var(--s2);
|
||||
color: var(--on-surface-variant);
|
||||
font: var(--type-body-small);
|
||||
letter-spacing: var(--tracking-body-small);
|
||||
}
|
||||
.errorLine > svg,
|
||||
.info > svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* design-spec §3.8 RemainingIndicator: radius 16, padding 10 / 14, gap 10. */
|
||||
.remaining {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-detail);
|
||||
padding: var(--gap-detail) 14px;
|
||||
border-radius: var(--radius-large);
|
||||
}
|
||||
.remainingZero {
|
||||
background: var(--surface-container-high);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.remainingOff {
|
||||
background: var(--warning-container);
|
||||
color: var(--on-warning-container);
|
||||
}
|
||||
.remainingOk {
|
||||
color: var(--owed);
|
||||
}
|
||||
.remainingText {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--s2);
|
||||
font: var(--type-body-large);
|
||||
}
|
||||
|
||||
/* design-spec §3.9 Timeline: header title-small, a card with a dot and connector per item. */
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
}
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
padding: var(--s2) var(--s2) 0;
|
||||
font: var(--type-title-small);
|
||||
letter-spacing: var(--tracking-title-small);
|
||||
}
|
||||
.timelineCard {
|
||||
margin: 0;
|
||||
padding: var(--s4) var(--s4) var(--s3);
|
||||
border-radius: var(--radius-extra-large);
|
||||
background: var(--card);
|
||||
box-shadow: var(--elevation-card);
|
||||
list-style: none;
|
||||
}
|
||||
.timelineItem {
|
||||
position: relative;
|
||||
padding: 0 0 var(--s4) calc(24px + var(--s3));
|
||||
font: var(--type-body-medium);
|
||||
letter-spacing: var(--tracking-body-medium);
|
||||
}
|
||||
.timelineItem:last-child {
|
||||
padding-bottom: var(--s1);
|
||||
}
|
||||
.timelineItem::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.4em;
|
||||
left: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--secondary);
|
||||
}
|
||||
.timelineItem:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(0.4em + 16px);
|
||||
bottom: 4px;
|
||||
left: 11px;
|
||||
width: 2px;
|
||||
background: var(--outline-variant);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* W-7 and W-8 building blocks after design-spec §3.4 (`AccountTile`), §3.8 (`DirectionCard`, `AllocationRow`,
|
||||
* `RemainingIndicator`) and §3.9 (`Timeline`). Feature-local until W0 offers shared ones.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { useUserPicture } from '../../api/pictures';
|
||||
import type { User } from '../../api/types';
|
||||
import { AmountField } from '../../components/AmountField';
|
||||
import { Avatar, avatarSlot } from '../../components/Avatar';
|
||||
import { Money } from '../../components/Money';
|
||||
import { Icon } from '../../design/Icon';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
import { formatMoney } from '../../lib/money';
|
||||
import type { AllocationLine } from './model';
|
||||
import styles from './parts.module.css';
|
||||
|
||||
const TILE_CLASSES = [
|
||||
styles.cat1,
|
||||
styles.cat2,
|
||||
styles.cat3,
|
||||
styles.cat4,
|
||||
styles.cat5,
|
||||
styles.cat6,
|
||||
styles.cat7,
|
||||
styles.cat8,
|
||||
];
|
||||
|
||||
/** design-spec §3.4 `AccountTile`: the account colour `chart-cat-(n mod 8 + 1)` and the name's first letter. */
|
||||
export function AccountTile({ name, colorIndex, size = 40, muted = false }: TileProps) {
|
||||
return (
|
||||
<span
|
||||
className={[styles.tile, TILE_CLASSES[colorIndex % TILE_CLASSES.length], muted ? styles.muted : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{(Array.from(name.trim())[0] ?? '').toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TileProps {
|
||||
name: string;
|
||||
colorIndex: number;
|
||||
size?: number;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
/** A household member's avatar with the picture when there is one. */
|
||||
export function PersonAvatar({
|
||||
user,
|
||||
householdUserIds,
|
||||
size,
|
||||
}: {
|
||||
user: User;
|
||||
householdUserIds: string[];
|
||||
size: number;
|
||||
}) {
|
||||
const picture = useUserPicture(user.id, user.picture_version);
|
||||
return (
|
||||
<Avatar
|
||||
displayName={user.display_name}
|
||||
slot={avatarSlot(user.id, householdUserIds)}
|
||||
size={size}
|
||||
pictureUrl={picture}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** design-spec §3.8 `DirectionCard`: payer → recipient, "Riktning" over the sentence, and the swap button. */
|
||||
export function DirectionCard({
|
||||
payer,
|
||||
recipient,
|
||||
householdUserIds,
|
||||
sentence,
|
||||
onSwap,
|
||||
}: {
|
||||
payer: User;
|
||||
recipient: User;
|
||||
householdUserIds: string[];
|
||||
sentence: string;
|
||||
onSwap: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<section className={styles.direction} aria-label={t('settle_direction')}>
|
||||
<span className={styles.people}>
|
||||
<PersonAvatar user={payer} householdUserIds={householdUserIds} size={40} />
|
||||
<Icon name="arrow_forward" size={20} className={styles.arrow} />
|
||||
<PersonAvatar user={recipient} householdUserIds={householdUserIds} size={40} />
|
||||
</span>
|
||||
<span className={styles.directionText}>
|
||||
<span className={styles.overline}>{t('settle_direction')}</span>
|
||||
<span className={styles.sentence} aria-live="polite">
|
||||
{sentence}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.swap}
|
||||
onClick={onSwap}
|
||||
aria-label={t('settle_swap')}
|
||||
title={t('settle_swap')}
|
||||
>
|
||||
<Icon name="swap_horiz" />
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The over-settle strip (design-spec §3.8): filled `warning` icon and the sentence on `warning-container`. */
|
||||
export function WarningStrip({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className={styles.warning}>
|
||||
<Icon name="warning" filled size={20} />
|
||||
<span>{children}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** An error under a field group: filled `error` icon and body-small in `error`. */
|
||||
export function ErrorLine({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className={styles.errorLine} role="alert">
|
||||
<Icon name="error" filled size={18} />
|
||||
<span>{children}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The §9.4 info line under the rows: `info` and body-small muted. */
|
||||
export function InfoLine({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className={styles.info}>
|
||||
<Icon name="info" size={18} />
|
||||
<span>{children}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* design-spec §3.8 `AllocationRow`: account tile, name over the payer's position, the amount field (or the plain
|
||||
* amount of a per-account row, which follows the settlement amount) and the remove button; the warning strip when
|
||||
* the row settles more than is owed (AC-11.5).
|
||||
*/
|
||||
export function AllocationRow({
|
||||
line,
|
||||
position,
|
||||
fixed,
|
||||
fieldId,
|
||||
onAmount,
|
||||
onRemove,
|
||||
}: {
|
||||
line: AllocationLine;
|
||||
position: string;
|
||||
fixed: boolean;
|
||||
/** Id of the amount field, so the page can focus a newly added row. */
|
||||
fieldId: string;
|
||||
onAmount: (text: string) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const name = line.account.name;
|
||||
return (
|
||||
<div className={styles.allocation}>
|
||||
<div className={styles.allocationMain}>
|
||||
<AccountTile name={name} colorIndex={line.colorIndex} muted={line.account.archived_at !== null} />
|
||||
<span className={styles.allocationText}>
|
||||
<span className={styles.accountName}>{name}</span>
|
||||
<span className={styles.position}>{position}</span>
|
||||
</span>
|
||||
{fixed ? (
|
||||
<Money ore={line.ore ?? 0} amountStyle="medium" />
|
||||
) : (
|
||||
<>
|
||||
<AmountField
|
||||
label={t('settle_row_amount', name)}
|
||||
value={line.text}
|
||||
onChange={(text) => {
|
||||
onAmount(text);
|
||||
}}
|
||||
id={fieldId}
|
||||
className={[styles.rowAmount, line.overSettles ? styles.rowAmountWarn : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconButton}
|
||||
onClick={onRemove}
|
||||
aria-label={t('settle_row_remove', name)}
|
||||
title={t('settle_row_remove', name)}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{line.overSettles ? <WarningStrip>{t('settle_over_settle_warning', name)}</WarningStrip> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** design-spec §3.8 `RemainingIndicator`: "Kvar att fördela" with the amount, in words when too much; polite. */
|
||||
export function RemainingIndicator({ remainingOre }: { remainingOre: number }) {
|
||||
const { t, language } = useI18n();
|
||||
const amount = formatMoney(remainingOre, language);
|
||||
return (
|
||||
<div className={[styles.remaining, remainingOre === 0 ? styles.remainingZero : styles.remainingOff].join(' ')}>
|
||||
{remainingOre === 0 ? (
|
||||
<Icon name="check_circle" filled size={20} className={styles.remainingOk} />
|
||||
) : (
|
||||
<Icon name="error" filled size={20} />
|
||||
)}
|
||||
<span role="status" aria-live="polite" className={styles.remainingText}>
|
||||
<span>{t('settle_remaining')}</span>
|
||||
<span className="amount">{remainingOre < 0 ? t('common_too_much', amount) : amount}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** design-spec §3.9 `Timeline`: "Historik" and the entries, oldest first. */
|
||||
export function Timeline({ lines }: { lines: string[] }) {
|
||||
const { t } = useI18n();
|
||||
if (lines.length === 0) return null;
|
||||
return (
|
||||
<section className={styles.timeline} aria-labelledby="settle-timeline-title">
|
||||
<h2 id="settle-timeline-title" className={styles.sectionTitle}>
|
||||
{t('timeline_title')}
|
||||
</h2>
|
||||
<ol className={styles.timelineCard}>
|
||||
{lines.map((line, index) => (
|
||||
// Lines may repeat (two identical changes) and never reorder, so the position is the key.
|
||||
<li key={index} className={styles.timelineItem}>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The household W3's tests use (design-spec S-9's frames): Jacob (the viewer) and Anna with three accounts.
|
||||
* Vardag: Jacob paid, Anna owes Jacob 1 184,50. Huset: Anna paid, Jacob owes 3 084,50. Japan 2027: Jacob owes 165,50.
|
||||
* Globally Jacob owes Anna 2 065,50.
|
||||
*/
|
||||
import type { Account, AuditEntry, Expense, PushedSettlement, Settlement, SyncPullResponse } from '../../api/types';
|
||||
import { FakeServer, json, type RecordedRequest } from '../../test/fakeServer';
|
||||
import { ANNA, ANNA_ID, GROCERIES, JACOB, JACOB_ID, TS, account, expense, page, settlement } from '../../test/fixtures';
|
||||
|
||||
export const VARDAG_ID = '01929c5e-0000-7000-8000-0000000000b1';
|
||||
export const HUSET_ID = '01929c5e-0000-7000-8000-0000000000b2';
|
||||
export const JAPAN_ID = '01929c5e-0000-7000-8000-0000000000b3';
|
||||
export const SETTLEMENT_ID = '01929c5e-0000-7000-8000-0000000000c1';
|
||||
|
||||
export const VARDAG: Account = { ...account(VARDAG_ID, 'Vardag', 20), created_at: '2026-01-01T08:00:00.000Z' };
|
||||
export const HUSET: Account = { ...account(HUSET_ID, 'Huset', 21), created_at: '2026-02-01T08:00:00.000Z' };
|
||||
export const JAPAN: Account = { ...account(JAPAN_ID, 'Japan 2027', 22), created_at: '2026-03-01T08:00:00.000Z' };
|
||||
|
||||
const half = (ore: number) => ({ [JACOB_ID]: ore / 2, [ANNA_ID]: ore / 2 });
|
||||
|
||||
export const VARDAG_EXPENSE: Expense = expense(
|
||||
'01929c5e-0000-7000-8000-0000000000e1',
|
||||
VARDAG_ID,
|
||||
236_900,
|
||||
JACOB_ID,
|
||||
half(236_900),
|
||||
30,
|
||||
);
|
||||
export const HUSET_EXPENSE: Expense = expense(
|
||||
'01929c5e-0000-7000-8000-0000000000e2',
|
||||
HUSET_ID,
|
||||
616_900,
|
||||
ANNA_ID,
|
||||
half(616_900),
|
||||
31,
|
||||
);
|
||||
export const JAPAN_EXPENSE: Expense = expense(
|
||||
'01929c5e-0000-7000-8000-0000000000e3',
|
||||
JAPAN_ID,
|
||||
33_100,
|
||||
ANNA_ID,
|
||||
half(33_100),
|
||||
32,
|
||||
);
|
||||
|
||||
/** Jacob paid Anna 500,00 for Japan 2027 (over-settles it: Jacob owed 165,50 there before). */
|
||||
export const SAVED: Settlement = {
|
||||
...settlement(SETTLEMENT_ID, JACOB_ID, ANNA_ID, { [JAPAN_ID]: 50_000 }, 40),
|
||||
note: 'Swish',
|
||||
created_at: '2026-10-16T12:00:00.000Z',
|
||||
updated_at: '2026-10-16T12:00:00.000Z',
|
||||
};
|
||||
|
||||
export function audit(
|
||||
id: string,
|
||||
action: AuditEntry['action'],
|
||||
actor: string,
|
||||
clientAt: string,
|
||||
serverVersion: number,
|
||||
changes: AuditEntry['changes'] = [],
|
||||
): AuditEntry {
|
||||
return {
|
||||
id,
|
||||
entity_type: 'settlement',
|
||||
entity_id: SETTLEMENT_ID,
|
||||
action,
|
||||
source: 'app',
|
||||
actor_user_id: actor,
|
||||
client_at: clientAt,
|
||||
server_at: clientAt,
|
||||
changes,
|
||||
server_version: serverVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export function rows(extra: Partial<SyncPullResponse> = {}): Partial<SyncPullResponse> {
|
||||
return {
|
||||
users: [JACOB, ANNA],
|
||||
categories: [GROCERIES],
|
||||
accounts: [VARDAG, HUSET, JAPAN],
|
||||
expenses: [VARDAG_EXPENSE, HUSET_EXPENSE, JAPAN_EXPENSE],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /sync`: everything from cursor 0, nothing new afterwards. */
|
||||
export function ledgerServer(content: Partial<SyncPullResponse> = rows()): FakeServer {
|
||||
const full = page(0, content);
|
||||
return new FakeServer().on('GET', '/sync', (request) =>
|
||||
json(new URLSearchParams(request.search).get('since') === '0' ? full : page(full.next_since, {})),
|
||||
);
|
||||
}
|
||||
|
||||
interface PushBody {
|
||||
mutations: { mutation_id: string; entity_type: string; entity: PushedSettlement }[];
|
||||
}
|
||||
|
||||
export function pushed(request: RecordedRequest | undefined): PushBody['mutations'][number] | undefined {
|
||||
return (request?.body as PushBody | undefined)?.mutations[0];
|
||||
}
|
||||
|
||||
/** `POST /sync` answering `accepted` with the pushed entity as stored. */
|
||||
export function acceptPush(server: FakeServer): FakeServer {
|
||||
return server.on('POST', '/sync', (request) => {
|
||||
const mutation = pushed(request);
|
||||
if (mutation === undefined) return json({}, 400);
|
||||
const entity: Settlement = {
|
||||
...mutation.entity,
|
||||
created_by_user_id: JACOB_ID,
|
||||
updated_by_user_id: JACOB_ID,
|
||||
server_version: 999,
|
||||
};
|
||||
return json({
|
||||
server_time: TS,
|
||||
results: [
|
||||
{
|
||||
mutation_id: mutation.mutation_id,
|
||||
entity_type: 'settlement',
|
||||
entity_id: entity.id,
|
||||
status: 'accepted',
|
||||
server_version: 999,
|
||||
entity,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** `POST /sync` answering `rejected` with `reason` and the stored row. */
|
||||
export function rejectPush(
|
||||
server: FakeServer,
|
||||
reason: string,
|
||||
current: Settlement | null,
|
||||
message = 'fake rejection',
|
||||
): FakeServer {
|
||||
return server.on('POST', '/sync', (request) => {
|
||||
const mutation = pushed(request);
|
||||
return json({
|
||||
server_time: TS,
|
||||
results: [
|
||||
{
|
||||
mutation_id: mutation?.mutation_id,
|
||||
entity_type: 'settlement',
|
||||
entity_id: mutation?.entity.id,
|
||||
status: 'rejected',
|
||||
reason,
|
||||
message,
|
||||
current,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The audit timeline of W-8 (AC-12.4, design-spec §3.9), as the app's `Timeline` and `:data` `timeline` build it: one
|
||||
* entry per audit row, oldest first; one sentence per changed field; ids resolved to names, öre as money, dates as list
|
||||
* dates. The web has no pending rows, so AC-12.5's last line never appears.
|
||||
*/
|
||||
import type { Ledger } from '../../api/ledger';
|
||||
import type { AuditEntry } from '../../api/types';
|
||||
import type { FormatArg, StringKey } from '../../i18n/catalog';
|
||||
import type { Language } from '../../i18n/language';
|
||||
import { listDate, listDateTime, localDateTime, parseDate, type LocalDate } from '../../lib/dates';
|
||||
import { formatMoney } from '../../lib/money';
|
||||
|
||||
type Translate = (key: StringKey, ...args: FormatArg[]) => string;
|
||||
|
||||
const FIELD_NAMES: Readonly<Record<string, StringKey>> = {
|
||||
account_id: 'timeline_field_account',
|
||||
category_id: 'timeline_field_category',
|
||||
payer_user_id: 'timeline_field_payer',
|
||||
amount_ore: 'timeline_field_amount',
|
||||
description: 'timeline_field_description',
|
||||
notes: 'timeline_field_notes',
|
||||
expense_date: 'timeline_field_date',
|
||||
settlement_date: 'timeline_field_date',
|
||||
split_mode: 'timeline_field_split_mode',
|
||||
splits: 'timeline_field_splits',
|
||||
from_user_id: 'timeline_field_from',
|
||||
to_user_id: 'timeline_field_to',
|
||||
note: 'timeline_field_note',
|
||||
allocations: 'timeline_field_allocations',
|
||||
};
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Audit entries of one entity, in the order the server appended them. */
|
||||
export function auditEntriesOf(ledger: Ledger, entityType: AuditEntry['entity_type'], entityId: string): AuditEntry[] {
|
||||
return [...ledger.auditEntries.values()]
|
||||
.filter((e) => e.entity_type === entityType && e.entity_id === entityId)
|
||||
.sort((a, b) => a.server_version - b.server_version);
|
||||
}
|
||||
|
||||
interface Context {
|
||||
ledger: Ledger;
|
||||
viewerId: string;
|
||||
t: Translate;
|
||||
language: Language;
|
||||
today: LocalDate;
|
||||
}
|
||||
|
||||
/** "Du" for the viewer, the display name otherwise (`personName`). */
|
||||
export function actorName(userId: string | null, context: Context): string {
|
||||
if (userId === null) return '';
|
||||
if (userId === context.viewerId) return context.t('common_you');
|
||||
return context.ledger.users.get(userId)?.display_name ?? '';
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string') return value === '' ? null : value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** A diff value for display (§14.4.7); null when empty. */
|
||||
export function fieldValue(field: string, value: unknown, context: Context): string | null {
|
||||
const { ledger, language, today } = context;
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
switch (field) {
|
||||
case 'amount_ore':
|
||||
return typeof value === 'number' ? formatMoney(value, language) : text(value);
|
||||
case 'expense_date':
|
||||
case 'settlement_date':
|
||||
return typeof value === 'string' && ISO_DATE.test(value)
|
||||
? listDate(parseDate(value), today, language)
|
||||
: text(value);
|
||||
case 'payer_user_id':
|
||||
case 'from_user_id':
|
||||
case 'to_user_id':
|
||||
return typeof value === 'string' ? (ledger.users.get(value)?.display_name ?? value) : text(value);
|
||||
case 'account_id':
|
||||
return typeof value === 'string' ? (ledger.accounts.get(value)?.name ?? value) : text(value);
|
||||
case 'allocations': {
|
||||
if (!Array.isArray(value)) return text(value);
|
||||
const parts = value.map((item: unknown) => {
|
||||
const row = (typeof item === 'object' && item !== null ? item : {}) as Record<string, unknown>;
|
||||
const id = typeof row.account_id === 'string' ? row.account_id : '';
|
||||
const ore = typeof row.amount_ore === 'number' ? formatMoney(row.amount_ore, language) : '';
|
||||
return `${ledger.accounts.get(id)?.name ?? id} ${ore}`.trim();
|
||||
});
|
||||
return parts.length === 0 ? null : parts.join('; ');
|
||||
}
|
||||
default:
|
||||
return text(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** The sentences of one entry (several changes: one sentence each). */
|
||||
export function entrySentences(entry: AuditEntry, context: Context): string[] {
|
||||
const { t } = context;
|
||||
const actor = actorName(entry.actor_user_id, context);
|
||||
if (entry.action === 'create') {
|
||||
if (entry.source === 'recurring') return [t('timeline_recurring')];
|
||||
if (entry.source === 'import') return [t('timeline_import')];
|
||||
return [t('timeline_created', actor)];
|
||||
}
|
||||
if (entry.action === 'delete') return [t('timeline_deleted', actor)];
|
||||
return entry.changes.map((change) => {
|
||||
const nameKey = FIELD_NAMES[change.field];
|
||||
const name = nameKey === undefined ? change.field : t(nameKey);
|
||||
const before = fieldValue(change.field, change.old, context);
|
||||
const after = fieldValue(change.field, change.new, context);
|
||||
if (before === null && after !== null) return t('timeline_changed_added', actor, name, after);
|
||||
if (after === null) return t('timeline_changed_removed', actor, name);
|
||||
return t('timeline_changed', actor, name, before ?? '', after);
|
||||
});
|
||||
}
|
||||
|
||||
/** "16 okt. 14:02" in the browser's time zone. */
|
||||
export function entryTime(clientAt: string, context: Context): string {
|
||||
return listDateTime(new Date(clientAt), context.today, context.language);
|
||||
}
|
||||
|
||||
/** Every line of the timeline, oldest first: "Anna skapade den här · 16 okt. 14:02". */
|
||||
export function timelineLines(entries: readonly AuditEntry[], context: Context): string[] {
|
||||
return entries.flatMap((entry) => {
|
||||
const time = context.t('timeline_time', entryTime(entry.client_at, context));
|
||||
return entrySentences(entry, context).map((sentence) => `${sentence} ${time}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Today in the browser's time zone, for list dates. */
|
||||
export function localToday(now: Date = new Date()): LocalDate {
|
||||
return localDateTime(now);
|
||||
}
|
||||
Reference in New Issue
Block a user