W3 web settle up: W-7 settle up and W-8 settlement detail
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:
2026-10-21 21:25:29 +02:00
parent 934eee793c
commit f9a9bfb472
16 changed files with 3048 additions and 6 deletions
@@ -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);
}
+329
View File
@@ -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();
});
});
+625 -3
View File
@@ -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>
);
}
+5
View File
@@ -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."
}
+5
View File
@@ -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."
}
+19
View File
@@ -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()}`;
}
+88
View File
@@ -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';
}
}
+221
View File
@@ -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');
});
});
+318
View File
@@ -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,
};
}
+273
View File
@@ -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);
}
+243
View File
@@ -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>
);
}
+153
View File
@@ -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,
},
],
});
});
}
+133
View File
@@ -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);
}