From f9a9bfb472b22a81be6ea4d739f786f91917dcdc Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 21 Oct 2026 21:25:29 +0200 Subject: [PATCH] W3 web settle up: W-7 settle up and W-8 settlement detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W-7 (/app/settle, mirrors S-9): global, per account (?account=) and editing (?settlement=). 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- web/src/features/settle/SettlePage.module.css | 117 ++++ web/src/features/settle/SettlePage.test.tsx | 329 +++++++++ web/src/features/settle/SettlePage.tsx | 628 +++++++++++++++++- .../settle/SettlementDetailPage.module.css | 117 ++++ .../settle/SettlementDetailPage.test.tsx | 124 ++++ .../features/settle/SettlementDetailPage.tsx | 281 +++++++- web/src/features/settle/i18n/en.json | 5 + web/src/features/settle/i18n/sv.json | 5 + web/src/features/settle/links.ts | 19 + web/src/features/settle/messages.ts | 88 +++ web/src/features/settle/model.test.ts | 221 ++++++ web/src/features/settle/model.ts | 318 +++++++++ web/src/features/settle/parts.module.css | 273 ++++++++ web/src/features/settle/parts.tsx | 243 +++++++ web/src/features/settle/testScenario.ts | 153 +++++ web/src/features/settle/timeline.ts | 133 ++++ 16 files changed, 3048 insertions(+), 6 deletions(-) create mode 100644 web/src/features/settle/SettlePage.module.css create mode 100644 web/src/features/settle/SettlePage.test.tsx create mode 100644 web/src/features/settle/SettlementDetailPage.module.css create mode 100644 web/src/features/settle/SettlementDetailPage.test.tsx create mode 100644 web/src/features/settle/i18n/en.json create mode 100644 web/src/features/settle/i18n/sv.json create mode 100644 web/src/features/settle/links.ts create mode 100644 web/src/features/settle/messages.ts create mode 100644 web/src/features/settle/model.test.ts create mode 100644 web/src/features/settle/model.ts create mode 100644 web/src/features/settle/parts.module.css create mode 100644 web/src/features/settle/parts.tsx create mode 100644 web/src/features/settle/testScenario.ts create mode 100644 web/src/features/settle/timeline.ts diff --git a/web/src/features/settle/SettlePage.module.css b/web/src/features/settle/SettlePage.module.css new file mode 100644 index 0000000..98f902c --- /dev/null +++ b/web/src/features/settle/SettlePage.module.css @@ -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); +} diff --git a/web/src/features/settle/SettlePage.test.tsx b/web/src/features/settle/SettlePage.test.tsx new file mode 100644 index 0000000..31c04ef --- /dev/null +++ b/web/src/features/settle/SettlePage.test.tsx @@ -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((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(); + }); +}); diff --git a/web/src/features/settle/SettlePage.tsx b/web/src/features/settle/SettlePage.tsx index 44c49af..9655dee 100644 --- a/web/src/features/settle/SettlePage.tsx +++ b/web/src/features/settle/SettlePage.tsx @@ -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=` for one account (S-5 "Settle this + * account"), `?settlement=` 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 ; + const [params] = useSearchParams(); + const target = targetOf(params); + const ledger = useLedger(); + const session = useSession(); + if (ledger.data === undefined || session === null) { + return ( + <> + +
+ {ledger.isError ? ( + } onClick={() => void ledger.refetch()}> + {t('common_retry')} + + } + /> + ) : ( +

+ {t('web_updating')} +

+ )} +
+ + ); + } + return ; +} + +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 ( + <> + +
+ + {t('common_back')} + + } + /> +
+ + ); + } + return ( + + ); +} + +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(null); + const [saving, setSaving] = useState(false); + const [failure, setFailure] = useState(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 ( + <> + +
{ + event.preventDefault(); + onConfirm(); + }} + > + {failure === null ? null : {t(failure.key, ...failure.args)}} + { + change({ ...form, payerId: view.recipient.id, rows: null }); + }} + /> + { + change({ ...form, amountText: text }); + }} + /> +

{t('settle_allocation')}

+ {view.lines.length === 0 ? ( + view.amountOre === null ? ( +

{t('settle_empty_hint')}

+ ) : null + ) : ( + + {view.lines.map((line) => ( + + { + 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) }); + }} + /> + + ))} + + )} + {allocationError === null ? null : {t(allocationError)}} + {mode !== 'account' && view.lines.length > 0 ? : null} + {view.addable.length > 0 || view.rowsEdited ? ( +
+ {view.addable.length > 0 ? ( + + ) : null} + {view.rowsEdited ? ( + + ) : null} +
+ ) : null} + + { + setDateError(false); + change({ ...form, date }); + }} + /> + { + change({ ...form, note }); + }} + /> +
+ + +
+ + + orderedAccounts(context.ledger).findIndex((a) => a.id === id)} + onClose={() => { + setDialog(null); + }} + onPick={(accountId) => { + setDialog(null); + setFocusRequest({ accountId }); + change({ ...form, rows: [...ownRows(view), { accountId, text: '' }] }); + }} + /> + { + setDialog(null); + }} + onConfirm={() => void save()} + /> + { + if (blocker.state === 'blocked') blocker.reset(); + }} + actions={ + <> + + + + } + /> + + ); +} + +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['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 ( + + {view.payer.id === household.me.id + ? t('settle_untouched_partner_owes', names, partner) + : t('settle_untouched_you_owe', names, partner)} + + ); +} + +/** "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 ( + + {t('common_cancel')} + + } + > +
    + {accounts.map((account) => ( +
  • + +
  • + ))} +
+
+ ); +} + +/** + * 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 ( + + + + + } + > +

{body}

+
    + {view.lines.map((line) => ( +
  • + {line.account.name} + +
  • + ))} +
+ {view.lines + .filter((line) => line.overSettles) + .map((line) => ( + {t('settle_over_settle_warning', line.account.name)} + ))} +
+ ); } diff --git a/web/src/features/settle/SettlementDetailPage.module.css b/web/src/features/settle/SettlementDetailPage.module.css new file mode 100644 index 0000000..1918d2f --- /dev/null +++ b/web/src/features/settle/SettlementDetailPage.module.css @@ -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; +} diff --git a/web/src/features/settle/SettlementDetailPage.test.tsx b/web/src/features/settle/SettlementDetailPage.test.tsx new file mode 100644 index 0000000..2f8bf15 --- /dev/null +++ b/web/src/features/settle/SettlementDetailPage.test.tsx @@ -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(); + }); +}); diff --git a/web/src/features/settle/SettlementDetailPage.tsx b/web/src/features/settle/SettlementDetailPage.tsx index 9c9e8a5..50434e8 100644 --- a/web/src/features/settle/SettlementDetailPage.tsx +++ b/web/src/features/settle/SettlementDetailPage.tsx @@ -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 ; + 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 ( + + {ledger.isError ? ( + } onClick={() => void ledger.refetch()}> + {t('common_retry')} + + } + /> + ) : ( +

+ {t('web_updating')} +

+ )} + + ); + } + if (settlement === undefined || household === null) { + return ( + + } onClick={() => void refresh()}> + {t('web_update_now')} + + } + /> + + ); + } + return ; +} + +function Frame({ children, actions }: { children: ReactNode; actions?: ReactNode }) { + const { t } = useI18n(); + return ( + <> + +
{children}
+ + ); +} + +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(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 ( + + } + aria-label={t('settle_title_edit')} + > + {t('common_edit')} + + + + ) + } + > + {failure === null ? null : {t(failure.key, ...failure.args)}} + {deleted ? ( +
+ + + + {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), + )} + + {t('settle_detail_deleted_body')} + +
+ ) : null} +
+
+ + + + + +

{heroText}

+ +
+ + + + + {note === '' ? null : ( + + + + )} + +

{t('settle_allocation')}

+ + {allocations.map(({ account, amountOre }) => ( + +
+ a.id === account.id), + )} + size={36} + muted={account.archived_at !== null} + /> + {account.name} + +
+
+ ))} +
+
+ + { + setConfirmDelete(false); + }} + actions={ + <> + + + + } + > + {t('settle_detail_delete_body')} + + + ); +} + +function Fact({ icon, label, value }: { icon: 'event' | 'notes'; label: string; value: string }) { + return ( +
+ + + {label} + {value} + +
+ ); } diff --git a/web/src/features/settle/i18n/en.json b/web/src/features/settle/i18n/en.json new file mode 100644 index 0000000..baed28b --- /dev/null +++ b/web/src/features/settle/i18n/en.json @@ -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." +} diff --git a/web/src/features/settle/i18n/sv.json b/web/src/features/settle/i18n/sv.json new file mode 100644 index 0000000..aea6b7e --- /dev/null +++ b/web/src/features/settle/i18n/sv.json @@ -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." +} diff --git a/web/src/features/settle/links.ts b/web/src/features/settle/links.ts new file mode 100644 index 0000000..c1ec8b2 --- /dev/null +++ b/web/src/features/settle/links.ts @@ -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=` for a per-account + * settle-up (S-5 "Settle this account"), `?settlement=` 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=`: settle one account (AC-11.4). */ +export function settleAccountPath(accountId: string): string { + return `${paths.settle}?${new URLSearchParams({ [SETTLE_ACCOUNT_PARAM]: accountId }).toString()}`; +} + +/** `/settle?settlement=`: edit a saved payment (AC-11.6). */ +export function settlementEditPath(settlementId: string): string { + return `${paths.settle}?${new URLSearchParams({ [SETTLE_SETTLEMENT_PARAM]: settlementId }).toString()}`; +} diff --git a/web/src/features/settle/messages.ts b/web/src/features/settle/messages.ts new file mode 100644 index 0000000..45a029f --- /dev/null +++ b/web/src/features/settle/messages.ts @@ -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; + +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, 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'; + } +} diff --git a/web/src/features/settle/model.test.ts b/web/src/features/settle/model.test.ts new file mode 100644 index 0000000..258dfee --- /dev/null +++ b/web/src/features/settle/model.test.ts @@ -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 = rows()): Ledger { + return applyPage(emptyLedger(), page(0, content)); +} + +function contextOf(ledger: Ledger, extra: Partial = {}): SettleContext { + const household = householdOf(ledger, JACOB_ID) as Household; + return { mode: 'global', ledger, household, editing: null, fixedAccount: null, ...extra }; +} + +const TODAY = '2026-10-21'; + +describe('opening form (AC-11.3, AC-11.4, AC-11.6)', () => { + it('global: the user who owes pays the absolute global balance; rows follow the proposal', () => { + const context = contextOf(ledgerOf()); + const form = initialForm(context, TODAY, 'sv'); + expect(form).toEqual({ payerId: JACOB_ID, amountText: '2065,50', rows: null, date: TODAY, note: '' }); + const view = settleView(context, form, 'sv'); + expect(view.payer.id).toBe(JACOB_ID); + expect(view.lines.map((l) => [l.account.name, l.ore, l.payerNetOre, l.overSettles])).toEqual([ + ['Huset', 206_550, -308_450, false], + ]); + expect(view.remainingOre).toBe(0); + expect(view.canConfirm).toBe(true); + expect(view.untouched.map((u) => [u.account.name, u.payerNetOre])).toEqual([['Vardag', 118_450]]); + expect(view.rowsEdited).toBe(false); + }); + + it('global, the other direction: the partner pays when the partner owes', () => { + const context = contextOf(ledgerOf(rows({ expenses: [VARDAG_EXPENSE] }))); + const form = initialForm(context, TODAY, 'en'); + expect(form.payerId).toBe(ANNA_ID); + expect(form.amountText).toBe('1184.50'); + expect(settleView(context, form, 'en').lines.map((l) => [l.account.id, l.ore])).toEqual([[VARDAG_ID, 118_450]]); + }); + + it('global, all settled: empty amount, no rows, nothing to confirm', () => { + const context = contextOf(ledgerOf(rows({ expenses: [] }))); + const form = initialForm(context, TODAY, 'sv'); + expect(form.amountText).toBe(''); + const view = settleView(context, form, 'sv'); + expect(view.lines).toEqual([]); + expect(view.error).toBe('required'); + expect(view.canConfirm).toBe(false); + expect(view.untouched).toEqual([]); + }); + + it('per account: direction and amount from that account; one fixed row that follows the amount', () => { + const context = contextOf(ledgerOf(), { mode: 'account', fixedAccount: VARDAG }); + const form = initialForm(context, TODAY, 'sv'); + expect(form.payerId).toBe(ANNA_ID); + expect(form.amountText).toBe('1184,50'); + const view = settleView(context, { ...form, amountText: '2000' }, 'sv'); + expect(view.lines.map((l) => [l.account.id, l.ore])).toEqual([[VARDAG_ID, 200_000]]); + expect(view.lines[0]?.overSettles).toBe(true); + expect(view.untouched).toEqual([]); + expect(view.addable).toEqual([]); + expect(view.rowsEdited).toBe(false); + }); + + it('editing: saved rows count as edited; nets leave the edited settlement out', () => { + const ledger = ledgerOf(rows({ settlements: [SAVED] })); + const context = contextOf(ledger, { mode: 'edit', editing: SAVED }); + const form = initialForm(context, TODAY, 'sv'); + expect(form).toEqual({ + payerId: JACOB_ID, + amountText: '500,00', + rows: [{ accountId: JAPAN_ID, text: '500,00' }], + date: '2026-10-16', + note: 'Swish', + }); + const view = settleView(context, form, 'sv'); + expect(view.lines[0]?.payerNetOre).toBe(-16_550); + expect(view.lines[0]?.overSettles).toBe(true); + expect(view.rowsEdited).toBe(true); + // "Föreslå igen": the proposal for 500,00 goes to the oldest owing account. + expect(settleView(context, { ...form, rows: null }, 'sv').lines.map((l) => [l.account.id, l.ore])).toEqual([ + [HUSET_ID, 50_000], + ]); + }); +}); + +describe('derived view', () => { + const context = contextOf(ledgerOf()); + const base: SettleForm = { payerId: JACOB_ID, amountText: '3250', rows: null, date: TODAY, note: '' }; + + it('re-proposes for a new amount while no row is edited', () => { + expect(settleView(context, base, 'sv').lines.map((l) => [l.account.name, l.ore])).toEqual([ + ['Huset', 308_450], + ['Japan 2027', 16_550], + ]); + }); + + it('swapping proposes for the other payer and lists the accounts the payer is owed in', () => { + const view = settleView(context, { ...base, payerId: ANNA_ID, amountText: '2065,50' }, 'sv'); + expect(view.lines.map((l) => [l.account.name, l.ore, l.overSettles])).toEqual([['Vardag', 206_550, true]]); + expect(view.untouched.map((u) => u.account.name)).toEqual(['Huset', 'Japan 2027']); + }); + + it('keeps edited rows, warns per row and reports the remaining amount', () => { + const view = settleView(context, base, 'sv'); + const edited = ownRows(view).map((r) => (r.accountId === HUSET_ID ? { ...r, text: '1750' } : r)); + const mismatch = settleView(context, { ...base, rows: edited }, 'sv'); + expect(mismatch.remainingOre).toBe(325_000 - 175_000 - 16_550); + expect(mismatch.error).toBe('allocation_sum_mismatch'); + expect(mismatch.canConfirm).toBe(false); + expect(mismatch.rowsEdited).toBe(true); + const balanced = edited.map((r) => (r.accountId === JAPAN_ID ? { ...r, text: '1500' } : r)); + const over = settleView(context, { ...base, rows: balanced }, 'sv'); + expect(over.remainingOre).toBe(0); + expect(over.lines.filter((l) => l.overSettles).map((l) => l.account.id)).toEqual([JAPAN_ID]); + expect(over.canConfirm).toBe(true); + const tooMuch = settleView(context, { ...base, amountText: '3000', rows: balanced }, 'sv'); + expect(tooMuch.remainingOre).toBe(-25_000); + }); + + it('reports the §14.5.3 token of the rows', () => { + const zero = settleView(context, { ...base, rows: [{ accountId: HUSET_ID, text: '0' }] }, 'sv'); + expect(zero.error).toBe('allocation_not_positive'); + const none = settleView(context, { ...base, rows: [] }, 'sv'); + expect(none.error).toBe('no_allocations'); + expect(none.untouched).toEqual([]); + expect(none.addable.map((a) => a.name)).toEqual(['Vardag', 'Huset', 'Japan 2027']); + }); + + it('builds the pushed entity with allocations sorted by account id and the note trimmed', () => { + const form = { ...base, note: ' Swish ' }; + const entity = pushedSettlement(settleView(context, form, 'sv'), form, null, 'new-id', '2026-10-21T10:00:00.000Z'); + expect(entity).toEqual({ + id: 'new-id', + from_user_id: JACOB_ID, + to_user_id: ANNA_ID, + amount_ore: 325_000, + settlement_date: TODAY, + note: 'Swish', + allocations: [ + { account_id: HUSET_ID, amount_ore: 308_450 }, + { account_id: JAPAN_ID, amount_ore: 16_550 }, + ], + created_at: '2026-10-21T10:00:00.000Z', + updated_at: '2026-10-21T10:00:00.000Z', + deleted_at: null, + }); + expect(deletedSettlement(SAVED, '2026-10-21T10:00:00.000Z')).toMatchObject({ + id: SAVED.id, + created_at: SAVED.created_at, + deleted_at: '2026-10-21T10:00:00.000Z', + allocations: SAVED.allocations, + }); + }); + + it('accepts dates from 2000-01-01 to today + 365 days (SPEC §9.6)', () => { + expect(dateInRange('2000-01-01', TODAY)).toBe(true); + expect(dateInRange('1999-12-31', TODAY)).toBe(false); + expect(dateInRange('2027-10-21', TODAY)).toBe(true); + expect(dateInRange('2027-10-22', TODAY)).toBe(false); + expect(dateInRange('2026-02-30', TODAY)).toBe(false); + expect(dateInRange('', TODAY)).toBe(false); + }); +}); + +describe('messages', () => { + it('maps every settlement token on the allocations (§14.5.3)', () => { + expect(allocationErrorKey('allocation_sum_mismatch')).toBe('settle_error_allocation_sum'); + expect(allocationErrorKey('allocation_not_positive')).toBe('settle_error_allocation_positive'); + expect(allocationErrorKey('no_allocations')).toBe('settle_error_no_allocations'); + expect(allocationErrorKey('too_many_allocations')).toBe('settle_error_too_many_allocations'); + for (const token of ['duplicate_account', 'same_user', 'user_unknown', 'amount_out_of_range']) { + expect(allocationErrorKey(token)).toBe('error_generic_invalid'); + } + }); + + it('maps every rejection reason (§14.4.6)', () => { + const users = new Map([[ANNA_ID, ANNA]]); + const rejected = (reason: RejectedResult['reason'], message = ''): RejectedResult => ({ + mutation_id: 'm', + entity_type: 'settlement', + entity_id: SAVED.id, + status: 'rejected', + reason, + message, + current: { ...SAVED, updated_by_user_id: ANNA_ID }, + }); + expect(rejectionMessage(rejected('stale'), users, ANNA)).toEqual({ key: 'home_sync_reject_stale', args: ['Anna'] }); + expect(rejectionMessage(rejected('deleted'), users, ANNA).key).toBe('home_sync_reject_deleted'); + expect(rejectionMessage(rejected('reference_not_found'), users, ANNA).key).toBe('home_sync_reject_reference'); + expect(rejectionMessage(rejected('clock_skew'), users, ANNA).key).toBe('web_settle_reject_clock_skew'); + expect(rejectionMessage(rejected('validation_failed', 'allocation_sum_mismatch'), users, ANNA).key).toBe( + 'settle_error_allocation_sum', + ); + expect(rejectionMessage(rejected('validation_failed', 'same_user'), users, ANNA).key).toBe( + 'home_sync_reject_validation', + ); + }); + + it('maps request failures', () => { + expect(apiErrorKey(new ApiError(0, 'network', null, 'x'))).toBe('common_could_not_reach_server'); + expect(apiErrorKey(new ApiError(409, 'household_incomplete', null, 'x'))).toBe('error_household_incomplete'); + expect(apiErrorKey(new ApiError(500, 'internal', null, 'x'))).toBe('error_server'); + expect(apiErrorKey(new Error('x'))).toBe('error_server'); + }); +}); diff --git a/web/src/features/settle/model.ts b/web/src/features/settle/model.ts new file mode 100644 index 0000000..f276199 --- /dev/null +++ b/web/src/features/settle/model.ts @@ -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, + }; +} diff --git a/web/src/features/settle/parts.module.css b/web/src/features/settle/parts.module.css new file mode 100644 index 0000000..5b47620 --- /dev/null +++ b/web/src/features/settle/parts.module.css @@ -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); +} diff --git a/web/src/features/settle/parts.tsx b/web/src/features/settle/parts.tsx new file mode 100644 index 0000000..d521934 --- /dev/null +++ b/web/src/features/settle/parts.tsx @@ -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 ( + + ); +} + +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 ( + + ); +} + +/** 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 ( +
+ + + + + + + {t('settle_direction')} + + {sentence} + + + +
+ ); +} + +/** The over-settle strip (design-spec §3.8): filled `warning` icon and the sentence on `warning-container`. */ +export function WarningStrip({ children }: { children: ReactNode }) { + return ( +

+ + {children} +

+ ); +} + +/** An error under a field group: filled `error` icon and body-small in `error`. */ +export function ErrorLine({ children }: { children: ReactNode }) { + return ( +

+ + {children} +

+ ); +} + +/** The §9.4 info line under the rows: `info` and body-small muted. */ +export function InfoLine({ children }: { children: ReactNode }) { + return ( +

+ + {children} +

+ ); +} + +/** + * 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 ( +
+
+ + + {name} + {position} + + {fixed ? ( + + ) : ( + <> + { + onAmount(text); + }} + id={fieldId} + className={[styles.rowAmount, line.overSettles ? styles.rowAmountWarn : undefined] + .filter(Boolean) + .join(' ')} + /> + + + )} +
+ {line.overSettles ? {t('settle_over_settle_warning', name)} : null} +
+ ); +} + +/** 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 ( +
+ {remainingOre === 0 ? ( + + ) : ( + + )} + + {t('settle_remaining')} + {remainingOre < 0 ? t('common_too_much', amount) : amount} + +
+ ); +} + +/** 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 ( +
+

+ {t('timeline_title')} +

+
    + {lines.map((line, index) => ( + // Lines may repeat (two identical changes) and never reorder, so the position is the key. +
  1. + {line} +
  2. + ))} +
+
+ ); +} diff --git a/web/src/features/settle/testScenario.ts b/web/src/features/settle/testScenario.ts new file mode 100644 index 0000000..df3188c --- /dev/null +++ b/web/src/features/settle/testScenario.ts @@ -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 = {}): Partial { + 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 = 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, + }, + ], + }); + }); +} diff --git a/web/src/features/settle/timeline.ts b/web/src/features/settle/timeline.ts new file mode 100644 index 0000000..7eaabaa --- /dev/null +++ b/web/src/features/settle/timeline.ts @@ -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> = { + 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; + 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); +}