diff --git a/web/scripts/gen-icons.mjs b/web/scripts/gen-icons.mjs index cfb7ac2..1287c5c 100644 --- a/web/scripts/gen-icons.mjs +++ b/web/scripts/gen-icons.mjs @@ -109,6 +109,10 @@ const UI_OUTLINE = [ 'language', 'contrast', 'lightbulb', + // K3 card purchases (Kortköp nav, unlink, ignore). + 'credit_card', + 'link_off', + 'visibility_off', ]; /** Filled UI icons of design-spec §2.12 (selected nav, status, handshake, app mark). */ @@ -124,6 +128,7 @@ const UI_FILLED = [ 'receipt_long', 'search', 'lightbulb', + 'credit_card', ]; /** diff --git a/web/src/api/cards.test.ts b/web/src/api/cards.test.ts new file mode 100644 index 0000000..9502f41 --- /dev/null +++ b/web/src/api/cards.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { cardConnection, cardPage, cardPurchase, P } from '../test/cards'; +import { errorResponse, FakeServer, json } from '../test/fakeServer'; +import { applyCardRows, currentConnection, emptyCards, pullCards, purchaseLinkedTo } from './cards'; +import { createApi } from './client'; +import { SessionStore } from './session'; + +function api(server: FakeServer) { + const session = new SessionStore('kvitt.session.cards-test'); + return createApi({ baseUrl: 'http://kvitt.test', session, language: () => 'sv', fetch: server.fetch }); +} + +describe('card snapshot (E-40, §15.5)', () => { + it('pulls pages until has_more is false, from the cursor it holds', async () => { + const first = cardPurchase(P(1), { card_version: 5 }); + const second = cardPurchase(P(2), { card_version: 9 }); + const server = new FakeServer().on('GET', '/cards/sync', (request) => { + const since = new URLSearchParams(request.search).get('since'); + if (since === '0') return json(cardPage(0, { purchases: [first] }, true)); + if (since === '5') + return json(cardPage(5, { purchases: [second], connections: [cardConnection({ card_version: 8 })] })); + return json(cardPage(Number(since), {})); + }); + const snapshot = await pullCards(api(server), undefined); + expect([...snapshot.purchases.keys()]).toEqual([P(1), P(2)]); + expect(snapshot.cursor).toBe(9); + await pullCards(api(server), snapshot); + expect(server.calls('GET', '/cards/sync').map((r) => new URLSearchParams(r.search).get('since'))).toEqual([ + '0', + '5', + '9', + ]); + expect(new URLSearchParams(server.calls('GET', '/cards/sync')[0]?.search).get('limit')).toBe('1000'); + }); + + it('starts again from 0 once on cursor_ahead', async () => { + const server = new FakeServer().on('GET', '/cards/sync', (request) => { + const since = new URLSearchParams(request.search).get('since'); + return since === '0' + ? json(cardPage(0, { purchases: [cardPurchase(P(3))] })) + : errorResponse('cursor_ahead', 409); + }); + const held = { ...applyCardRows(emptyCards(), { purchases: [cardPurchase(P(1))] }), cursor: 99 }; + const snapshot = await pullCards(api(server), held); + expect([...snapshot.purchases.keys()]).toEqual([P(3)]); + }); + + it('keeps the newer card_version; an equal one replaces (E-44 idempotent answers, E-42 fetching)', () => { + const row = cardPurchase(P(1), { card_version: 10, merchant: 'A' }); + let snapshot = applyCardRows(emptyCards(), { purchases: [row] }); + snapshot = applyCardRows(snapshot, { purchases: [{ ...row, card_version: 9, merchant: 'old' }] }); + expect(snapshot.purchases.get(P(1))?.merchant).toBe('A'); + snapshot = applyCardRows(snapshot, { purchases: [{ ...row, merchant: 'same version' }] }); + expect(snapshot.purchases.get(P(1))?.merchant).toBe('same version'); + }); + + it('finds the current connection and the purchase linked to an expense', () => { + const snapshot = applyCardRows(emptyCards(), { + connections: [cardConnection({ id: 'old', status: 'disconnected' })], + purchases: [cardPurchase(P(1), { expense_id: 'e1' }), cardPurchase(P(2), { expense_id: 'e2', deleted_at: 'x' })], + }); + expect(currentConnection(snapshot)).toBeUndefined(); + const connected = applyCardRows(snapshot, { connections: [cardConnection()] }); + expect(currentConnection(connected)?.status).toBe('active'); + expect(purchaseLinkedTo(snapshot, 'e1')?.id).toBe(P(1)); + expect(purchaseLinkedTo(snapshot, 'e2')).toBeUndefined(); + }); +}); diff --git a/web/src/api/cards.ts b/web/src/api/cards.ts new file mode 100644 index 0000000..f6b570e --- /dev/null +++ b/web/src/api/cards.ts @@ -0,0 +1,197 @@ +/** + * The viewer's card purchases (SPEC §9.12, FR-22), read through E-40 `GET /cards/sync` into an in-memory card + * snapshot next to the household ledger (§15.5): the first read pulls from cursor 0, every refetch (window focus, + * the shared 60 s interval, after every card write) pulls from the cursor it holds; a 409 `cursor_ahead` starts again + * from 0. Rows are applied by `card_version`, tombstones included. Nothing is persisted across a reload. Only the + * card holder's own rows ever arrive here (CP-11), so every connection and purchase in the snapshot is the viewer's. + * + * Several features read it (W-21 to W-23 in `features/cards`, W-5 and W-6 in `features/expenses`, W-3 in + * `features/accounts`, W-13 in `features/settings`), so it lives with the ledger in `api/`. + */ +import { useQuery, useQueryClient, type QueryClient, type UseQueryOptions } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { ApiError, unwrap, type Api } from './client'; +import type { components } from './schema'; +import { useApi } from './useApi'; + +type Schemas = components['schemas']; + +export type CardProvider = Schemas['CardProvider']; +export type CardConnection = Schemas['CardConnection']; +export type CardPurchase = Schemas['CardPurchase']; +export type CardAttempt = Schemas['CardAttempt']; +export type CardSyncResponse = Schemas['CardSyncResponse']; + +export const CARDS_QUERY_KEY = ['cards'] as const; +/** Rows per E-40 page: the endpoint's maximum (§14.3.4), so a first read takes the fewest round trips. */ +export const CARD_PULL_PAGE_LIMIT = 1000; + +export interface CardSnapshot { + /** The highest `card_version` applied. */ + cursor: number; + serverTime: string; + connections: ReadonlyMap; + purchases: ReadonlyMap; +} + +export function emptyCards(): CardSnapshot { + return { cursor: 0, serverTime: '', connections: new Map(), purchases: new Map() }; +} + +export interface CardRows { + connections?: readonly CardConnection[]; + purchases?: readonly CardPurchase[]; +} + +function upsert( + current: ReadonlyMap, + rows: readonly T[] | undefined, +): ReadonlyMap { + if (rows === undefined || rows.length === 0) return current; + const next = new Map(current); + for (const row of rows) { + const existing = next.get(row.id); + if (existing === undefined || existing.card_version <= row.card_version) next.set(row.id, row); + } + return next; +} + +/** + * Applies rows (a pull page or a write's answer). A row with the same or a newer `card_version` replaces the held + * one: E-44 answers an unchanged row with its existing version (idempotent), and E-42 can answer a connection whose + * `fetching` changed without a new version. Pure. + */ +export function applyCardRows(snapshot: CardSnapshot, rows: CardRows): CardSnapshot { + return { + ...snapshot, + connections: upsert(snapshot.connections, rows.connections), + purchases: upsert(snapshot.purchases, rows.purchases), + }; +} + +/** Pulls E-40 until `has_more` is false, from `previous` or from 0; `cursor_ahead` restarts from 0 once. */ +export async function pullCards(api: Api, previous: CardSnapshot | undefined, signal?: AbortSignal) { + let snapshot = previous ?? emptyCards(); + let restarted = previous === undefined; + for (;;) { + let page: CardSyncResponse; + try { + page = await unwrap( + api.GET('/cards/sync', { params: { query: { since: snapshot.cursor, limit: CARD_PULL_PAGE_LIMIT } }, signal }), + ); + } catch (error) { + if (error instanceof ApiError && error.code === 'cursor_ahead' && !restarted) { + snapshot = emptyCards(); + restarted = true; + continue; + } + throw error; + } + snapshot = { + ...applyCardRows(snapshot, { connections: page.connections, purchases: page.purchases }), + cursor: page.next_since, + serverTime: page.server_time, + }; + if (!page.has_more) return snapshot; + } +} + +function cardsQuery(api: Api, queryClient: QueryClient) { + return { + queryKey: CARDS_QUERY_KEY, + queryFn: ({ signal }: { signal: AbortSignal }) => + pullCards(api, queryClient.getQueryData(CARDS_QUERY_KEY), signal), + structuralSharing: false, + }; +} + +export interface UseCardsOptions { + /** Re-read sooner than the shared 60 s while a fetch runs at the server (W-21), in ms; false for the default. */ + refetchInterval?: (snapshot: CardSnapshot | undefined) => number | false; + /** False keeps the query idle (W-5 reads the snapshot only when opened from a purchase). */ + enabled?: boolean; +} + +/** The card snapshot, optionally through a selector. */ +export function useCards(select?: (snapshot: CardSnapshot) => T, options: UseCardsOptions = {}) { + const api = useApi(); + const queryClient = useQueryClient(); + const interval = options.refetchInterval; + const query: UseQueryOptions = { + ...cardsQuery(api, queryClient), + select, + enabled: options.enabled ?? true, + }; + // Set only when asked: an explicit `undefined` would replace the shared 60 s default. + if (interval !== undefined) query.refetchInterval = (current) => interval(current.state.data); + return useQuery(query); +} + +/** Merges the rows a card write returned and pulls what else changed (§15.5: after every card write). */ +export function useCardWriter() { + const queryClient = useQueryClient(); + const apply = useCallback( + (rows: CardRows) => { + queryClient.setQueryData(CARDS_QUERY_KEY, (current) => + current === undefined ? current : applyCardRows(current, rows), + ); + }, + [queryClient], + ); + // `exact`: the catalogue and attempt queries of the card pages share the `cards` prefix and are not re-read. + const refresh = useCallback( + () => queryClient.invalidateQueries({ queryKey: CARDS_QUERY_KEY, exact: true }), + [queryClient], + ); + return { apply, refresh }; +} + +/** Non-deleted purchases. */ +export function livePurchases(snapshot: CardSnapshot): CardPurchase[] { + return [...snapshot.purchases.values()].filter((p) => p.deleted_at === null); +} + +/** + * The viewer's current connection: the newest one that is not `disconnected` (one per provider, D-167; v0.35 has one + * provider). "While the viewer has a card connection" (W-3, W-13) means this is defined. + */ +export function currentConnection(snapshot: CardSnapshot): CardConnection | undefined { + return [...snapshot.connections.values()] + .filter((c) => c.status !== 'disconnected') + .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0))[0]; +} + +/** The viewer's purchase linked to an expense (at most one, CP-9). */ +export function purchaseLinkedTo(snapshot: CardSnapshot, expenseId: string): CardPurchase | undefined { + return livePurchases(snapshot).find((p) => p.expense_id === expenseId); +} + +/** E-44 `PUT /cards/purchases/{id}`: sets the link and the ignore flag (both always sent, CP-9). */ +export function putCardPurchase( + api: Api, + purchaseId: string, + body: { expense_id: string | null; ignored: boolean }, +): Promise { + return unwrap(api.PUT('/cards/purchases/{purchase_id}', { params: { path: { purchase_id: purchaseId } }, body })); +} + +/** + * E-44 sent at once (the web keeps no queue, CP-9): the answered row is merged into the snapshot, then the snapshot + * pulls. Throws ApiError (`expense_already_linked`, `not_found`, `network`, …) for the caller to show. + */ +export function useSetCardPurchase() { + const api = useApi(); + const { apply, refresh } = useCardWriter(); + return useCallback( + async (purchaseId: string, body: { expense_id: string | null; ignored: boolean }): Promise => { + try { + const row = await putCardPurchase(api, purchaseId, body); + apply({ purchases: [row] }); + return row; + } finally { + void refresh(); + } + }, + [api, apply, refresh], + ); +} diff --git a/web/src/app/Shell.test.tsx b/web/src/app/Shell.test.tsx index ab52c23..6da32d9 100644 --- a/web/src/app/Shell.test.tsx +++ b/web/src/app/Shell.test.tsx @@ -14,12 +14,15 @@ describe('Shell', () => { renderApp('/', { server: server() }); expect(await screen.findByRole('heading', { level: 1, name: 'Översikt' })).toBeInTheDocument(); const nav = screen.getByRole('navigation', { name: 'Huvudnavigering' }); - for (const name of ['Översikt', 'Statistik', 'Inställningar', 'Insikter', 'Sök']) { + for (const name of ['Översikt', 'Kortköp', 'Statistik', 'Inställningar', 'Insikter', 'Sök']) { expect(within(nav).getByRole('link', { name })).toBeInTheDocument(); } expect(within(nav).getByRole('link', { name: 'Översikt' })).toHaveAttribute('aria-current', 'page'); expect(screen.getAllByRole('link', { name: 'Lägg till utgift' })[0]).toHaveAttribute('href', '/app/expenses/new'); - expect(within(screen.getByRole('navigation', { name: 'Navigering' })).getAllByRole('link')).toHaveLength(3); + // D-172: four destinations, Kortköp after Översikt. + const bottom = within(screen.getByRole('navigation', { name: 'Navigering' })).getAllByRole('link'); + expect(bottom.map((link) => link.textContent)).toEqual(['Översikt', 'Kortköp', 'Statistik', 'Inställningar']); + expect(within(nav).getByRole('link', { name: 'Kortköp' })).toHaveAttribute('href', '/app/cards'); expect((await screen.findAllByText('Uppdaterad nyss')).length).toBeGreaterThan(0); expect(document.title).toBe('Översikt · Kvitt'); }); diff --git a/web/src/app/Shell.tsx b/web/src/app/Shell.tsx index 8368132..f501223 100644 --- a/web/src/app/Shell.tsx +++ b/web/src/app/Shell.tsx @@ -18,9 +18,13 @@ interface Destination { end?: boolean; } -/** The app's three destinations (SPEC §13.2, D-048), in the bottom bar on phones and the sidebar on desktop. */ +/** + * The app's four destinations (SPEC §13.2, D-172: Kortköp after Översikt, K3), in the bottom bar on phones and the + * sidebar on desktop. + */ const TABS: readonly Destination[] = [ { to: paths.home, icon: 'home', label: 'nav_home', end: true }, + { to: paths.cards, icon: 'credit_card', label: 'nav_cards' }, { to: paths.analytics, icon: 'bar_chart', label: 'nav_analytics' }, { to: paths.settings, icon: 'settings', label: 'nav_settings' }, ]; @@ -48,7 +52,7 @@ const DESKTOP_EXTRA: readonly Destination[] = [ ]; /** - * The signed-in frame: a sidebar (Home, Analytics, Settings, Insights, Search, New expense, the updated line) from 720 px, and + * The signed-in frame: a sidebar (Home, Kortköp, Analytics, Settings, Insights, Search, New expense, the updated line) from 720 px, and * under 720 px a single column with the app's bottom bar. Destination changes have no transition (D-108). Global * shortcuts: N new expense, / search, ? help, Esc back from a pushed screen (an open dialog takes Esc first). */ diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index f514469..79632c7 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter, type RouteObject } from 'react-router'; import { accountsRoutes } from '../features/accounts'; import { analyticsRoutes } from '../features/analytics'; import { authRoutes } from '../features/auth'; +import { cardsRoutes } from '../features/cards'; import { expensesRoutes } from '../features/expenses'; import { homeRoutes } from '../features/home'; import { searchRoutes } from '../features/search'; @@ -33,6 +34,7 @@ export const routes: RouteObject[] = [ ...searchRoutes, ...settleRoutes, ...analyticsRoutes, + ...cardsRoutes, ...settingsRoutes, { path: '*', element: }, ], diff --git a/web/src/app/routes.ts b/web/src/app/routes.ts index e030b8b..ebba4f8 100644 --- a/web/src/app/routes.ts +++ b/web/src/app/routes.ts @@ -9,9 +9,19 @@ export type DrillDownKind = 'category' | 'account' | 'bucket'; /** * Query parameters the pages read (D-144): `account` presets the account of W-5 and W-7 (`/expenses/new?account=`, - * `/settle?account=`); `settlement` opens W-7 to edit a saved payment (`/settle?settlement=`). + * `/settle?account=`) and opens W-21 as S-27 "Från kortköp" (`/cards?account=`); `settlement` opens W-7 to + * edit a saved payment (`/settle?settlement=`); `cardPurchase` opens W-5 prefilled from a card purchase + * (`/expenses/new?cardPurchase=`, CP-9); `attempt` is the card connection attempt W-21 polls after the bank's + * return (`/cards?attempt=`, CP-2; the server's callback page links there); `provider` opens W-22's connect step + * for one card (`/settings/cards?provider=`, S-25). */ -export const QUERY = { account: 'account', settlement: 'settlement' } as const; +export const QUERY = { + account: 'account', + settlement: 'settlement', + cardPurchase: 'cardPurchase', + attempt: 'attempt', + provider: 'provider', +} as const; function withQuery(path: string, query: Readonly>): string { const params = new URLSearchParams(); @@ -35,6 +45,7 @@ export const paths = { drillDown: (kind: DrillDownKind, id: string) => `/analytics/${kind}/${encodeURIComponent(id)}`, insights: '/insights', search: '/search', + cards: '/cards', settings: '/settings', settingsCategories: '/settings/categories', settingsRecurring: '/settings/recurring', @@ -42,11 +53,26 @@ export const paths = { settingsProfile: '/settings/profile', settingsInvite: '/settings/invite', settingsAbout: '/settings/about', + settingsCards: '/settings/cards', + settingsCardConnection: (id: string) => `/settings/cards/${encodeURIComponent(id)}`, } as const; -/** W-5 New expense, optionally with the account preset (S-5's FAB): `/expenses/new?account=`. */ -export function expensesNewPath({ account }: { account?: string } = {}): string { - return withQuery(paths.expenseNew, { [QUERY.account]: account }); +/** + * W-5 New expense, optionally with the account preset (S-5's FAB): `/expenses/new?account=`; from a card purchase + * (CP-9) `/expenses/new?cardPurchase=`, plus `&account=` when W-21 was opened from an account. + */ +export function expensesNewPath({ account, cardPurchase }: { account?: string; cardPurchase?: string } = {}): string { + return withQuery(paths.expenseNew, { [QUERY.cardPurchase]: cardPurchase, [QUERY.account]: account }); +} + +/** W-21 Kortköp; `account` opens it as S-27 "Från kortköp" for that account (W-3, AC-22.9). */ +export function cardsPath({ account }: { account?: string } = {}): string { + return withQuery(paths.cards, { [QUERY.account]: account }); +} + +/** W-22 Anslut kort; `provider` opens the connect step (S-25) for that card, also for "Förnya med BankID". */ +export function connectCardPath({ provider }: { provider?: string } = {}): string { + return withQuery(paths.settingsCards, { [QUERY.provider]: provider }); } /** W-7 Settle up, optionally for one account (AC-11.4, "Gör upp det här kontot"): `/settle?account=`. */ @@ -60,4 +86,11 @@ export function settlementEditPath(settlementId: string): string { } /** Top-level destinations: Esc does not go back from these. */ -export const TOP_LEVEL = new Set([paths.home, paths.analytics, paths.settings, paths.search, paths.insights]); +export const TOP_LEVEL = new Set([ + paths.home, + paths.cards, + paths.analytics, + paths.settings, + paths.search, + paths.insights, +]); diff --git a/web/src/design/icons.generated.ts b/web/src/design/icons.generated.ts index 54b8089..78782f7 100644 --- a/web/src/design/icons.generated.ts +++ b/web/src/design/icons.generated.ts @@ -33,6 +33,7 @@ export const OUTLINE = { content_copy: 'M306.22-198.09q-27.6 0-47.87-20.26-20.26-20.27-20.26-47.87v-555.69q0-27.7 20.26-48.04 20.27-20.33 47.87-20.33h435.69q27.7 0 48.04 20.33 20.33 20.34 20.33 48.04v555.69q0 27.6-20.33 47.87-20.34 20.26-48.04 20.26H306.22Zm0-68.13h435.69v-555.69H306.22v555.69ZM178.09-69.72q-27.7 0-48.04-20.33-20.33-20.34-20.33-48.04v-589.76q0-14.42 9.87-24.24 9.87-9.82 24.49-9.82t24.31 9.82q9.7 9.82 9.7 24.24v589.76h469.76q14.42 0 24.24 9.87 9.82 9.88 9.82 24.49 0 14.62-9.82 24.32-9.82 9.69-24.24 9.69H178.09Zm128.13-196.5v-555.69 555.69Z', content_paste: 'M182.15-114.02q-29.35 0-48.74-19.28-19.39-19.27-19.39-48.85v-595.7q0-29.58 19.28-48.97 19.27-19.4 48.85-19.4h195.31q7.95-35.24 36.58-57.98 28.62-22.73 65.96-22.73 36.96 0 65.65 22.73 28.7 22.74 37.13 57.98h195.07q29.58 0 48.97 19.4 19.4 19.39 19.4 48.97v595.7q0 29.58-19.4 48.85-19.39 19.28-48.97 19.28h-595.7Zm0-68.13h595.7v-595.7h-58.81v58.81q0 14.42-9.82 24.24-9.82 9.82-24.24 9.82H275.02q-14.42 0-24.24-9.82-9.82-9.82-9.82-24.24v-58.81h-58.81v595.7ZM508.5-792.1q11.5-11.62 11.5-28.5 0-16.88-11.5-28.38t-28.5-11.5q-17 0-28.5 11.5T440-820.6q0 16.88 11.5 28.5 11.5 11.62 28.5 11.62t28.5-11.62Z', contrast: 'M321.76-105.95q-74.05-31.92-128.96-86.83-54.92-54.9-86.85-128.93-31.93-74.04-31.93-158.26 0-84.23 31.98-158.3 31.98-74.08 86.82-128.91 54.83-54.84 128.89-86.94 74.05-32.1 158.29-32.1t158.29 32.1q74.06 32.1 128.89 86.94 54.84 54.83 86.94 128.89 32.1 74.05 32.1 158.29t-32.1 158.29q-32.1 74.06-86.94 128.89Q712.35-137.98 638.3-106q-74.04 31.98-158.27 31.98t-158.27-31.93Zm180.39-36.68q135.33-10.48 225.51-105.28 90.19-94.8 90.19-232.08 0-136.58-90.19-231.62-90.18-95.04-225.51-105.76v674.74Z', + credit_card: 'M886.22-737.85v515.7q0 27.6-20.34 47.86-20.33 20.27-48.03 20.27h-675.7q-27.6 0-47.86-20.27-20.27-20.26-20.27-47.86v-515.7q0-27.7 20.27-48.03 20.26-20.34 47.86-20.34h675.7q27.7 0 48.03 20.34 20.34 20.33 20.34 48.03ZM142.15-631.48h675.7v-106.37h-675.7v106.37Zm0 129v280.33h675.7v-280.33h-675.7Zm0 280.33v-515.7 515.7Z', date_range: 'M273.08-406.68q-12.1-12.2-12.1-30.06 0-17.85 12.13-30.07 12.12-12.21 30.05-12.21t30.02 12.21q12.1 12.22 12.1 30.07 0 17.86-12.12 30.06-12.13 12.2-30.06 12.2t-30.02-12.2Zm179.87 0q-12.1-12.2-12.1-30.06 0-17.85 12.12-30.07 12.13-12.21 30.06-12.21t30.02 12.21q12.1 12.22 12.1 30.07 0 17.86-12.12 30.06-12.13 12.2-30.06 12.2t-30.02-12.2Zm172.87 0q-12.1-12.2-12.1-30.06 0-17.85 12.12-30.07 12.13-12.21 30.06-12.21t30.02 12.21q12.1 12.22 12.1 30.07 0 17.86-12.13 30.06-12.12 12.2-30.05 12.2t-30.02-12.2ZM182.15-74.02q-27.6 0-47.86-20.27-20.27-20.26-20.27-47.86v-615.7q0-27.7 20.27-48.03 20.26-20.34 47.86-20.34H245v-25.85q0-14.35 9.76-24.25 9.77-9.9 24.46-9.9 14.68 0 24.77 9.9 10.08 9.9 10.08 24.25v25.85h331.86v-25.85q0-14.35 9.77-24.25 9.76-9.9 24.45-9.9t24.77 9.9q10.08 9.9 10.08 24.25v25.85h62.85q27.7 0 48.03 20.34 20.34 20.33 20.34 48.03v615.7q0 27.6-20.34 47.86-20.33 20.27-48.03 20.27h-595.7Zm0-68.13h595.7V-570h-595.7v427.85Zm0-487.85h595.7v-127.85h-595.7V-630Zm0 0v-127.85V-630Z', delete: 'M259.09-114.02q-28.45 0-48.41-19.89-19.96-19.89-19.96-48.24v-565.94h-11q-14.43 0-24.25-9.87t-9.82-24.37q0-14.5 9.82-24.19 9.82-9.7 24.25-9.7h164.21q0-14.67 9.83-24.49 9.82-9.81 24.24-9.81h203.76q14.42 0 24.25 9.95 9.82 9.96 9.82 24.35h164.45q14.43 0 24.25 9.87t9.82 24.37q0 14.5-9.82 24.2-9.82 9.69-24.25 9.69h-11v565.94q0 28.35-19.96 48.24t-48.41 19.89H259.09Zm441.82-634.07H259.09v565.94h441.82v-565.94Zm-282.05 472.6q9.1-9.26 9.1-22.66v-335.18q0-13.4-9.28-22.65-9.27-9.26-22.93-9.26t-22.76 9.26q-9.1 9.25-9.1 22.65v335.18q0 13.4 9.27 22.66 9.28 9.25 22.94 9.25 13.66 0 22.76-9.25Zm168.27 0q9.22-9.26 9.22-22.66v-335.18q0-13.4-9.37-22.65-9.37-9.26-22.93-9.26-13.57 0-22.79 9.26-9.22 9.25-9.22 22.65v335.18q0 13.4 9.44 22.66 9.44 9.25 22.94 9.25 13.49 0 22.71-9.25Zm-328.04-472.6v565.94-565.94Z', devices: 'M497.89-544.07Zm-34.3 390.05h-355.5q-14.43 0-24.25-9.87t-9.82-24.37q0-14.5 9.82-24.2 9.82-9.69 24.25-9.69h355.5q14.39 0 24.35 9.87 9.95 9.87 9.95 24.37 0 14.5-9.95 24.19-9.96 9.7-24.35 9.7ZM181.91-282.15q-27.69 0-48.03-20.34-20.34-20.33-20.34-48.03v-387.33q0-27.7 20.34-48.03 20.34-20.34 48.03-20.34h630.24q14.43 0 24.25 10.01 9.82 10.02 9.82 24.49 0 14.48-9.82 24.18-9.82 9.69-24.25 9.69H181.91v387.33h281.68q14.39 0 24.35 10.01 9.95 10.01 9.95 24.49 0 14.48-9.95 24.17-9.96 9.7-24.35 9.7H181.91Zm639.52 63.58V-613.3h-199v394.73h199Zm-212.32 64.55q-20.63 0-35.93-15.3-15.29-15.29-15.29-35.92v-421.63q0-20.69 15.29-35.83 15.3-15.15 35.93-15.15H835q20.63 0 35.92 15.15 15.3 15.14 15.3 35.83v421.63q0 20.63-15.3 35.92-15.29 15.3-35.92 15.3H609.11Zm113.32-355.7q12.83 0 22.38-9.48 9.54-9.47 9.54-22.19 0-12.83-9.54-22.37-9.55-9.54-22.86-9.54-12.23 0-21.71 9.54t-9.48 22.85q0 12.24 9.48 21.71 9.48 9.48 22.19 9.48Zm0 93.79Z', @@ -58,6 +59,7 @@ export const OUTLINE = { language: 'M320.73-105.88q-74.08-31.86-128.91-86.82-54.84-54.95-86.32-129.53Q74.02-396.8 74.02-482q0-85.2 31.48-158.89 31.48-73.7 86.32-128.41 54.83-54.72 128.91-85.82 74.07-31.1 159.27-31.1 85.2 0 159.27 31.1 74.08 31.1 128.91 85.82 54.84 54.71 86.44 128.41 31.6 73.69 31.6 158.89 0 85.2-31.6 159.77-31.6 74.58-86.44 129.53-54.83 54.96-128.91 86.82Q565.2-74.02 480-74.02q-85.2 0-159.27-31.86Zm159.03-34.03q34.28-35.76 57.66-82.02 23.38-46.27 38.62-110.27H384.96q13.76 60 37.14 107.77 23.38 47.76 57.66 84.52Zm-84.28-12q-25.24-37.52-42.88-81.52-17.64-44-29.64-98.77H173.91q37.52 70.77 87.29 111.03 49.76 40.26 134.28 69.26Zm169.56-1q71.53-22.76 128.79-68.64 57.26-45.88 92.02-110.65H638.04q-13 53.77-30.38 97.77-17.38 44-42.62 81.52ZM154.15-391.48h157.81q-3-27-3.5-48.38-.5-21.38-.5-42.14 0-25 1-44.26 1-19.26 4-43.26H154.15q-7 24-9.5 42.76-2.5 18.76-2.5 44.76 0 25.76 2.5 46.14t9.5 44.38Zm219.81 0h213.08q3.76-31 4.64-50.38.89-19.38.89-40.14 0-20-.89-38.26-.88-18.26-4.64-49.26H373.96q-4 31-5 49.26-1 18.26-1 38.26 0 20.76 1 40.14t5 50.38Zm273.08 0h158.57q6.76-24 9.38-44.38 2.62-20.38 2.62-46.14 0-26-2.62-44.76-2.62-18.76-9.38-42.76H648.04q2.53 35.24 3.53 53.38t1 34.14q0 21.76-1.5 41.14t-4.03 49.38Zm-10-237.56h148.81q-32.76-68.53-90.02-114.41-57.26-45.88-131.79-64.64 25.24 36.76 42.62 79.64t30.38 99.41Zm-252.08 0h192.08q-10.76-52.53-36.76-102.03t-60.52-87.02q-31.28 26.76-53.16 70.52-21.88 43.77-41.64 118.53Zm-211.05 0h150.05q11-53.53 27.64-95.91 16.64-42.38 42.88-82.14-74.76 18.76-130.4 63.52-55.65 44.77-90.17 114.53Z', lightbulb: 'M421.07-93.7q-24.22-24.69-24.22-60.6h166.3q0 35.91-24.22 60.6Q514.7-69 479.99-69q-34.71 0-58.92-24.7Zm-73.32-124.04q-13.7 0-22.56-8.95-8.86-8.96-8.86-22.45 0-13.5 8.91-22.49 8.91-8.98 22.52-8.98h264.72q13.51 0 22.35 8.98 8.84 8.99 8.84 22.49 0 13.49-8.84 22.45-8.84 8.95-22.35 8.95H347.75Zm-26.42-125.3Q253.89-387 213.96-453.01q-39.94-66-39.94-149.01 0-124.33 90.72-215.16Q355.47-908 479.93-908t215.37 90.82q90.92 90.83 90.92 215.16 0 82.91-39.44 148.97Q707.35-387 638.67-343.04H321.33Zm24.7-68.13h269.01q47.51-31.71 75.16-82.02 27.65-50.31 27.65-108.78 0-98.1-69.9-167.88-69.91-69.78-167.95-69.78-98.04 0-167.95 69.78-69.9 69.78-69.9 167.88 0 58.47 27.76 108.75 27.76 50.29 76.12 82.05Zm133.97 0Z', link: 'M280-274.02q-85.63 0-145.8-60.18Q74.02-394.37 74.02-480q0-85.63 60.18-145.92 60.17-60.3 145.8-60.3h133.3q14.43 0 24.25 9.99t9.82 24.49q0 14.5-9.82 24.2-9.82 9.69-24.25 9.69H280q-57.62 0-97.73 40.05-40.12 40.05-40.12 97.56 0 57.52 40.12 97.81 40.11 40.28 97.73 40.28h133.3q14.43 0 24.25 9.87t9.82 24.37q0 14.5-9.82 24.19-9.82 9.7-24.25 9.7H280Zm72.37-174.55q-13.23 0-22.33-9.03-9.11-9.03-9.11-22.58 0-13.54 8.99-22.4 8.98-8.85 22.45-8.85h255.26q13.23 0 22.33 9.03 9.11 9.03 9.11 22.58 0 13.54-8.99 22.4-8.98 8.85-22.45 8.85H352.37Zm194.56 174.55q-14.66 0-24.48-9.87t-9.82-24.37q0-14.5 9.82-24.2 9.82-9.69 24.48-9.69H680q57.62 0 97.73-40.05 40.12-40.05 40.12-97.56 0-57.52-40.12-97.81-40.11-40.28-97.73-40.28H546.93q-14.66 0-24.48-9.87t-9.82-24.37q0-14.5 9.82-24.31 9.82-9.82 24.48-9.82H680q85.63 0 145.92 60.3 60.3 60.29 60.3 145.92t-60.3 145.8Q765.63-274.02 680-274.02H546.93Z', + link_off: 'm597.35-443.65-64.55-64.78h70.53q14.67 0 24.49 9.69 9.81 9.7 9.81 24.37 0 13-8.38 21.74-8.38 8.74-20.9 8.98h-11ZM833.13-79.2q-9.48 9.72-22.46 9.72-12.97 0-22.45-9.72L83.04-783.13q-9.47-9.48-9.59-22.46-.12-12.98 9.59-22.45 9.48-9.72 22.34-9.72t22.58 9.72l705.17 705.17q9.48 9.48 9.48 21.84t-9.48 21.83ZM279.76-274.02q-85.63 0-145.8-60.18Q73.78-394.37 73.78-480q0-72.24 43.9-128.08 43.91-55.83 112.91-71.99l61.98 62.22h-12.81q-57.04 0-97.44 40.28-40.41 40.29-40.41 97.57 0 57.28 40.41 97.57 40.4 40.28 97.44 40.28h133.31q14.67 0 24.36 9.69 9.7 9.7 9.7 24.37 0 14.68-9.7 24.37-9.69 9.7-24.36 9.7H279.76Zm74.28-174.55q-13.71 0-22.57-8.85-8.86-8.86-8.86-22.58 0-13.72 8.86-22.58 8.86-8.85 22.57-8.85h46.85l62.87 62.86H354.04Zm366.48 139.96q-6.43-12.43-1.93-25.87 4.5-13.43 16.93-19.87 37.05-16.76 59.81-49.3 22.76-32.55 22.76-73.35 0-57.04-40.29-97.45-40.28-40.4-97.32-40.4H560.26q-14.67 0-24.37-9.69-9.69-9.7-9.69-24.37 0-14.68 9.69-24.49 9.7-9.82 24.37-9.82h119.98q85.63 0 145.8 60.3 60.18 60.29 60.18 145.92 0 59.48-33.1 107.58-33.1 48.09-85.1 74.81-12.19 7.2-26.75 3.2-14.55-4-20.75-17.2Z', local_cafe: 'M188.09-114.02q-14.43 0-24.25-9.87t-9.82-24.37q0-14.5 9.82-24.2 9.82-9.69 24.25-9.69h583.82q14.4 0 24.35 9.87 9.96 9.87 9.96 24.37 0 14.5-9.96 24.19-9.95 9.7-24.35 9.7H188.09ZM309.8-258.57q-65.17 0-110.48-44.93-45.3-44.93-45.3-109.85v-364.5q0-28.45 19.89-48.41 19.89-19.96 48.24-19.96h595.94q28.1 0 48.11 20.08 20.02 20.08 20.02 48.05v160q0 28.1-20.02 48.12-20.01 20.01-48.11 20.01h-92.66v136.61q0 64.92-45.47 109.85-45.48 44.93-110.55 44.93H309.8Zm.24-68.36h259.11q34.84 0 61.5-26.52 26.65-26.52 26.65-60.14v-364.26H222.15v364.26q0 33.62 26.98 60.14 26.97 26.52 60.91 26.52Zm415.39-291.16h92.42v-160h-92.42v160ZM310.04-326.93h-87.89H657.3 310.04Z', local_gas_station: 'M151.87-145.93V-780q0-27.7 20.27-48.03 20.26-20.34 47.86-20.34h269q27.7 0 48.03 20.34 20.34 20.33 20.34 48.03v293.5h68.11q21.38 0 36.41 15.05 15.02 15.04 15.02 36.15v219q0 21.16 15.05 35.48 15.05 14.32 36.54 14.32 21.5 0 36.64-14.32t15.14-35.48v-305.05q-10.76 5.76-22.55 8.64-11.8 2.88-23.25 2.88-40.2 0-67.7-27.61-27.5-27.62-27.5-67.82 0-31.14 17.64-56.45 17.65-25.31 47.41-33.79l-75.81-75.8q-8-7.88-8.12-18.78-.12-10.9 8.12-19.14 8.24-8.48 18.96-7.86 10.72.62 18.96 8.86L796.65-678q15.44 15.43 25.85 33.13 10.41 17.7 10.41 39.61v388.96q0 43.97-30.17 74.2-30.18 30.23-74.08 30.23t-74.14-30.23q-30.24-30.23-30.24-74.29v-217.48h-66.91v287.94q0 14.42-9.96 24.24-9.95 9.82-24.34 9.82H185.93q-14.42 0-24.24-9.82-9.82-9.82-9.82-24.24ZM220-552h269v-228H220v228Zm514.5-10.7q17.5 0 30.14-12.54 12.64-12.55 12.64-30.05 0-17.49-12.66-30.01-12.67-12.53-30.17-12.53-17.49 0-30.01 12.55-12.53 12.55-12.53 30.04 0 17.5 12.55 30.02t30.04 12.52ZM220-180h269v-312H220v312Zm269 0H220h269Z', lock: 'M222.15-74.5q-28.1 0-48.12-20.01-20.01-20.02-20.01-48.12v-429.7q0-28.2 20.01-48.28 20.02-20.09 48.12-20.09h65.22v-90.73q0-80.37 56.27-136.86 56.26-56.49 136.41-56.49t136.36 56.49q56.22 56.49 56.22 136.86v90.73h65.22q28.2 0 48.28 20.09 20.09 20.08 20.09 48.28v429.7q0 28.1-20.09 48.12-20.08 20.01-48.28 20.01h-515.7Zm0-68.13h515.7v-429.7h-515.7v429.7ZM534.5-302.51q22.5-22.03 22.5-52.97 0-30-22.67-54.5t-54.5-24.5q-31.83 0-54.33 24.5t-22.5 55q0 30.5 22.67 52.5t54.5 22q31.83 0 54.33-22.03Zm-179-338.19h249v-90.68q0-52.39-36.15-88.71-36.15-36.32-88.18-36.32-52.02 0-88.34 36.32-36.33 36.32-36.33 88.71v90.68ZM222.15-142.63v-429.7 429.7Z', @@ -97,6 +99,7 @@ export const OUTLINE = { sync_problem: 'M457.45-289.49q-9.6-9.65-9.6-22.56 0-12.91 9.64-22.5 9.65-9.6 22.56-9.6 12.91 0 22.5 9.64 9.6 9.65 9.6 22.56 0 12.91-9.64 22.5-9.65 9.6-22.56 9.6-12.91 0-22.5-9.64Zm1.97-147.93q-8.85-8.86-8.85-22.53V-650.2q0-13.51 8.85-22.47 8.86-8.96 22.58-8.96 13.72 0 22.58 8.86 8.85 8.86 8.85 22.53V-460q0 13.51-8.85 22.47-8.86 8.96-22.58 8.96-13.72 0-22.58-8.85ZM189.2-476.76q0 62.95 26.9 114.94 26.9 51.99 87.23 91.28v-88.26q0-13.52 8.95-22.36t22.46-8.84q13.5 0 22.48 8.84t8.98 22.36v168.32q0 14.68-9.83 24.37-9.83 9.7-24.48 9.7h-167.8q-13.76 0-22.62-8.96-8.86-8.95-8.86-22.45 0-13.51 8.96-22.36 8.96-8.86 22.47-8.86h92.83q-63.76-48.72-99.9-109.18-36.14-60.45-36.14-148.54 0-90.91 47.93-170.85 47.94-79.93 137.33-122.41 11.95-5.48 23.53.22 11.58 5.69 16.58 18.37 4.23 12.43-.36 25.13-4.6 12.69-16.8 18.41-61.02 31.18-100.43 93.1-39.41 61.92-39.41 138.03Zm591.37-6.72q0-62.86-27.03-114.78-27.02-51.91-87.34-91.2v88.26q0 13.52-8.84 22.36-8.83 8.84-22.33 8.84-13.51 0-22.49-8.84-8.97-8.84-8.97-22.36v-168.32q0-14.68 9.69-24.37 9.7-9.7 24.37-9.7h168.09q13.51 0 22.35 8.96 8.84 8.95 8.84 22.45 0 13.51-8.84 22.36-8.84 8.86-22.35 8.86h-93.07q63.76 48.72 100.02 109.22t36.26 148.26q0 91.23-48.05 171.51-48.05 80.27-137.21 122.75-12.19 5.48-23.65-.6-11.45-6.07-16.69-18.86-4-12.32.59-25.14 4.6-12.81 16.56-18.53 61.04-31.28 100.56-93.12 39.53-61.84 39.53-138.01Z', train: 'M154.02-342.63V-720q0-43.58 19.96-75.3 19.95-31.72 60.53-51.7 40.58-19.98 102.03-29.6Q398-886.22 480-886.22q86 0 147.46 9.12 61.45 9.12 101.15 29.1 39.69 19.98 58.65 51.34 18.96 31.37 18.96 76.66v377.37q0 62.23-42.54 105.78-42.53 43.55-104.16 44.5l26 26q15.96 15.96 7.36 36.65-8.6 20.7-31.73 20.7-6.54 0-12.4-2.24t-10.86-7.48l-73.39-73.15h-169l-73.15 73.15q-5.24 5.24-11.13 7.48-5.89 2.24-12.31 2.24-22.52 0-31.45-20.7-8.94-20.69 7.02-36.65l26-26q-61.63-.95-104.05-44.5-42.41-43.55-42.41-105.78ZM480-817.85q-120 0-173 15.5t-76 44.5h498.85q-18-27-75.43-43.5Q597-817.85 480-817.85Zm-257.85 275H454v-155H222.15v155Zm432.83 60H222.15h515.7-82.87Zm-140.98-60h223.85v-155H514v155ZM375.08-330q16.12-15.95 16.12-38.88 0-22.92-16.12-39.04-16.12-16.12-39.05-16.12-22.93 0-38.88 16.12-15.95 16.12-15.95 39.04 0 22.93 15.95 38.88 15.95 15.96 38.88 15.96 22.93 0 39.05-15.96Zm287.84 0q16.12-15.95 16.12-38.88 0-22.92-16.12-39.04-16.12-16.12-39.04-16.12-22.93 0-38.88 16.12-15.96 16.12-15.96 39.04 0 22.93 15.96 38.88 15.95 15.96 38.88 15.96 22.92 0 39.04-15.96Zm-357.9 75h349.96q35.02 0 58.94-25.75 23.93-25.74 23.93-61.88v-140.22h-515.7v140.22q0 36.14 23.93 61.88Q270-255 305.02-255ZM480-757.85h249.85H231h249Z', unarchive: 'M503.17-288.14q9.22-9.37 9.22-23.01v-160l61.22 61.22q8.6 8.71 21.38 8.59 12.77-.12 21.73-9.05 8.71-8.69 8.6-21.52-.12-12.83-8.84-21.61L504.11-566.37q-10.28-9.96-24.03-9.96-13.75 0-23.95 10.2L343.52-453.28q-8.72 8.6-8.84 21.49-.11 12.9 8.94 21.97 8.33 8.6 21.16 8.6t21.61-8.71l61.22-61.22v160q0 13.64 9.44 23.01 9.44 9.38 23.17 9.38 13.74 0 22.95-9.38ZM182.15-665.63v483.48h595.7v-483.48h-595.7Zm0 551.61q-28.35 0-48.24-19.89t-19.89-48.24v-520.39q0-11.32 3.6-21.93t10.55-19.57l57.03-76.03q9.13-12.2 23.3-19.17 14.17-6.98 29.72-6.98h482.8q15.31 0 29.48 6.98 14.17 6.97 23.3 19.17l58.27 76.03q6.71 8.96 10.43 19.57 3.72 10.61 3.72 21.93v520.39q0 28.35-19.96 48.24t-48.41 19.89h-595.7Zm21.07-618.07h552.56l-36.41-46H239.22l-36 46ZM480-423.89Z', + visibility_off: 'M600-620q26.61 26.61 39.87 66.36 13.26 39.75 6.17 78.64 0 14.28-9.33 23.57-9.34 9.3-23.59 9.3t-23.54-9.3q-9.28-9.29-9.28-23.57 10.29-30.41 2.86-57.68-7.42-27.28-27.03-46.12-18.85-19.61-46.62-26.54-27.77-6.92-57.18 3.6-13.9 0-23.15-9.25-9.25-9.26-9.25-23.18t9.25-23.13q9.25-9.22 23.15-9.22 39.37-7.33 79.85 5.93Q572.67-647.33 600-620ZM490-740q-30.57 0-61.01 2.88-30.45 2.88-59.66 12.16-13.55 3.76-26.5-1.83-12.96-5.6-18.2-18.04-5.48-12.19.24-24.41 5.72-12.22 17.91-16.22 34.05-10.76 69.77-15.4 35.72-4.64 72.45-4.64 138.13 0 251.86 76.34 113.73 76.33 174.44 200.81 3.24 6.68 5.36 13.78 2.12 7.11 2.12 14.58t-1.5 14.69q-1.5 7.21-4.74 13.69-22.76 48.04-54.04 88.83-31.28 40.78-72.09 75.3-9.71 9.2-21.79 6.82-12.08-2.38-20.03-12.1-7.96-9.72-6.58-21.91 1.38-12.2 11.1-21.16 34.8-29.52 62.85-65.06Q840-456.43 856.76-500q-49.51-108.76-148.22-174.38Q609.84-740 490-740Zm-10.02 545.5q-137.18 0-249.87-76.36-112.7-76.36-178.94-197.55-4.24-7.96-5.97-15.3-1.74-7.33-1.74-16.28 0-8.94 2.24-16.53 2.23-7.59 5.47-15.31 23.76-47.28 54.93-89.08 31.17-41.79 70.16-76.76L73.17-800.76q-9.47-9.35-9.09-21.77.38-12.43 8.85-21.9 9.48-9.48 22.46-9.48t22.46 9.48L834.8-127.48q8.48 8.48 8.24 20.46-.24 11.98-8.34 21.21-8.37 10.48-21.35 10.48-12.98 0-22.46-9.47L649.2-223.74q-41.24 15.48-83.65 22.36-42.41 6.88-85.57 6.88ZM221.26-652.09q-39.98 29-70.38 67.52-30.4 38.53-48.4 84.57 52.03 112 156.36 176Q363.16-260 488-260q28.28 0 56.33-1.26 28.04-1.26 53.56-14.26l-64-64q-13.28 5.76-26.94 7.64Q493.28-330 480-330q-71 0-120.5-49.5T310-500q0-13.28 2.26-26.95 2.26-13.66 6.78-26.94l-97.78-98.2Zm309.37 138.66ZM409.61-452.8Z', warning: 'M93.91-116.89q-10.19 0-17.65-4.72-7.46-4.72-11.93-12.43-4.48-7.72-4.98-16.42-.5-8.69 4.98-17.89L450.41-835q5.48-8.96 13.06-12.93 7.57-3.98 16.53-3.98t16.53 3.98q7.58 3.97 13.06 12.93l386.08 666.65q5.48 9.2 4.98 17.89-.5 8.7-4.98 16.42-4.47 7.71-11.93 12.43t-17.65 4.72H93.91Zm54.63-65.74h662.92L480-754.98 148.54-182.63Zm357.56-63.71q8.86-8.86 8.86-22.09 0-13.24-8.86-21.98t-22.1-8.74q-13.24 0-22.1 8.74-8.86 8.74-8.86 21.98 0 13.23 8.86 22.09t22.1 8.86q13.24 0 22.1-8.86Zm-.6-110.16Q514-365 514-378v-162.09q0-13-8.5-21.5t-21.5-8.5q-13 0-21.5 8.5t-8.5 21.5V-378q0 13 8.5 21.5T484-348q13 0 21.5-8.5ZM480-468.8Z', water_drop: 'M247.2-169.7q-93.18-95.67-93.18-238.3 0-61.61 27.21-123.05 27.2-61.45 69.75-119.57 42.54-58.12 94.12-110.89 51.57-52.77 100.83-96.79 7.96-6.72 16.42-9.58 8.45-2.86 17.65-2.86t17.65 2.86q8.46 2.86 16.42 9.58 49.26 44.02 100.83 96.79 51.58 52.77 94.12 110.89 42.55 58.12 69.87 119.57 27.33 61.44 27.33 123.05 0 142.63-93.3 238.3Q619.63-74.02 480-74.02T247.2-169.7Zm417.25-48.35q73.4-75.91 73.4-189.95 0-77.8-65.9-177.23Q606.04-684.65 480-796.89 353.96-684.65 288.05-585.23q-65.9 99.43-65.9 177.23 0 114.04 73.4 189.95 73.41 75.9 184.45 75.9 111.04 0 184.45-75.9ZM480-480.96Zm-.52 272q16.72 0 25.93-6.09 9.22-6.1 9.22-17.58 0-11.72-9.1-18.32-9.1-6.59-27.05-6.59-43.2 0-84.31-26.26-41.1-26.27-52.87-90.4-2-9.23-9.35-15.33-7.36-6.1-16.32-6.1-12.2 0-18.55 8.98-6.36 8.98-4.36 18.69 15 84.24 71 121.62 56 37.38 115.76 37.38Z', wifi: 'M417-154q-27-27-27-63t27-63q27-27 63-27t63 27q27 27 27 63t-27 63q-27 27-63 27t-63-27Zm207.68-378.98q67.41 27.02 130.41 80.55 14.71 12.47 15.71 32.05t-13.47 34.05q-14.72 14.72-33.92 14.08-19.19-.64-35.39-12.88-52.04-42.8-103.75-61.09Q532.57-464.5 480-464.5q-52.33 0-104.03 18.28-51.71 18.29-103.75 60.85-16.2 12.24-35.51 13.12-19.32.88-33.8-13.84-14.71-14.48-13.71-34.05 1-19.58 15.71-32.06 63-53.52 130.41-80.66Q402.72-560 480-560t144.68 27.02Zm95.62-219q113.03 48.02 203.55 128.31 14.95 13.47 16.45 33.17 1.5 19.7-12.97 34.17-14.72 14.72-35.42 14.46-20.69-.26-36.89-13.74-82.04-68.56-175.25-108.73Q586.57-704.5 480-704.5q-106.57 0-199.77 40.16-93.21 40.17-175.25 108.73-16.2 13.48-36.27 13.74-20.08.26-34.8-14.46Q19.2-570.8 20.2-590.5q1-19.7 15.95-33.17 90.52-80.29 203.55-128.31Q352.72-800 480-800q127.28 0 240.3 48.02Z', @@ -115,6 +118,7 @@ export const FILLED = { checkroom: 'M228.26-225.09H732.3L480.57-424.35 228.26-225.09Zm182.26-464.65q-4.13 10.83-13.75 18.02-9.63 7.2-22.53 7.2-16.72 0-28.04-11.33-11.33-11.32-11.33-28.54v-8q0-4.13 2.5-8.14 18.67-42.44 57.55-68.3 38.88-25.87 85.65-25.87 64.37 0 109.81 44.96 45.45 44.95 45.45 109.31 0 51.73-33.26 90.97-33.27 39.24-82.96 55.5v20.09l359.13 277.04q7.7 6.13 11.83 14.13t4.13 17.18q0 17-11.61 28.32-11.61 11.33-28.57 11.33H106.04q-17.52 0-28.84-11.33-11.33-11.32-11.33-28.32 0-9.18 4.13-17.18t11.83-14.13l359.13-277.04v-50.48q0-15.88 11.18-27.75 11.18-11.86 26.86-11.86 32.22 0 54.63-21.8 22.41-21.81 22.41-54.39 0-31.02-22.08-52.89-22.09-21.87-53.25-21.87-23.01 0-41.88 12.43-18.87 12.44-28.31 32.74Z', child_care: 'M618.26-510.2q14.13-13.84 14.13-34.26 0-20.41-14.13-34.54-14.13-14.13-34.26-14.13-20.13 0-34.26 14.13-14.13 14.13-14.13 34.54 0 20.42 14.13 34.26 14.13 13.85 34.26 13.85 20.13 0 34.26-13.85Zm-209 0q14.13-13.84 14.13-34.26 0-20.41-14.13-34.54-14.13-14.13-34.26-14.13-20.13 0-34.26 14.13-14.13 14.13-14.13 34.54 0 20.42 14.13 34.26 14.13 13.85 34.26 13.85 20.13 0 34.26-13.85Zm174.85 193.79q47.85-30.42 73.28-83.68H302.61q25.43 53.26 73.28 83.68Q423.74-286 480-286t104.11-30.41ZM334.85-135.22q-68.33-29.34-119.31-80.32-50.98-50.98-80.32-119.31-29.35-68.32-29.35-145.15 0-76.83 29.35-145.15 29.34-68.33 80.32-119.31 50.98-50.98 119.31-80.61Q403.17-854.7 480-854.7q76.83 0 145.15 29.63 68.33 29.63 119.31 80.61 50.98 50.98 80.61 119.31Q854.7-556.83 854.7-480q0 76.83-29.63 145.15-29.63 68.33-80.61 119.31-50.98 50.98-119.31 80.32-68.32 29.35-145.15 29.35-76.83 0-145.15-29.35Zm161.28-498.82q32.96 0 46.54-9.92 13.59-9.91 13.59-24.78 0-10.69-7.91-16.48-7.92-5.78-19.48-5.78-7.57 0-15.85 1.5t-20.28 1.5q-22.17 0-37.26-14.8-15.09-14.81-15.09-38.68 0-8.3 1.22-18.8t2.65-19.2q-14.56 2.57-27.13 5.07-12.56 2.5-26.56 7.5-2 5.56-2.79 13.63-.78 8.06-.78 14.63 0 44.3 30.72 74.45 30.71 30.16 78.41 30.16Z', cloud_done: 'm411.3-368.48-77.73-77.74q-9.14-9.13-23.05-8.63t-23.04 9.63q-9.13 9.13-9.13 22.48t9.13 22.48l94.48 93.48q11.82 11.82 28.06 11.82t28.07-11.82l184.48-184.48q9.13-9.13 9.13-21.98t-9.13-21.98q-9.14-9.13-22.27-9.13-13.13 0-22.26 9.13L411.3-368.48ZM252.13-145.87q-93.09 0-159.11-65.17Q27-276.22 27-369.87q0-81.39 50.56-144.63 50.57-63.24 130.4-76.37 22.26-98.7 98.52-160.76Q382.74-813.7 482-813.7q114.26 0 194.09 82.07 79.82 82.06 84.35 197.76v24q73.69 3.09 123.13 54.41Q933-404.13 933-327.87q0 75.22-53.39 128.61-53.39 53.39-128.61 53.39H252.13Z', + credit_card: 'M145.09-145.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-509.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h669.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v509.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H145.09Zm0-357.26h669.82v-129H145.09v129Z', directions_car: 'M201.13-189.87v36.65q0 19.73-14 33.54-13.99 13.81-33.87 13.81-19.87 0-33.63-13.93-13.76-13.92-13.76-33.98v-309.65q0-6.56.78-12.54.78-5.99 3.35-12.68l76.87-232.05q7.69-24.65 28.25-39.32 20.55-14.68 46.27-14.68h437.22q25.72 0 46.27 14.68 20.56 14.67 28.82 39.32L850-488.65q2.57 6.69 3.63 12.68 1.07 5.98 1.07 12.54v310.21q0 19.73-14.15 33.54-14.15 13.81-33.87 13.81t-34.27-14.22q-14.54-14.22-14.54-34.69v-35.09H201.13ZM209.22-534h542.13l-53.31-160.91H261.96L209.22-534Zm79.37 217.17q22.67 0 38.45-15.75 15.79-15.75 15.79-38.25 0-23.33-16.04-39.66-16.03-16.34-37.96-16.34-23.9 0-39.95 16.27-16.05 16.26-16.05 39.5 0 23.23 15.98 38.73 15.98 15.5 39.78 15.5Zm384.15 0q23.33 0 39.67-15.75 16.33-15.75 16.33-38.25 0-23.33-16.27-39.66-16.26-16.34-39.5-16.34-23.23 0-38.73 16.27-15.5 16.26-15.5 39.5 0 23.23 15.75 38.73 15.75 15.5 38.25 15.5Z', error: 'M512.54-278.33q12.9-12.46 12.9-32.26 0-19.8-12.75-32.98-12.74-13.17-32.54-13.17-19.8 0-32.69 13.16-12.9 13.15-12.9 32.95 0 19.8 12.75 32.28 12.74 12.48 32.54 12.48 19.8 0 32.69-12.46Zm-1.26-165.56q11.33-11.45 11.33-28.15v-183.39q0-16.71-11.5-28.16t-28.29-11.45q-16.78 0-28.1 11.45-11.33 11.45-11.33 28.16v183.39q0 16.7 11.5 28.15 11.5 11.46 28.29 11.46 16.78 0 28.1-11.46ZM480.27-65.87q-86.13 0-161.43-32.39-75.3-32.4-131.74-88.84-56.44-56.44-88.84-131.81-32.39-75.38-32.39-161.59 0-86.21 32.39-161.59 32.4-75.37 88.84-131.31 56.44-55.94 131.81-88.62 75.38-32.68 161.59-32.68 86.21 0 161.59 32.68 75.37 32.68 131.31 88.62 55.94 55.94 88.62 131.47 32.68 75.54 32.68 161.66 0 86.13-32.68 161.43-32.68 75.3-88.62 131.56-55.94 56.25-131.47 88.83-75.54 32.58-161.66 32.58Z', fitness_center: 'M270.83-634.17 154.35-517.7q-11.26 11.27-25.81 10.98-14.54-.28-25.8-11.54-10.26-10.26-10.48-25.3-.22-15.05 10.04-26.31l30.35-30.78L105-628.87q-11.83-11.83-11.83-28.06 0-16.24 11.83-28.07l57.78-57.78-13.65-13.09q-12.39-11.83-12.39-28.06 0-16.24 11.83-27.5 10.82-11.4 27.28-11.61 16.45-.22 28.28 10.61l14.09 14.21L275-855q11.83-11.83 28.07-11.83 16.23 0 28.63 11.83l27.65 27.65 31.35-30.91q11.26-10.26 26.08-10.26 14.83 0 25.52 10.69 11.27 11.83 11.27 26.37 0 14.55-11.27 25.81L326.39-689.74l363.35 363.35L805.65-442.3q11.26-11.27 26.09-11.27t26.09 11.27q10.26 10.26 10.47 25.58.22 15.33-10.04 26.02l-30.91 31.35L855-331.7q11.83 12.4 11.83 28.63 0 16.24-11.83 28.07l-58.78 58.78 14.21 13.09q11.83 11.83 11.83 28.06 0 16.24-11.83 27.5-11.82 11.83-27.78 11.83-15.95 0-27.78-12.39l-13.09-13.65L685-105q-11.83 11.83-28.07 11.83-16.23 0-28.06-11.83l-28.22-27.65-31.35 30.91q-11.26 10.26-25.8 9.98-14.54-.28-25.24-10.98-11.83-11.26-11.83-25.8 0-14.55 11.83-26.37l115.91-115.92-363.34-363.34Z', diff --git a/web/src/features/accounts/AccountDetailPage.test.tsx b/web/src/features/accounts/AccountDetailPage.test.tsx index 573cd19..43c1bb2 100644 --- a/web/src/features/accounts/AccountDetailPage.test.tsx +++ b/web/src/features/accounts/AccountDetailPage.test.tsx @@ -1,6 +1,7 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cardConnection, withCards } from '../../test/cards'; import { errorResponse, json } from '../../test/fakeServer'; import { JACOB_ID } from '../../test/fixtures'; import { renderApp } from '../../test/render'; @@ -242,4 +243,32 @@ describe('Account detail (W-3)', () => { ), ); }); + + it('offers "Från kortköp" while the viewer has a card connection (AC-22.9)', async () => { + const srv = withCards(serverWith(), { connections: [cardConnection()] }); + const { router } = renderApp(path(VARDAG_ID), { server: srv }); + const user = userEvent.setup(); + expect(await screen.findByRole('heading', { level: 1, name: 'Vardag' })).toBeInTheDocument(); + await waitFor(() => { + expect(srv.calls('GET', '/cards/sync').length).toBeGreaterThan(0); + }); + const menu = await openMenu(user); + await user.click(await menu.findByRole('menuitem', { name: 'Från kortköp' })); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app/cards'); + }); + expect(router.state.location.search).toBe(`?account=${VARDAG_ID}`); + }); + + it('has no "Från kortköp" without a connection', async () => { + const srv = withCards(serverWith(), { connections: [cardConnection({ status: 'disconnected' })] }); + renderApp(path(VARDAG_ID), { server: srv }); + const user = userEvent.setup(); + expect(await screen.findByRole('heading', { level: 1, name: 'Vardag' })).toBeInTheDocument(); + await waitFor(() => { + expect(srv.calls('GET', '/cards/sync').length).toBeGreaterThan(0); + }); + const menu = await openMenu(user); + expect(menu.queryByRole('menuitem', { name: 'Från kortköp' })).not.toBeInTheDocument(); + }); }); diff --git a/web/src/features/accounts/AccountDetailPage.tsx b/web/src/features/accounts/AccountDetailPage.tsx index b997cf4..91fd5e1 100644 --- a/web/src/features/accounts/AccountDetailPage.tsx +++ b/web/src/features/accounts/AccountDetailPage.tsx @@ -1,8 +1,9 @@ import { useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; +import { currentConnection, useCards } from '../../api/cards'; import type { Ledger } from '../../api/ledger'; import { useLedger } from '../../api/ledger'; -import { expensesNewPath, paths } from '../../app/routes'; +import { cardsPath, expensesNewPath, paths } from '../../app/routes'; import { TopBar } from '../../app/TopBar'; import { Button, ButtonLink } from '../../components/Button'; import { CategoryIcon } from '../../components/CategoryIcon'; @@ -84,6 +85,8 @@ function Loaded({ const toast = useToast(); const navigate = useNavigate(); const writes = useAccountWrites(); + // AC-22.9: "Från kortköp" while the viewer has a card connection (only the viewer's own rows are in the snapshot). + const hasCardConnection = useCards((snapshot) => currentConnection(snapshot) !== undefined).data === true; const [busy, setBusy] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const { account } = detail; @@ -157,6 +160,18 @@ function Loaded({ archived ? { key: 'unarchive', label: t('account_unarchive'), icon: 'unarchive', onSelect: () => void unarchive() } : { key: 'archive', label: t('account_archive'), icon: 'inventory_2', onSelect: () => void archive() }, + ...(hasCardConnection + ? [ + { + key: 'from_card_purchases', + label: t('account_from_card_purchases'), + icon: 'credit_card' as const, + onSelect: () => { + void navigate(cardsPath({ account: account.id })); + }, + }, + ] + : []), { key: 'delete', label: t('account_delete'), diff --git a/web/src/features/cards/CardArt.module.css b/web/src/features/cards/CardArt.module.css new file mode 100644 index 0000000..4e49983 --- /dev/null +++ b/web/src/features/cards/CardArt.module.css @@ -0,0 +1,40 @@ +.art { + display: block; + height: auto; + object-fit: contain; + background: none; +} +.small { + width: calc(var(--s12) + var(--s6)); +} +.large { + width: 100%; + max-width: calc(var(--s12) * 6); +} +.full { + width: 100%; +} +/* D-173's drawn card: the light values in both themes (light-dark() follows this element's colour scheme). */ +.fallback { + color-scheme: light; + display: flex; + align-items: flex-end; + aspect-ratio: 1.586; + padding: var(--s3); + border-radius: var(--radius-large); + background: var(--cat-green); + color: var(--on-cat); +} +.fallback.small { + padding: var(--s1); + border-radius: var(--radius-small); +} +.fallbackName { + font: var(--type-title-medium); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.small .fallbackName { + font: var(--type-label-small); +} diff --git a/web/src/features/cards/CardArt.tsx b/web/src/features/cards/CardArt.tsx new file mode 100644 index 0000000..c09b7d5 --- /dev/null +++ b/web/src/features/cards/CardArt.tsx @@ -0,0 +1,44 @@ +import sjPrioMastercard from './assets/card-sj-prio-mastercard.png'; +import styles from './CardArt.module.css'; + +/** + * The bundled artwork per catalogue image key (CP-1, D-180). Vite emits the file under `/app/assets/` with a hashed + * name, served from the server's own origin (CSP `img-src 'self'`). + */ +const ARTWORK: Readonly> = { + sj_prio_mastercard: sjPrioMastercard, +}; + +/** Intrinsic size of the committed SJ Prio picture (D-180), so the box is reserved before it loads. */ +const ART_WIDTH = 1313; +const ART_HEIGHT = 829; + +export type CardArtSize = 'small' | 'large' | 'full'; + +/** + * A card's picture (S-23 header small, S-24 tile full width, S-25 and the empty state large): the artwork at its own + * aspect ratio, never clipped and without a background (it carries its own corners and notch); for an unknown image + * key the drawn card of D-173 (aspect 1.586, radius 16, the `green` token's light value, the name in white). + */ +export function CardArt({ imageKey, name, size }: { imageKey: string; name: string; size: CardArtSize }) { + const src = ARTWORK[imageKey]; + const sizeClass = styles[size]; + if (src !== undefined) { + return ( + {name} + ); + } + return ( +
+ +
+ ); +} diff --git a/web/src/features/cards/CardConnectionPage.tsx b/web/src/features/cards/CardConnectionPage.tsx new file mode 100644 index 0000000..9b7ac2c --- /dev/null +++ b/web/src/features/cards/CardConnectionPage.tsx @@ -0,0 +1,201 @@ +import { useId, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useCards, type CardConnection, type CardProvider } from '../../api/cards'; +import { apiErrorText } from '../../api/errors'; +import { connectCardPath, paths } from '../../app/routes'; +import { TopBar } from '../../app/TopBar'; +import { relativeText } from '../../app/UpdatedLine'; +import { useNow } from '../../app/useNow'; +import { Button, ButtonLink } from '../../components/Button'; +import { Card } from '../../components/Card'; +import { Dialog } from '../../components/Dialog'; +import { EmptyState } from '../../components/EmptyState'; +import { ErrorState, LoadingState } from '../../components/PageStates'; +import { useToast } from '../../components/Toast'; +import { Icon } from '../../design/Icon'; +import { useI18n } from '../../i18n/useI18n'; +import { detailDate, localDateTime } from '../../lib/dates'; +import { CardArt } from './CardArt'; +import styles from './Cards.module.css'; +import { useCardProviders, useConnectionWrites } from './cardsApi'; +import { HISTORY_CHOICES, isExpired, isExpiring } from './model'; + +const MINUTE_MS = 60_000; + +/** W-23 the card connection, /app/settings/cards/:id (mirrors S-26). */ +export default function CardConnectionPage() { + const { t } = useI18n(); + const { id = '' } = useParams(); + const cards = useCards(); + const providers = useCardProviders(); + if (cards.data === undefined) { + return ( + <> + +
+ {cards.isError ? ( + void cards.refetch()} /> + ) : ( + + )} +
+ + ); + } + const connection = cards.data.connections.get(id); + if (connection === undefined || connection.status === 'disconnected') { + return ( + <> + + + + ); + } + const provider = providers.data?.find((p) => p.id === connection.provider_id); + return ; +} + +function Connection({ connection, provider }: { connection: CardConnection; provider: CardProvider | undefined }) { + const i18n = useI18n(); + const { t, tp, language } = i18n; + const navigate = useNavigate(); + const toast = useToast(); + const writes = useConnectionWrites(); + const now = useNow(MINUTE_MS); + const historyId = useId(); + const eraseId = useId(); + const [busy, setBusy] = useState(false); + const [confirm, setConfirm] = useState(false); + const [eraseHandled, setEraseHandled] = useState(false); + const name = provider?.name ?? t('cards_title'); + const expired = isExpired(connection, now); + const highlight = expired || isExpiring(connection, now); + + const setHistory = async (days: number) => { + setBusy(true); + try { + await writes.setHistory(connection.id, days); + } catch (error) { + toast({ message: apiErrorText(error, t) }); + } finally { + setBusy(false); + } + }; + + const disconnect = async () => { + setBusy(true); + try { + await writes.disconnect(connection.id, eraseHandled); + setConfirm(false); + void navigate(paths.settingsCards, { replace: true }); + } catch (error) { + toast({ message: apiErrorText(error, t) }); + } finally { + setBusy(false); + } + }; + + return ( + <> + +
+ {provider === undefined ? null : } + +

+ {expired ? t('cards_status_expired') : t('cards_status_active')} +

+ {connection.valid_until === null ? null : ( +

{t('cards_valid_until', detailDate(localDateTime(new Date(connection.valid_until)), language))}

+ )} + {connection.last_fetch_at === null ? null : ( +

+ {t( + 'cards_last_updated', + relativeText( + Date.parse(connection.last_fetch_at), + Math.max(now, Date.parse(connection.last_fetch_at)), + i18n, + ), + )} +

+ )} +
+ + + +

{t('cards_history_longer_note')}

+
+
+ } + > + {t('cards_renew')} + + +
+
+ { + setConfirm(false); + }} + actions={ + <> + + + + } + > +

{t('cards_disconnect_body')}

+

+ { + setEraseHandled(event.target.checked); + }} + /> + +

+
+ + ); +} diff --git a/web/src/features/cards/Cards.module.css b/web/src/features/cards/Cards.module.css new file mode 100644 index 0000000..63b7327 --- /dev/null +++ b/web/src/features/cards/Cards.module.css @@ -0,0 +1,434 @@ +.page { + display: flex; + flex-direction: column; + gap: var(--gap-section); + padding: 0 var(--gutter) var(--s6); +} +.intro, +.subtitle { + font: var(--type-body-large); + letter-spacing: var(--tracking-body-large); + color: var(--on-surface-variant); +} +.muted { + font: var(--type-body-medium); + letter-spacing: var(--tracking-body-medium); + color: var(--on-surface-variant); +} +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s4); + padding-top: var(--s6); +} + +/* Header card (S-23): artwork, name, privacy note, updated line, Uppdatera. */ +.header { + gap: var(--s2); +} +.headerTop { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s3) var(--s4); +} +.headerText { + flex: 1; + min-width: 12rem; + display: flex; + flex-direction: column; + gap: 2px; +} +.headerName { + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); + color: var(--on-surface); + text-decoration: none; +} +.headerName:hover { + text-decoration: underline; +} + +/* Banners: expiry, expired, fetch failed, the connect attempt. */ +.banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s2) var(--s3); + padding: var(--s3) var(--s4); + border-radius: var(--radius-large); + background: var(--surface-container); + color: var(--on-surface); +} +.bannerWarning { + background: var(--warning-container); +} +.bannerError { + background: var(--error-container); + color: var(--on-error-container); +} +.bannerText { + flex: 1; + min-width: 12rem; + display: flex; + flex-direction: column; +} +.bannerDetail { + font: var(--type-body-medium); +} +.spinner { + animation: spin 1.2s linear infinite; +} +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } +} +@keyframes spin { + to { + transform: rotate(-360deg); + } +} +.iconButton { + display: grid; + place-items: center; + width: var(--s10); + height: var(--s10); + border: none; + border-radius: var(--radius-full); + background: none; + color: inherit; + cursor: pointer; +} +.iconButton:hover { + background: color-mix(in srgb, currentColor 8%, transparent); +} + +/* Filter chips with counts. */ +.filters { + display: flex; + flex-wrap: wrap; + gap: var(--s2); +} +.filter { + display: inline-flex; + align-items: center; + gap: var(--s1); + min-height: var(--s8); + padding: 0 var(--s3); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-small); + background: none; + color: var(--on-surface-variant); + font: var(--type-label-large); + letter-spacing: var(--tracking-label); + cursor: pointer; +} +.filterOn { + border-color: transparent; + background: var(--secondary-container); + color: var(--on-secondary-container); +} +.count { + font-variant-numeric: tabular-nums; +} + +/* The list and, from 720 px, the purchase panel beside it. */ +.layout { + display: grid; + gap: var(--gap-section); + align-items: start; +} +@media (min-width: 720px) { + .layoutWithPanel { + grid-template-columns: minmax(0, 1fr) minmax(0, 24rem); + } +} +.list { + display: flex; + flex-direction: column; + gap: var(--gap-section); + min-width: 0; +} +.day { + display: flex; + flex-direction: column; +} +.dayHeader { + padding: var(--s3) var(--s2) 2px; + margin-bottom: var(--s2); + font: var(--type-title-small); + letter-spacing: var(--tracking-title-small); + color: var(--on-surface-variant); +} + +/* A purchase row (S-23). */ +.row { + display: flex; + flex-direction: column; + background: var(--card); +} +.selected { + background: var(--secondary-container); +} +.rowMain { + display: flex; + align-items: center; + gap: var(--s3); + width: 100%; + min-height: var(--row-min); + padding: var(--s3) var(--s4); + border: none; + background: none; + color: var(--on-surface); + text-align: start; + text-decoration: none; + font: inherit; + cursor: pointer; +} +.rowMain:hover { + background: color-mix(in srgb, var(--on-surface) 4%, transparent); +} +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.merchant { + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rowMeta { + font: var(--type-body-medium); + color: var(--on-surface-variant); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rowEnd { + flex-shrink: 0; + text-align: end; +} +.foreign { + font: var(--type-amount-small); +} +.foreignLarge { + font: var(--type-amount-large); + letter-spacing: var(--tracking-amount-large); +} +.chips { + display: flex; + flex-wrap: wrap; + gap: var(--s1); +} +.chip { + align-self: flex-start; + padding: 0 var(--s2); + border-radius: var(--radius-small); + background: var(--pending-badge); + color: var(--pending); + font: var(--type-label-medium); +} +.matchChip { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: var(--s1); + max-width: calc(100% - 2 * var(--s4)); + min-height: var(--s8); + margin: 0 var(--s4) var(--s3); + padding: 0 var(--s3); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-small); + background: none; + color: var(--primary); + font: var(--type-label-large); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +/* The purchase panel (side panel from 720 px, dialog content below). */ +.panel { + position: sticky; + top: var(--s4); + display: flex; + flex-direction: column; + gap: var(--s4); + padding: var(--s5); + border-radius: var(--radius-extra-large); + background: var(--card); + box-shadow: var(--elevation-card); +} +.panelHeader { + display: flex; + align-items: flex-start; + gap: var(--s2); +} +.panelTitle { + flex: 1; + min-width: 0; + font: var(--type-title-large); + overflow-wrap: anywhere; +} +.details { + display: flex; + flex-direction: column; + gap: var(--s4); + color: var(--on-surface); +} +.facts { + display: flex; + flex-direction: column; + gap: var(--s1); +} +.factLine { + font: var(--type-body-large); +} +.note { + padding: var(--s3) var(--s4); + border-radius: var(--radius-large); + background: var(--surface-container); + font: var(--type-body-medium); +} +.primaryAction { + align-self: flex-start; +} +.section { + display: flex; + flex-direction: column; + gap: var(--s2); +} +.sectionTitle { + font: var(--type-title-small); + letter-spacing: var(--tracking-title-small); + color: var(--on-surface-variant); +} +.choices { + display: flex; + flex-direction: column; + gap: var(--gap-segmented); +} +.choice { + display: flex; + align-items: center; + gap: var(--s3); + width: 100%; + min-height: var(--row-min); + padding: var(--s2) var(--s3); + border: none; + border-radius: var(--radius-small); + background: var(--surface-container-low); + color: var(--on-surface); + text-align: start; + font: inherit; + cursor: pointer; +} +.choice:hover:not(:disabled) { + background: var(--surface-container); +} +.choiceText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +.choiceTitle { + font: var(--type-title-small); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.choiceMeta { + font: var(--type-body-small); + color: var(--on-surface-variant); +} +.actionRow { + display: flex; + flex-wrap: wrap; + gap: var(--s2); +} + +/* W-22 catalogue and connect step. */ +.tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr)); + gap: var(--gap-section); +} +.tile { + display: flex; + flex-direction: column; + gap: var(--s3); + padding: var(--s4); + border-radius: var(--radius-extra-large); + background: var(--card); + box-shadow: var(--elevation-card); + color: var(--on-surface); + text-decoration: none; +} +.tileDisabled { + opacity: 0.6; +} +.tileText { + display: flex; + flex-direction: column; + gap: 2px; +} +.tileName { + font: var(--type-title-medium); +} +.tileStatus { + font: var(--type-label-large); + color: var(--primary); +} +.skeletonTile { + aspect-ratio: 1.586; + max-width: calc(var(--s12) * 6); + border-radius: var(--radius-extra-large); + background: var(--surface-container); +} +.historyLabel { + font: var(--type-title-small); + letter-spacing: var(--tracking-title-small); +} +.waiting { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--s2); +} +.waitingTitle { + font: var(--type-title-medium); +} + +/* W-23. */ +.statusActive { + font: var(--type-title-medium); + color: var(--owed); +} +.statusEnded { + font: var(--type-title-medium); + color: var(--warning); +} +.select { + min-height: var(--s12); + padding: 0 var(--s3); + border: 1px solid var(--outline); + border-radius: var(--radius-extra-small); + background: var(--surface); + color: var(--on-surface); + font: var(--type-body-large); +} +.checkboxRow { + display: flex; + align-items: center; + gap: var(--s2); + color: var(--on-surface); +} diff --git a/web/src/features/cards/CardsPage.test.tsx b/web/src/features/cards/CardsPage.test.tsx new file mode 100644 index 0000000..798873b --- /dev/null +++ b/web/src/features/cards/CardsPage.test.tsx @@ -0,0 +1,444 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CardAttempt } from '../../api/cards'; +import type { Expense } from '../../api/types'; +import { + cardConnection, + cardPurchase, + CONNECTION_ID, + P, + putBody, + SJ_PRIO, + withCards, + type CardData, +} from '../../test/cards'; +import { errorResponse, FakeServer, json } from '../../test/fakeServer'; +import { account, ANNA, ANNA_ID, expense, JACOB, JACOB_ID, page } from '../../test/fixtures'; +import { renderApp } from '../../test/render'; + +const NOW = new Date('2026-10-17T08:00:00.000Z'); +const VARDAG = account('01929c5e-0000-7000-8000-00000000b001', 'Vardag', 3); +const HUSET = account('01929c5e-0000-7000-8000-00000000b002', 'Huset', 4); +const E = (n: number) => `01929c5e-0000-7000-8000-0000000e${String(n).padStart(4, '0')}`; + +function exp(id: string, patch: Partial = {}): Expense { + return { ...expense(id, VARDAG.id, 41_200, JACOB_ID, { [JACOB_ID]: 20_600, [ANNA_ID]: 20_600 }, 10), ...patch }; +} + +function server(expenses: Expense[] = [], cards: CardData = {}): FakeServer { + const base = new FakeServer().on('GET', '/sync', (request) => { + const since = Number(new URLSearchParams(request.search).get('since') ?? '0'); + return json(since === 0 ? page(0, { users: [JACOB, ANNA], accounts: [VARDAG, HUSET], expenses }) : page(since, {})); + }); + return withCards(base, cards); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +async function open(path: string, srv: FakeServer) { + const result = renderApp(path, { server: srv }); + await screen.findByRole('heading', { level: 1 }); + return { ...result, user: userEvent.setup() }; +} + +const filter = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}`) }); + +describe('W-21 Kortköp (S-23)', () => { + it('without a connection shows the empty state with the artwork and "Anslut kort"', async () => { + await open('/cards', server()); + expect(await screen.findByRole('heading', { name: 'Anslut ditt kort' })).toBeInTheDocument(); + expect( + screen.getByText('Se dina köp här och gör om dem till utgifter med några tryck. Bara du ser dem.'), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Anslut kort' })).toHaveAttribute('href', '/app/settings/cards'); + const art = await screen.findByRole('img', { name: 'SJ Prio Mastercard' }); + expect(art.tagName).toBe('IMG'); + expect(art.getAttribute('src')).toMatch(/card-sj-prio-mastercard.*\.png$/); + expect(screen.getByRole('heading', { level: 1, name: 'Kortköp' })).toBeInTheDocument(); + }); + + it('shows the first fetch while connected and no fetch has finished', async () => { + await open('/cards', server([], { connections: [cardConnection({ last_fetch_at: null, fetching: true })] })); + expect(await screen.findByText('Bara du ser dina kortköp')).toBeInTheDocument(); + expect(screen.getByText('Hämtar dina köp…')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Uppdaterar…' })).toBeDisabled(); + }); + + it('lists the purchases by day with filters and counts; pending, credits only under "Alla"', async () => { + const srv = server([], { + connections: [cardConnection()], + purchases: [ + cardPurchase(P(1), { merchant: 'ICA MAXI', purchase_date: '2026-10-16' }), + cardPurchase(P(2), { + merchant: 'SL ACCESS', + purchase_date: '2026-10-15', + status: 'pending', + booking_date: null, + }), + cardPurchase(P(3), { merchant: 'Inbetalning', direction: 'credit', amount_ore: 500_000 }), + cardPurchase(P(4), { merchant: 'Pressbyrån', ignored: true }), + cardPurchase(P(5), { merchant: 'Systembolaget', purchase_date: '2025-12-30' }), + ], + }); + const { user } = await open('/cards', srv); + expect(await screen.findByText('ICA MAXI')).toBeInTheDocument(); + expect(screen.getByText('Uppdaterad för 2 h sedan')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'SJ Prio Mastercard' })).toHaveAttribute( + 'href', + `/app/settings/cards/${CONNECTION_ID}`, + ); + expect(filter('Att hantera')).toHaveAttribute('aria-pressed', 'true'); + expect(filter('Att hantera')).toHaveTextContent('3'); + expect(filter('Kopplade')).toHaveTextContent('0'); + expect(filter('Ignorerade')).toHaveTextContent('1'); + expect(filter('Alla')).toHaveTextContent('5'); + expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual([ + '16 okt.', + '15 okt.', + '30 dec. 2025', + ]); + const pending = screen.getByText('SL ACCESS').closest('button'); + expect(pending).not.toBeNull(); + expect(within(pending as HTMLElement).getByText('Preliminär')).toBeInTheDocument(); + expect(screen.queryByText('Inbetalning')).not.toBeInTheDocument(); + expect(screen.queryByText('Pressbyrån')).not.toBeInTheDocument(); + + await user.click(filter('Alla')); + const credit = screen.getByText('Inbetalning').closest('button'); + expect(within(credit as HTMLElement).getByText('Insättning')).toBeInTheDocument(); + expect(screen.getByText('Pressbyrån')).toBeInTheDocument(); + await user.click(screen.getByText('Inbetalning')); + const sheet = screen.getByRole('dialog', { name: 'Inbetalning' }); + expect( + within(sheet).getByText( + 'Insättningar och återbetalningar är inga utgifter. Ändra den ursprungliga utgiften för hand vid behov.', + ), + ).toBeInTheDocument(); + expect(within(sheet).queryByRole('link', { name: 'Skapa utgift' })).not.toBeInTheDocument(); + }); + + it('links the only match in one tap from the row, with undo', async () => { + const srv = server([exp(E(1), { description: 'ICA Maxi Lindhagen', expense_date: '2026-10-14' })], { + connections: [cardConnection()], + purchases: [cardPurchase(P(1))], + }); + const { user } = await open('/cards', srv); + await user.click(await screen.findByRole('button', { name: 'Koppla till ICA Maxi Lindhagen' })); + expect(await screen.findByText('Kopplad')).toBeInTheDocument(); + expect(putBody(srv.calls('PUT', `/cards/purchases/${P(1)}`)[0] ?? fail())).toEqual({ + expense_id: E(1), + ignored: false, + }); + expect(await screen.findByText('Inget att hantera')).toBeInTheDocument(); + expect(screen.getByText('Nya kortköp visas här.')).toBeInTheDocument(); + expect(filter('Kopplade')).toHaveTextContent('1'); + + await user.click(screen.getByRole('button', { name: 'Ångra' })); + await waitFor(() => { + expect(srv.calls('PUT', `/cards/purchases/${P(1)}`)).toHaveLength(2); + }); + expect(putBody(srv.calls('PUT', `/cards/purchases/${P(1)}`)[1] ?? fail())).toEqual({ + expense_id: null, + ignored: false, + }); + expect(await screen.findByText('ICA MAXI LINDHAGEN')).toBeInTheDocument(); + }); + + it('opens the purchase with its matches; one click links and closes the panel', async () => { + const srv = server( + [ + exp(E(1), { description: 'Mat helgen', expense_date: '2026-10-15', payer_user_id: ANNA_ID }), + exp(E(2), { description: 'ICA', expense_date: '2026-10-16' }), + exp(E(3), { description: 'För långt bort', expense_date: '2026-10-11' }), + exp(E(4), { description: 'Annat belopp', amount_ore: 41_201 }), + ], + { connections: [cardConnection()], purchases: [cardPurchase(P(1))] }, + ); + const { user } = await open('/cards', srv); + await user.click(await screen.findByRole('button', { name: '2 möjliga utgifter' })); + const sheet = screen.getByRole('dialog', { name: 'ICA MAXI LINDHAGEN' }); + expect(within(sheet).getByText('fredag 16 oktober 2026')).toBeInTheDocument(); + expect(within(sheet).getByText('Bokförd')).toBeInTheDocument(); + expect(within(sheet).getByRole('link', { name: 'Skapa utgift' })).toHaveAttribute( + 'href', + `/app/expenses/new?cardPurchase=${P(1)}`, + ); + const choices = within(sheet) + .getAllByRole('button') + .filter((b) => b.textContent.includes('412,00')); + expect(choices.map((c) => c.textContent)).toEqual([ + expect.stringContaining('ICA'), + expect.stringContaining('Mat helgen'), + ]); + expect(within(sheet).getByText('Vardag · Anna · 15 okt.')).toBeInTheDocument(); + await user.click(within(sheet).getByText('Mat helgen')); + expect(await screen.findByText('Kopplad')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(putBody(srv.calls('PUT', `/cards/purchases/${P(1)}`)[0] ?? fail()).expense_id).toBe(E(1)); + }); + + it('offers "Välj en annan utgift" (14 days, any amount) and "Ignorera" with undo', async () => { + const srv = server( + [ + exp(E(1), { description: 'Hyra', amount_ore: 1_200_000, expense_date: '2026-10-02' }), + exp(E(2), { description: 'Gammal', expense_date: '2026-10-01' }), + ], + { connections: [cardConnection()], purchases: [cardPurchase(P(1), { amount_ore: 9_900 })] }, + ); + const { user } = await open('/cards', srv); + await user.click(await screen.findByText('ICA MAXI LINDHAGEN')); + const sheet = screen.getByRole('dialog', { name: 'ICA MAXI LINDHAGEN' }); + expect(within(sheet).getByText('Ingen utgift med samma belopp runt det här datumet')).toBeInTheDocument(); + await user.click(within(sheet).getByRole('button', { name: 'Välj en annan utgift' })); + const other = screen.getByRole('dialog', { name: 'Välj en annan utgift' }); + expect(within(other).getByText('Hyra')).toBeInTheDocument(); + expect(within(other).queryByText('Gammal')).not.toBeInTheDocument(); + await user.click(within(other).getByRole('button', { name: 'Avbryt' })); + + await user.click(within(sheet).getByRole('button', { name: 'Ignorera' })); + expect(await screen.findByText('Ignorerad')).toBeInTheDocument(); + expect(putBody(srv.calls('PUT', `/cards/purchases/${P(1)}`)[0] ?? fail())).toEqual({ + expense_id: null, + ignored: true, + }); + expect(filter('Ignorerade')).toHaveTextContent('1'); + }); + + it('shows linked and ignored purchases with their actions', async () => { + const srv = server([exp(E(1), { description: 'Veckohandling', account_id: HUSET.id })], { + connections: [cardConnection()], + purchases: [ + cardPurchase(P(1), { merchant: 'ICA KVANTUM', expense_id: E(1) }), + cardPurchase(P(2), { merchant: 'Pressbyrån', ignored: true }), + ], + }); + const { user } = await open('/cards', srv); + await screen.findByText('Inget att hantera'); + await user.click(filter('Kopplade')); + expect(screen.getByText('Kopplad till Veckohandling · Huset')).toBeInTheDocument(); + await user.click(screen.getByText('ICA KVANTUM')); + const sheet = screen.getByRole('dialog', { name: 'ICA KVANTUM' }); + expect(within(sheet).getByRole('link', { name: 'Öppna utgift' })).toHaveAttribute('href', `/app/expenses/${E(1)}`); + await user.click(within(sheet).getByRole('button', { name: 'Ta bort koppling' })); + await waitFor(() => { + expect(srv.calls('PUT', `/cards/purchases/${P(1)}`)).toHaveLength(1); + }); + expect(await screen.findByText('Inga köp här')).toBeInTheDocument(); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Stäng' })); + + await user.click(filter('Ignorerade')); + await user.click(screen.getByText('Pressbyrån')); + await user.click(screen.getByRole('button', { name: 'Sluta ignorera' })); + await waitFor(() => { + expect(putBody(srv.calls('PUT', `/cards/purchases/${P(2)}`)[0] ?? fail())).toEqual({ + expense_id: null, + ignored: false, + }); + }); + }); + + it('says why a link failed: expense_already_linked and not_found', async () => { + let answer = errorResponse('expense_already_linked', 409); + const srv = server([exp(E(1))], { + connections: [cardConnection()], + purchases: [cardPurchase(P(1))], + respond: () => answer, + }); + const { user } = await open('/cards', srv); + await user.click(await screen.findByRole('button', { name: 'Koppla till ICA Maxi Lindhagen' })); + expect(await screen.findByText('Utgiften är redan kopplad till ett annat kortköp.')).toBeInTheDocument(); + expect(filter('Att hantera')).toHaveTextContent('1'); + + answer = errorResponse('not_found', 404, 'expense_id'); + await user.click(screen.getByRole('button', { name: 'Koppla till ICA Maxi Lindhagen' })); + expect(await screen.findByText('Det här finns inte längre')).toBeInTheDocument(); + expect(screen.queryByText('Kopplad')).not.toBeInTheDocument(); + }); + + it('shows the expiry notice from 14 days before, and a failed fetch with "Försök igen"', async () => { + const srv = server([], { + connections: [ + cardConnection({ + valid_until: '2026-10-30T10:00:00.000Z', + last_fetch_error: 'bank_unavailable', + last_fetch_at: '2026-10-17T07:30:00.000Z', + }), + ], + }); + srv.on('POST', `/cards/connections/${CONNECTION_ID}/refresh`, () => + json({ connection: cardConnection({ fetching: true }), fetched: false }), + ); + const { user } = await open('/cards', srv); + expect(await screen.findByText('Kortanslutningen upphör fredag 30 oktober 2026')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Förnya med BankID' })).toHaveAttribute( + 'href', + '/app/settings/cards?provider=sj_prio_mastercard', + ); + expect(screen.getByText('Kunde inte hämta köp. Senast uppdaterad för 30 min sedan')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Försök igen' })); + await waitFor(() => { + expect(srv.calls('POST', `/cards/connections/${CONNECTION_ID}/refresh`)).toHaveLength(1); + }); + expect(await screen.findByRole('button', { name: 'Uppdaterar…' })).toBeDisabled(); + }); + + it('runs "Uppdatera" with E-42 and reads the snapshot again', async () => { + const srv = server([], { connections: [cardConnection()], purchases: [cardPurchase(P(1))] }); + srv.on('POST', `/cards/connections/${CONNECTION_ID}/refresh`, () => + json({ connection: cardConnection({ last_fetch_at: NOW.toISOString() }), fetched: true }), + ); + const { user } = await open('/cards', srv); + await user.click(await screen.findByRole('button', { name: 'Uppdatera' })); + const header = screen.getByText('Bara du ser dina kortköp').closest('section'); + expect(header).not.toBeNull(); + expect(await within(header as HTMLElement).findByText('Uppdaterad nyss')).toBeInTheDocument(); + await waitFor(() => { + expect(srv.calls('GET', '/cards/sync').length).toBeGreaterThanOrEqual(2); + }); + expect(srv.calls('GET', '/cards/sync').at(-1)?.search).not.toContain('since=0'); + }); + + it('keeps the list when the connection has ended, disables "Uppdatera" and offers renewal', async () => { + await open( + '/cards', + server([], { + connections: [cardConnection({ status: 'expired', valid_until: '2026-10-10T10:00:00.000Z' })], + purchases: [cardPurchase(P(1))], + }), + ); + expect(await screen.findByText('Kortanslutningen har upphört')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Uppdatera' })).toBeDisabled(); + expect(screen.getByRole('link', { name: 'Förnya med BankID' })).toBeInTheDocument(); + expect(screen.getByText('ICA MAXI LINDHAGEN')).toBeInTheDocument(); + }); + + it('shows long content: long merchants, 7-digit amounts and foreign currency', async () => { + const long = 'RESTAURANG OCH BAR VID STORTORGET I GAMLA STAN STOCKHOLM AB FILIAL NUMMER TOLV'; + await open( + '/cards', + server([], { + connections: [cardConnection()], + purchases: [ + cardPurchase(P(1), { merchant: long, amount_ore: 999_999_999 }), + cardPurchase(P(2), { + merchant: 'AMAZON.DE', + amount_ore: null, + original_amount: '42.90', + original_currency: 'EUR', + }), + ], + }), + ); + expect(await screen.findByText(long)).toBeInTheDocument(); + expect(screen.getByText('9 999 999,99 kr')).toBeInTheDocument(); + expect(screen.getByText('42.90 EUR')).toBeInTheDocument(); + expect(screen.getByText('Utländsk valuta')).toBeInTheDocument(); + await userEvent.setup().click(screen.getByText('AMAZON.DE')); + expect(screen.getByText('Beloppet är i EUR: skriv beloppet i kronor.')).toBeInTheDocument(); + }); + + it('shows the error state when the card snapshot cannot be read', async () => { + const srv = server(); + srv.on('GET', '/cards/sync', () => errorResponse('internal', 500)); + await open('/cards', srv); + expect(await screen.findByRole('heading', { name: 'Kunde inte hämta dina kortköp' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Försök igen' })).toBeInTheDocument(); + }); +}); + +describe('W-21 after the bank (CP-2, ?attempt=)', () => { + const ATTEMPT = '01929c5e-0000-7000-8000-00000000a7a1'; + const attempt = (patch: Partial): CardAttempt => ({ + id: ATTEMPT, + provider_id: SJ_PRIO.id, + status: 'pending', + failure: null, + auth_url: null, + expires_at: '2026-10-17T08:15:00.000Z', + connection: null, + ...patch, + }); + + it('polls E-38 every 2 s until the attempt is connected, then drops the parameter', async () => { + const answers = [ + attempt({}), + attempt({ status: 'connected', connection: cardConnection({ last_fetch_at: null }) }), + ]; + const srv = server(); + srv.on('GET', `/cards/auth/${ATTEMPT}`, () => json(answers.length > 1 ? answers.shift() : answers[0])); + const { router } = await open(`/cards?attempt=${ATTEMPT}`, srv); + expect(await screen.findByText('Väntar på BankID…')).toBeInTheDocument(); + expect(await screen.findByText('Kortet är anslutet')).toBeInTheDocument(); + expect(srv.calls('GET', `/cards/auth/${ATTEMPT}`)).toHaveLength(2); + await waitFor(() => { + expect(router.state.location.search).toBe(''); + }); + expect(router.state.location.pathname).toBe('/app/cards'); + expect(await screen.findByText('Hämtar dina köp…')).toBeInTheDocument(); + }); + + it('shows a failed or timed-out attempt with "Försök igen"', async () => { + const srv = server(); + srv.on('GET', `/cards/auth/${ATTEMPT}`, () => json(attempt({ status: 'expired' }))); + const { router } = await open(`/cards?attempt=${ATTEMPT}`, srv); + expect(await screen.findByText('Anslutningsförsöket tog för lång tid')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Försök igen' })).toHaveAttribute( + 'href', + '/app/settings/cards?provider=sj_prio_mastercard', + ); + await waitFor(() => { + expect(router.state.location.search).toBe(''); + }); + expect(screen.getByText('Anslutningsförsöket tog för lång tid')).toBeInTheDocument(); + }); + + it('treats an unknown attempt as failed', async () => { + await open(`/cards?attempt=${ATTEMPT}`, server()); + expect(await screen.findByText('Det gick inte att ansluta kortet')).toBeInTheDocument(); + }); +}); + +describe('W-21 from an account (S-27, ?account=)', () => { + it('lists the purchases to handle; one click opens W-5 with this account', async () => { + const srv = server([], { + connections: [cardConnection()], + purchases: [ + cardPurchase(P(1), { merchant: 'ICA MAXI' }), + cardPurchase(P(2), { merchant: 'Pressbyrån', ignored: true }), + cardPurchase(P(3), { + merchant: 'AMAZON.DE', + amount_ore: null, + original_amount: '1.00', + original_currency: 'EUR', + }), + ], + }); + await open(`/cards?account=${HUSET.id}`, srv); + expect(screen.getByRole('heading', { level: 1, name: 'Från kortköp' })).toBeInTheDocument(); + expect(await screen.findByText('Huset')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Tillbaka' })).toHaveAttribute('href', `/app/accounts/${HUSET.id}`); + expect(screen.getByRole('link', { name: /ICA MAXI/ })).toHaveAttribute( + 'href', + `/app/expenses/new?cardPurchase=${P(1)}&account=${HUSET.id}`, + ); + expect(screen.getByRole('link', { name: /AMAZON\.DE/ })).toBeInTheDocument(); + expect(screen.queryByText('Pressbyrån')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Koppla till/ })).not.toBeInTheDocument(); + }); + + it('says when there is nothing to handle', async () => { + await open(`/cards?account=${HUSET.id}`, server([], { connections: [cardConnection()] })); + expect(await screen.findByText('Inga kortköp att hantera')).toBeInTheDocument(); + }); +}); + +function fail(): never { + throw new Error('missing request'); +} diff --git a/web/src/features/cards/CardsPage.tsx b/web/src/features/cards/CardsPage.tsx new file mode 100644 index 0000000..0a8ab02 --- /dev/null +++ b/web/src/features/cards/CardsPage.tsx @@ -0,0 +1,571 @@ +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router'; +import { + currentConnection, + livePurchases, + useCards, + useCardWriter, + type CardAttempt, + type CardConnection, + type CardProvider, + type CardSnapshot, +} from '../../api/cards'; +import { ApiError } from '../../api/client'; +import { apiErrorText } from '../../api/errors'; +import { useLedger, type Ledger } from '../../api/ledger'; +import { REFETCH_INTERVAL_MS } from '../../api/queryClient'; +import { connectCardPath, expensesNewPath, paths, QUERY } from '../../app/routes'; +import { useShortcut } from '../../app/shortcuts'; +import { TopBar } from '../../app/TopBar'; +import { relativeText } from '../../app/UpdatedLine'; +import { useNow } from '../../app/useNow'; +import { useSession } from '../../app/useSession'; +import { useToday } from '../../app/useToday'; +import { Button, ButtonLink } from '../../components/Button'; +import { Card } from '../../components/Card'; +import { EmptyState } from '../../components/EmptyState'; +import { SegmentedGroup, SegmentedItem } from '../../components/LedgerRow'; +import { ErrorState, LoadingState } from '../../components/PageStates'; +import { useToast } from '../../components/Toast'; +import { Icon } from '../../design/Icon'; +import { useI18n } from '../../i18n/useI18n'; +import { detailDate, listDate, localDateTime, parseDate, type LocalDate } from '../../lib/dates'; +import { CardArt } from './CardArt'; +import styles from './Cards.module.css'; +import { useCardProviders, useConnectionWrites, useReadCardAttempt } from './cardsApi'; +import { + anyFetching, + ATTEMPT_POLL_MS, + dayGroups, + fetchFailed, + filterCounts, + FILTERS, + inFilter, + isExpired, + isExpiring, + isFirstFetch, + matchesFor, + purchasesToHandle, + type CardFilter, +} from './model'; +import { usePurchaseActions } from './purchaseActions'; +import { PurchasePanel } from './PurchasePanel'; +import { PurchaseRow } from './PurchaseRow'; +import { useWideLayout } from './useWideLayout'; + +const MINUTE_MS = 60_000; + +const FILTER_LABEL = { + to_handle: 'cards_filter_to_handle', + linked: 'cards_filter_linked', + ignored: 'cards_filter_ignored', + all: 'cards_filter_all', +} as const; + +/** While a fetch runs at the server, the snapshot is read every 2 s (as attempts are polled, CP-2), else every 60 s. */ +function cardsInterval(snapshot: CardSnapshot | undefined): number { + return anyFetching(snapshot) ? ATTEMPT_POLL_MS : REFETCH_INTERVAL_MS; +} + +/** + * W-21 Kortköp, /app/cards (mirrors S-23); `?account=` shows it as S-27 "Från kortköp" for that account, and + * `?attempt=` polls the connection attempt the bank's return names (CP-2). + */ +export default function CardsPage() { + const [params] = useSearchParams(); + const accountId = params.get(QUERY.account); + return accountId === null ? : ; +} + +type AttemptOutcome = 'waiting' | 'failed' | 'expired'; + +/** CP-2: polls E-38 every 2 s while the attempt is pending, then reports the end and drops `?attempt=`. */ +function useAttemptReturn(): { outcome: AttemptOutcome | null; providerId: string | null; dismiss: () => void } { + const { t } = useI18n(); + const toast = useToast(); + const [params, setParams] = useSearchParams(); + const attemptId = params.get(QUERY.attempt); + const read = useReadCardAttempt(); + const { apply, refresh } = useCardWriter(); + const [ended, setEnded] = useState<{ outcome: 'failed' | 'expired'; providerId: string | null } | null>(null); + const attempt = useQuery({ + queryKey: ['cards', 'attempt', attemptId], + queryFn: ({ signal }) => read(attemptId ?? '', signal), + enabled: attemptId !== null, + retry: false, + refetchOnWindowFocus: false, + refetchInterval: (query) => (query.state.data?.status === 'pending' ? ATTEMPT_POLL_MS : false), + }); + + const drop = useCallback(() => { + setParams( + (current) => { + const next = new URLSearchParams(current); + next.delete(QUERY.attempt); + return next; + }, + { replace: true }, + ); + }, [setParams]); + + const finish = useCallback( + (data: CardAttempt | undefined) => { + if (data?.status === 'connected') { + if (data.connection !== null) apply({ connections: [data.connection] }); + toast({ message: t('cards_connected') }); + setEnded(null); + } else { + setEnded({ outcome: data?.status === 'expired' ? 'expired' : 'failed', providerId: data?.provider_id ?? null }); + } + void refresh(); + drop(); + }, + [apply, refresh, drop, toast, t], + ); + + const data = attempt.data; + const done = attemptId !== null && (attempt.isError || (data !== undefined && data.status !== 'pending')); + const handled = useRef(null); + useEffect(() => { + if (!done || handled.current === attemptId) return; + handled.current = attemptId; + finish(attempt.isError ? undefined : data); + }, [done, attemptId, attempt.isError, data, finish]); + + const waiting = attemptId !== null && !done; + return { + outcome: waiting ? 'waiting' : (ended?.outcome ?? null), + providerId: waiting ? (data?.provider_id ?? null) : (ended?.providerId ?? null), + dismiss: () => { + setEnded(null); + }, + }; +} + +function AttemptBanner({ + outcome, + providerId, + onDismiss, +}: { + outcome: AttemptOutcome; + providerId: string | null; + onDismiss: () => void; +}) { + const { t } = useI18n(); + if (outcome === 'waiting') { + return ( +
+ + {t('cards_waiting')} +
+ ); + } + return ( +
+ + + {outcome === 'expired' ? t('cards_connect_timeout') : t('cards_connect_failed')} + + + {t('common_retry')} + + +
+ ); +} + +function CardPurchasesPage() { + const { t } = useI18n(); + const cards = useCards(undefined, { refetchInterval: cardsInterval }); + const ledger = useLedger(); + const providers = useCardProviders(); + const attempt = useAttemptReturn(); + const top = ; + const banner = + attempt.outcome === null ? null : ( + + ); + + if (cards.data === undefined || ledger.data === undefined) { + const error = cards.isError ? cards.error : ledger.isError ? ledger.error : null; + return ( + <> + {top} +
+ {banner} + {error === null ? ( + + ) : ( + { + void cards.refetch(); + void ledger.refetch(); + }} + /> + )} +
+ + ); + } + + const connection = currentConnection(cards.data); + if (connection === undefined) { + const provider = providers.data?.[0]; + return ( + <> + {top} +
+ {banner} +
+ {provider === undefined ? null : ( + + )} + }> + {t('cards_connect')} + + } + /> +
+
+ + ); + } + + const provider = providers.data?.find((p) => p.id === connection.provider_id); + return ( + <> + {top} + + + ); +} + +function Header({ connection, provider }: { connection: CardConnection; provider: CardProvider | undefined }) { + const i18n = useI18n(); + const { t } = i18n; + const toast = useToast(); + const now = useNow(MINUTE_MS); + const writes = useConnectionWrites(); + const { refresh: pull } = useCardWriter(); + const [running, setRunning] = useState(false); + const expired = isExpired(connection, now); + const refreshing = running || connection.fetching; + + const refresh = async () => { + setRunning(true); + try { + await writes.refresh(connection.id); + } catch (error) { + if (error instanceof ApiError && error.code === 'card_consent_expired') void pull(); + else toast({ message: apiErrorText(error, t) }); + } finally { + setRunning(false); + } + }; + + return ( + +
+ {provider === undefined ? null : } +
+ + {provider?.name ?? t('cards_title')} + + {t('cards_private_note')} + {connection.last_fetch_at === null ? null : ( + + {t( + 'cards_updated', + relativeText( + Date.parse(connection.last_fetch_at), + Math.max(now, Date.parse(connection.last_fetch_at)), + i18n, + ), + )} + + )} +
+ +
+
+ ); +} + +function Notices({ connection, onRetry }: { connection: CardConnection; onRetry: () => void }) { + const i18n = useI18n(); + const { t, language } = i18n; + const now = useNow(MINUTE_MS); + const renew = ( + + {t('cards_renew')} + + ); + if (isExpired(connection, now)) { + return ( +
+ + {t('cards_expired')} + {renew} +
+ ); + } + const notices = []; + if (isExpiring(connection, now) && connection.valid_until !== null) { + notices.push( +
+ + + {t('cards_expiring', detailDate(localDateTime(new Date(connection.valid_until)), language))} + + {renew} +
, + ); + } + if (fetchFailed(connection)) { + const last = connection.last_fetch_at; + notices.push( +
+ + + {last === null + ? t('web_cards_fetch_failed_never') + : t('cards_fetch_failed', relativeText(Date.parse(last), Math.max(now, Date.parse(last)), i18n))} + + +
, + ); + } + return notices.length === 0 ? null : <>{notices}; +} + +function Loaded({ + snapshot, + ledger, + connection, + provider, + banner, +}: { + snapshot: CardSnapshot; + ledger: Ledger; + connection: CardConnection; + provider: CardProvider | undefined; + banner: ReactNode; +}) { + const { t, language } = useI18n(); + const session = useSession(); + const viewerId = session?.userId ?? ''; + const today = useToday(); + const wide = useWideLayout(); + const toast = useToast(); + const writes = useConnectionWrites(); + const actions = usePurchaseActions(); + const [filter, setFilter] = useState('to_handle'); + const [selectedId, setSelectedId] = useState(null); + const [busyId, setBusyId] = useState(null); + + const purchases = useMemo(() => livePurchases(snapshot), [snapshot]); + const counts = useMemo(() => filterCounts(purchases), [purchases]); + const groups = useMemo(() => dayGroups(purchases.filter((p) => inFilter(p, filter))), [purchases, filter]); + const selected = selectedId === null ? undefined : snapshot.purchases.get(selectedId); + const open = selected !== undefined && selected.deleted_at === null ? selected : undefined; + + const retry = () => { + writes.refresh(connection.id).catch((error: unknown) => { + toast({ message: apiErrorText(error, t) }); + }); + }; + const firstFetch = isFirstFetch(connection); + + return ( +
+ {banner} +
+ + {firstFetch ? ( + + ) : ( + <> +
+ {FILTERS.map((value) => ( + + ))} +
+
+
+ {groups.length === 0 ? ( + filter === 'to_handle' ? ( + + ) : ( + + ) + ) : ( + groups.map((group) => ( + + {group.purchases.map((purchase) => ( + + { + setSelectedId(purchase.id); + }} + onLink={(expense) => { + setBusyId(purchase.id); + void actions.link(purchase, expense.id).finally(() => { + setBusyId(null); + }); + }} + /> + + ))} + + )) + )} +
+ {open === undefined ? null : ( + { + setSelectedId(null); + }} + /> + )} +
+ + )} +
+ ); +} + +function DayGroupView({ + date, + today, + language, + children, +}: { + date: string; + today: LocalDate; + language: 'sv' | 'en'; + children: ReactNode; +}) { + const label = listDate(parseDate(date), today, language); + return ( +
+

{label}

+ {children} +
+ ); +} + +/** S-27 on the web: the viewer's purchases to handle; one click opens W-5 prefilled with this account (CP-9). */ +function FromCardPurchasesPage({ accountId }: { accountId: string }) { + const { t, language } = useI18n(); + const navigate = useNavigate(); + const today = useToday(); + const cards = useCards(); + const ledger = useLedger(); + const back = paths.account(accountId); + useShortcut('Escape', () => { + void navigate(back); + }); + const top = ; + if (cards.data === undefined || ledger.data === undefined) { + const error = cards.isError ? cards.error : ledger.isError ? ledger.error : null; + return ( + <> + {top} +
+ {error === null ? ( + + ) : ( + { + void cards.refetch(); + void ledger.refetch(); + }} + /> + )} +
+ + ); + } + const account = ledger.data.accounts.get(accountId); + const groups = dayGroups(purchasesToHandle(cards.data)); + return ( + <> + {top} +
+ {account === undefined ? null :

{account.name}

} + {groups.length === 0 ? ( + + ) : ( +
+ {groups.map((group) => ( + + {group.purchases.map((purchase) => ( + + + + ))} + + ))} +
+ )} +
+ + ); +} diff --git a/web/src/features/cards/ConnectCardPage.test.tsx b/web/src/features/cards/ConnectCardPage.test.tsx new file mode 100644 index 0000000..8ee52ec --- /dev/null +++ b/web/src/features/cards/ConnectCardPage.test.tsx @@ -0,0 +1,230 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CardAttempt } from '../../api/cards'; +import { cardConnection, CONNECTION_ID, SJ_PRIO, withCards, type CardData } from '../../test/cards'; +import { errorResponse, FakeServer, json, type RecordedRequest } from '../../test/fakeServer'; +import { ANNA, JACOB, page } from '../../test/fixtures'; +import { renderApp } from '../../test/render'; +import { bankNavigation } from './cardsApi'; + +const NOW = new Date('2026-10-17T08:00:00.000Z'); +const ATTEMPT_ID = '01929c5e-0000-7000-8000-00000000a7a1'; +const BANK_URL = 'https://tilisy.enablebanking.example/auth?session=not-a-secret'; +const OTHER_CARD = { + id: 'other_card', + name: 'Annat kort', + issuer: 'Bank', + image_key: 'other_card', + available: false, +}; + +function server(cards: CardData = {}): FakeServer { + return withCards( + new FakeServer().on('GET', '/sync', () => json(page(0, { users: [JACOB, ANNA] }))), + cards, + ); +} + +function attempt(patch: Partial = {}): CardAttempt { + return { + id: ATTEMPT_ID, + provider_id: SJ_PRIO.id, + status: 'pending', + failure: null, + auth_url: BANK_URL, + expires_at: '2026-10-17T08:15:00.000Z', + connection: null, + ...patch, + }; +} + +const visited: string[] = []; +const assign = bankNavigation.assign.bind(bankNavigation); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + visited.length = 0; + bankNavigation.assign = (url: string) => { + visited.push(url); + }; +}); +afterEach(() => { + vi.useRealTimers(); + bankNavigation.assign = assign; +}); + +describe('W-22 Anslut kort (S-24)', () => { + it('shows the catalogue with artwork; an unavailable card cannot be opened', async () => { + renderApp('/settings/cards', { server: server({ providers: [SJ_PRIO, OTHER_CARD] }) }); + expect(await screen.findByRole('heading', { level: 1, name: 'Anslut kort' })).toBeInTheDocument(); + expect( + screen.getByText('Välj ditt kort. Kvitt läser dina köp men kan aldrig betala eller flytta pengar.'), + ).toBeInTheDocument(); + const tile = await screen.findByRole('link', { name: /SJ Prio Mastercard.*SEB Kort/ }); + expect(tile).toHaveAttribute('href', '/app/settings/cards?provider=sj_prio_mastercard'); + expect(within(tile).getByRole('img', { name: 'SJ Prio Mastercard' }).tagName).toBe('IMG'); + // D-173's drawn card for an unknown image key. + const drawn = screen.getByRole('img', { name: 'Annat kort' }); + expect(drawn.tagName).toBe('DIV'); + expect(screen.getByText('Inte konfigurerat på servern')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Annat kort/ })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Tillbaka' })).toHaveAttribute('href', '/app/settings'); + }); + + it('opens a connected card at W-23 with its validity', async () => { + renderApp('/settings/cards', { server: server({ connections: [cardConnection()] }) }); + const tile = await screen.findByRole('link', { name: /Ansluten · giltig till torsdag 22 april 2027/ }); + expect(tile).toHaveAttribute('href', `/app/settings/cards/${CONNECTION_ID}`); + }); + + it('shows the error with "Försök igen" when the catalogue cannot be read', async () => { + const srv = server(); + srv.on('GET', '/cards/providers', () => errorResponse('internal', 500)); + renderApp('/settings/cards', { server: srv }); + expect(await screen.findByRole('heading', { name: 'Kunde inte hämta korten' })).toBeInTheDocument(); + }); +}); + +describe('W-22 connect step (S-25)', () => { + it('asks E-37 with the chosen history and client web, then opens the bank in this tab', async () => { + const srv = server(); + const bodies: unknown[] = []; + srv.on('POST', '/cards/auth', (request: RecordedRequest) => { + bodies.push(request.body); + return json(attempt(), 201); + }); + srv.on('GET', `/cards/auth/${ATTEMPT_ID}`, () => json(attempt({ auth_url: null }))); + renderApp('/settings/cards?provider=sj_prio_mastercard', { server: srv }); + const user = userEvent.setup(); + expect(await screen.findByRole('heading', { level: 1, name: 'SJ Prio Mastercard' })).toBeInTheDocument(); + const history = screen.getByRole('radiogroup', { name: 'Hämta köp från de senaste' }); + expect( + within(history) + .getAllByRole('radio') + .map((r) => r.textContent), + ).toEqual(['7 dagar', '30 dagar', '90 dagar', '180 dagar']); + expect(within(history).getByRole('radio', { name: '30 dagar' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText('Du kan korta perioden senare. En längre period kräver BankID igen.')).toBeInTheDocument(); + await user.click(within(history).getByRole('radio', { name: '90 dagar' })); + await user.click(screen.getByRole('button', { name: 'Anslut med BankID' })); + expect(await screen.findByText('Väntar på BankID…')).toBeInTheDocument(); + expect(bodies).toEqual([{ provider_id: 'sj_prio_mastercard', history_days: 90, client: 'web' }]); + expect(visited).toEqual([BANK_URL]); + expect(screen.getByText('Slutför i webbläsaren och i BankID och kom sedan tillbaka hit.')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Avbryt' })); + expect(screen.getByRole('button', { name: 'Anslut med BankID' })).toBeInTheDocument(); + }); + + it('renews with the connection’s history preset', async () => { + const srv = server({ connections: [cardConnection({ history_days: 90, status: 'expired' })] }); + renderApp('/settings/cards?provider=sj_prio_mastercard', { server: srv }); + expect(await screen.findByRole('button', { name: 'Förnya med BankID' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: '90 dagar' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('never follows a non-https bank URL', async () => { + const srv = server(); + srv.on('POST', '/cards/auth', () => json(attempt({ auth_url: 'javascript:alert(1)' }), 201)); + renderApp('/settings/cards?provider=sj_prio_mastercard', { server: srv }); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Anslut med BankID' })); + expect(await screen.findByText('Det gick inte att ansluta kortet')).toBeInTheDocument(); + expect(visited).toEqual([]); + }); + + it('says when the server has no Enable Banking application or the bank does not answer', async () => { + let answer = errorResponse('cards_unavailable', 409); + const srv = server(); + srv.on('POST', '/cards/auth', () => answer); + renderApp('/settings/cards?provider=sj_prio_mastercard', { server: srv }); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Anslut med BankID' })); + expect(await screen.findByText('Inte konfigurerat på servern')).toBeInTheDocument(); + answer = errorResponse('card_bank_unavailable', 502); + await user.click(screen.getByRole('button', { name: 'Försök igen' })); + expect(await screen.findByText('Banken svarar inte just nu. Försök igen om en stund.')).toBeInTheDocument(); + expect(visited).toEqual([]); + }); + + it('shows the timeout when the attempt expires while the page is open', async () => { + const srv = server(); + srv.on('POST', '/cards/auth', () => json(attempt(), 201)); + srv.on('GET', `/cards/auth/${ATTEMPT_ID}`, () => json(attempt({ auth_url: null, status: 'expired' }))); + renderApp('/settings/cards?provider=sj_prio_mastercard', { server: srv }); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Anslut med BankID' })); + expect(await screen.findByText('Anslutningsförsöket tog för lång tid')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Försök igen' })).toBeEnabled(); + }); + }); +}); + +describe('W-23 card connection (S-26)', () => { + it('shows status, validity and history; a shorter history is sent with E-41', async () => { + const srv = server({ connections: [cardConnection({ history_days: 90 })] }); + const patches: unknown[] = []; + srv.on('PATCH', `/cards/connections/${CONNECTION_ID}`, (request) => { + patches.push(request.body); + return json(cardConnection({ history_days: 30 })); + }); + renderApp(`/settings/cards/${CONNECTION_ID}`, { server: srv }); + expect(await screen.findByRole('heading', { level: 1, name: 'SJ Prio Mastercard' })).toBeInTheDocument(); + expect(screen.getByText('Ansluten')).toBeInTheDocument(); + expect(screen.getByText('Giltig till torsdag 22 april 2027')).toBeInTheDocument(); + expect(screen.getByText('Senast uppdaterad för 2 h sedan')).toBeInTheDocument(); + const select = screen.getByLabelText('Historik vid anslutning'); + expect(select).toHaveValue('90'); + expect(within(select).getByRole('option', { name: '180 dagar' })).toBeDisabled(); + expect(within(select).getByRole('option', { name: '7 dagar' })).toBeEnabled(); + expect(screen.getByText('En längre period kräver BankID igen')).toBeInTheDocument(); + await userEvent.setup().selectOptions(select, '30'); + await waitFor(() => { + expect(patches).toEqual([{ history_days: 30 }]); + }); + expect(await screen.findByDisplayValue('30 dagar')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Förnya med BankID' })).toHaveAttribute( + 'href', + '/app/settings/cards?provider=sj_prio_mastercard', + ); + }); + + it('disconnects after the confirmation, with "Radera även kopplade och ignorerade köp"', async () => { + const srv = server({ connections: [cardConnection()] }); + const searches: string[] = []; + srv.on('DELETE', `/cards/connections/${CONNECTION_ID}`, (request) => { + searches.push(request.search); + return json(cardConnection({ status: 'disconnected' })); + }); + const { router } = renderApp(`/settings/cards/${CONNECTION_ID}`, { server: srv }); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Koppla från' })); + const dialog = screen.getByRole('alertdialog', { name: 'Koppla från SJ Prio Mastercard?' }); + expect( + within(dialog).getByText( + 'Köp som du inte har kopplat eller ignorerat raderas. Utgifter du har skapat finns kvar.', + ), + ).toBeInTheDocument(); + await user.click(within(dialog).getByLabelText('Radera även kopplade och ignorerade köp')); + await user.click(within(dialog).getByRole('button', { name: 'Koppla från' })); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app/settings/cards'); + }); + expect(searches).toEqual(['?erase_handled=true']); + }); + + it('shows "Har upphört" for an ended connection and not-found for a disconnected one', async () => { + renderApp(`/settings/cards/${CONNECTION_ID}`, { + server: server({ connections: [cardConnection({ status: 'expired' })] }), + }); + expect(await screen.findByText('Har upphört')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Förnya med BankID' })).toBeInTheDocument(); + }); + + it('answers a disconnected or unknown connection with not found', async () => { + renderApp(`/settings/cards/${CONNECTION_ID}`, { + server: server({ connections: [cardConnection({ status: 'disconnected' })] }), + }); + expect(await screen.findByText('Det här finns inte längre')).toBeInTheDocument(); + }); +}); diff --git a/web/src/features/cards/ConnectCardPage.tsx b/web/src/features/cards/ConnectCardPage.tsx new file mode 100644 index 0000000..8ed4cd6 --- /dev/null +++ b/web/src/features/cards/ConnectCardPage.tsx @@ -0,0 +1,247 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router'; +import { useCards, useCardWriter, type CardConnection, type CardProvider } from '../../api/cards'; +import { ApiError } from '../../api/client'; +import { apiErrorText } from '../../api/errors'; +import { connectCardPath, paths, QUERY } from '../../app/routes'; +import { TopBar } from '../../app/TopBar'; +import { Button } from '../../components/Button'; +import { Card } from '../../components/Card'; +import { ErrorState } from '../../components/PageStates'; +import { Segmented } from '../../components/Segmented'; +import { useToast } from '../../components/Toast'; +import { Icon } from '../../design/Icon'; +import type { I18n } from '../../i18n/I18nProvider'; +import { useI18n } from '../../i18n/useI18n'; +import { detailDate, localDateTime } from '../../lib/dates'; +import { CardArt } from './CardArt'; +import styles from './Cards.module.css'; +import { bankNavigation, isBankUrl, useCardProviders, useReadCardAttempt, useStartCardAuth } from './cardsApi'; +import { ATTEMPT_POLL_MS, DEFAULT_HISTORY_DAYS, HISTORY_CHOICES } from './model'; + +/** The viewer's connection for a provider that is not disconnected (one per user and provider, D-167). */ +function connectionFor(connections: ReadonlyMap | undefined, providerId: string) { + if (connections === undefined) return undefined; + return [...connections.values()].find((c) => c.provider_id === providerId && c.status !== 'disconnected'); +} + +/** + * W-22 Anslut kort, /app/settings/cards (mirrors S-24): the catalogue with artwork; `?provider=` shows S-25's + * connect step for that card (also "Förnya med BankID"), whose button opens the bank's page in the same tab (D-174). + */ +export default function ConnectCardPage() { + const { t } = useI18n(); + const [params] = useSearchParams(); + const providerId = params.get(QUERY.provider); + const providers = useCardProviders(); + const cards = useCards(); + const connections = cards.data?.connections; + const step = providerId === null ? undefined : providers.data?.find((p) => p.id === providerId && p.available); + + if (step !== undefined) { + return ; + } + return ( + <> + +
+

{t('cards_connect_intro')}

+ {providers.data === undefined ? ( + providers.isError ? ( + void providers.refetch()} + /> + ) : ( +
+ ) + ) : ( +
+ {providers.data.map((provider) => ( + + ))} +
+ )} +
+ + ); +} + +function ProviderTile({ provider, connection }: { provider: CardProvider; connection: CardConnection | undefined }) { + const { t, language } = useI18n(); + const art = ; + const text = (line: string | null) => ( + + {provider.name} + {provider.issuer} + {line === null ? null : {line}} + + ); + if (connection !== undefined) { + const line = + connection.status === 'expired' || connection.valid_until === null + ? t('cards_status_expired') + : t('cards_provider_connected', detailDate(localDateTime(new Date(connection.valid_until)), language)); + return ( + + {art} + {text(line)} + + ); + } + if (!provider.available) { + return ( +
+ {art} + {text(t('cards_provider_unavailable'))} +
+ ); + } + return ( + + {art} + {text(null)} + + ); +} + +function startErrorText(error: unknown, t: I18n['t']): string { + if (error instanceof ApiError && error.code === 'cards_unavailable') return t('cards_provider_unavailable'); + if (error instanceof ApiError && error.code === 'card_bank_unavailable') return t('web_cards_bank_unavailable'); + return apiErrorText(error, t); +} + +type Step = { kind: 'idle' } | { kind: 'waiting'; attemptId: string } | { kind: 'failed'; reason: string | null }; + +/** + * S-25 on the web: the history choice (CP-3) and "Anslut med BankID" ("Förnya med BankID" for a renewal, the history + * preset to the connection's). E-37 with `client` `web`, then the bank's page in this tab, where BankID shows its QR. + * While the page is still open it polls the attempt every 2 s (CP-2); the callback page links back to W-21. + */ +function ConnectStep({ provider, connection }: { provider: CardProvider; connection: CardConnection | undefined }) { + const { t, tp } = useI18n(); + const navigate = useNavigate(); + const toast = useToast(); + const start = useStartCardAuth(); + const read = useReadCardAttempt(); + const { apply, refresh } = useCardWriter(); + const [days, setDays] = useState(connection?.history_days ?? DEFAULT_HISTORY_DAYS); + const [step, setStep] = useState({ kind: 'idle' }); + const [starting, setStarting] = useState(false); + const attemptId = step.kind === 'waiting' ? step.attemptId : null; + const attempt = useQuery({ + queryKey: ['cards', 'attempt', attemptId], + queryFn: ({ signal }) => read(attemptId ?? '', signal), + enabled: attemptId !== null, + retry: false, + refetchOnWindowFocus: false, + refetchInterval: (query) => (query.state.data?.status === 'pending' ? ATTEMPT_POLL_MS : false), + }); + const status = attempt.data?.status; + const connected = attempt.data?.connection ?? null; + + useEffect(() => { + if (attemptId === null) return; + if (status === 'connected') { + if (connected !== null) apply({ connections: [connected] }); + void refresh(); + toast({ message: t('cards_connected') }); + void navigate(paths.cards, { replace: true }); + } + }, [attemptId, status, connected, apply, refresh, toast, t, navigate]); + // An attempt that ended without connecting shows the failure, or the timeout (S-25). + const shown: Step = + step.kind === 'waiting' && (status === 'failed' || status === 'expired') + ? { kind: 'failed', reason: status === 'expired' ? t('cards_connect_timeout') : null } + : step; + + const connect = async () => { + setStarting(true); + try { + const created = await start(provider.id, days); + if (created.auth_url === null || !isBankUrl(created.auth_url)) { + setStep({ kind: 'failed', reason: null }); + return; + } + setStep({ kind: 'waiting', attemptId: created.id }); + bankNavigation.assign(created.auth_url); + } catch (error) { + setStep({ kind: 'failed', reason: startErrorText(error, t) }); + } finally { + setStarting(false); + } + }; + + const renewal = connection !== undefined; + return ( + <> + +
+ + {shown.kind === 'waiting' ? ( + +
+ +

{t('cards_waiting')}

+

{t('cards_waiting_body')}

+
+
+ +
+
+ ) : ( + + + ({ + value: String(value), + label: tp('cards_history_days', value), + }))} + value={String(days)} + onChange={(value) => { + setDays(Number(value)); + }} + /> +

{t('cards_history_note')}

+ {shown.kind === 'failed' ? ( +
+ + + {t('cards_connect_failed')} + {shown.reason === null ? null : {shown.reason}} + +
+ ) : null} +
+ +
+
+ )} +
+ + ); +} diff --git a/web/src/features/cards/PurchasePanel.tsx b/web/src/features/cards/PurchasePanel.tsx new file mode 100644 index 0000000..c1dab24 --- /dev/null +++ b/web/src/features/cards/PurchasePanel.tsx @@ -0,0 +1,252 @@ +import { useId, useMemo, useState } from 'react'; +import type { CardPurchase, CardSnapshot } from '../../api/cards'; +import type { Ledger } from '../../api/ledger'; +import { expensesNewPath, paths } from '../../app/routes'; +import { useShortcut } from '../../app/shortcuts'; +import { Button, ButtonLink } from '../../components/Button'; +import { Dialog } from '../../components/Dialog'; +import { Icon } from '../../design/Icon'; +import { useI18n } from '../../i18n/useI18n'; +import { detailDate, parseDate, type LocalDate } from '../../lib/dates'; +import styles from './Cards.module.css'; +import { matchesFor, otherExpensesFor, purchaseState } from './model'; +import { usePurchaseActions } from './purchaseActions'; +import { ExpenseChoice, PurchaseAmount } from './PurchaseParts'; + +interface Props { + purchase: CardPurchase; + ledger: Ledger; + snapshot: CardSnapshot; + viewerId: string; + today: LocalDate; + /** Side panel from 720 px, a dialog below (§15.6 W-21). */ + wide: boolean; + onClose: () => void; +} + +/** W-21's purchase panel: the S-23 sheet with every action for the purchase's state (CP-8 to CP-10). */ +export function PurchasePanel({ purchase, ledger, snapshot, viewerId, today, wide, onClose }: Props) { + const { t } = useI18n(); + const titleId = useId(); + useShortcut('Escape', onClose, { enabled: wide }); + const body = ( + + ); + if (!wide) { + return ( + + {t('common_close')} + + } + > + {body} + + ); + } + return ( + + ); +} + +function PurchaseDetails({ + purchase, + ledger, + snapshot, + viewerId, + today, + onDone, +}: Omit & { onDone: () => void }) { + const { t, language } = useI18n(); + const actions = usePurchaseActions(); + const [busy, setBusy] = useState(false); + const [otherOpen, setOtherOpen] = useState(false); + const state = purchaseState(purchase); + const matches = useMemo(() => matchesFor(purchase, ledger, snapshot), [purchase, ledger, snapshot]); + const others = useMemo( + () => (otherOpen ? otherExpensesFor(purchase, ledger, snapshot) : []), + [otherOpen, purchase, ledger, snapshot], + ); + const linked = purchase.expense_id === null ? undefined : ledger.expenses.get(purchase.expense_id); + + const act = async (run: () => Promise, closeAfter: boolean) => { + setBusy(true); + const done = await run(); + setBusy(false); + if (done && closeAfter) { + setOtherOpen(false); + onDone(); + } + }; + const link = (expenseId: string) => void act(() => actions.link(purchase, expenseId), true); + + return ( +
+
+ + {detailDate(parseDate(purchase.purchase_date), language)} + {purchase.status === 'pending' ? t('cards_pending') : t('cards_booked')} +
+ + {state === 'credit' ?

{t('cards_sheet_credit_note')}

: null} + + {state === 'to_handle' ? ( + <> + {purchase.amount_ore === null ? ( +

{t('cards_sheet_foreign_note', purchase.original_currency ?? '')}

+ ) : null} + } + className={styles.primaryAction} + > + {t('cards_sheet_create')} + +
+

{t('cards_sheet_matches')}

+ {matches.length === 0 ? ( +

{t('cards_sheet_no_matches')}

+ ) : ( +
+ {matches.map((expense) => ( + { + link(expense.id); + }} + /> + ))} +
+ )} +
+
+ + +
+ + ) : null} + + {state === 'linked' ? ( + <> + {linked === undefined ? null : ( +

+ {t('cards_linked_to', linked.description, ledger.accounts.get(linked.account_id)?.name ?? '')} +

+ )} +
+ {purchase.expense_id === null ? null : ( + } + > + {t('cards_sheet_open_expense')} + + )} + +
+ + ) : null} + + {state === 'ignored' ? ( +
+ +
+ ) : null} + + { + setOtherOpen(false); + }} + actions={ + + } + > + {others.length === 0 ? ( +

{t('web_cards_other_empty')}

+ ) : ( +
+ {others.map((expense) => ( + { + link(expense.id); + }} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/web/src/features/cards/PurchaseParts.tsx b/web/src/features/cards/PurchaseParts.tsx new file mode 100644 index 0000000..45d06e1 --- /dev/null +++ b/web/src/features/cards/PurchaseParts.tsx @@ -0,0 +1,87 @@ +/** Pieces of W-21's rows and purchase panel (S-23): the amount, the chips and an expense to link to. */ +import type { Ledger } from '../../api/ledger'; +import type { CardPurchase } from '../../api/cards'; +import type { Expense } from '../../api/types'; +import { Money } from '../../components/Money'; +import { useI18n } from '../../i18n/useI18n'; +import { listDate, parseDate, type LocalDate } from '../../lib/dates'; +import styles from './Cards.module.css'; + +/** `MoneyText`, or " " for a purchase in a foreign currency (CP-7). */ +export function PurchaseAmount({ purchase, large = false }: { purchase: CardPurchase; large?: boolean }) { + const { t } = useI18n(); + if (purchase.amount_ore !== null) { + return ; + } + return ( + + {t('cards_foreign_amount', purchase.original_amount ?? '', purchase.original_currency ?? '')} + + ); +} + +/** "Preliminär" for a pending purchase, "Insättning" for a credit, "Utländsk valuta" for a foreign amount. */ +export function PurchaseChips({ purchase }: { purchase: CardPurchase }) { + const { t } = useI18n(); + const chips: string[] = []; + if (purchase.status === 'pending') chips.push(t('cards_pending')); + if (purchase.direction === 'credit') chips.push(t('cards_credit')); + if (purchase.amount_ore === null) chips.push(t('cards_foreign')); + if (chips.length === 0) return null; + return ( + + {chips.map((chip) => ( + + {chip} + + ))} + + ); +} + +/** Account name, payer ("du" or the partner's name) and list date of an expense, joined with " · ". */ +export function expenseMeta( + expense: Expense, + ledger: Ledger, + viewerId: string, + today: LocalDate, + t: ReturnType['t'], + language: ReturnType['language'], +): string { + const account = ledger.accounts.get(expense.account_id)?.name ?? ''; + const payer = + expense.payer_user_id === viewerId + ? t('common_you') + : (ledger.users.get(expense.payer_user_id)?.display_name ?? ''); + return [account, payer, listDate(parseDate(expense.expense_date), today, language)] + .filter((part) => part !== '') + .join(t('common_separator_dot')); +} + +/** One expense offered for linking (S-23 sheet: description, account, payer, date, amount); one click links. */ +export function ExpenseChoice({ + expense, + ledger, + viewerId, + today, + disabled, + onChoose, +}: { + expense: Expense; + ledger: Ledger; + viewerId: string; + today: LocalDate; + disabled: boolean; + onChoose: () => void; +}) { + const { t, language } = useI18n(); + return ( + + ); +} diff --git a/web/src/features/cards/PurchaseRow.tsx b/web/src/features/cards/PurchaseRow.tsx new file mode 100644 index 0000000..c179947 --- /dev/null +++ b/web/src/features/cards/PurchaseRow.tsx @@ -0,0 +1,82 @@ +import { Link } from 'react-router'; +import type { CardPurchase } from '../../api/cards'; +import type { Ledger } from '../../api/ledger'; +import type { Expense } from '../../api/types'; +import { Icon } from '../../design/Icon'; +import { useI18n } from '../../i18n/useI18n'; +import styles from './Cards.module.css'; +import type { CardFilter } from './model'; +import { PurchaseAmount, PurchaseChips } from './PurchaseParts'; + +interface Props { + purchase: CardPurchase; + ledger: Ledger; + filter: CardFilter | null; + /** CP-10's matches (shown as the chip under "Att hantera"). */ + matches: readonly Expense[]; + selected?: boolean; + /** Opens the purchase (the panel), or `to` navigates (S-27 rows open W-5). */ + onOpen?: () => void; + to?: string; + /** One tap on "Koppla till " (AC-22.7). */ + onLink?: (expense: Expense) => void; + busy?: boolean; +} + +/** + * An S-23 row: merchant (one line), amount, the chips, under "Att hantera" the match chip ("Koppla till " + * links in one tap, " möjliga utgifter" opens the panel), under "Kopplade" the line "Kopplad till … · ". + */ +export function PurchaseRow({ purchase, ledger, filter, matches, selected, onOpen, to, onLink, busy }: Props) { + const { t, tp } = useI18n(); + const linked = purchase.expense_id === null ? undefined : ledger.expenses.get(purchase.expense_id); + const main = ( + <> + + {purchase.merchant} + {filter === 'linked' && linked !== undefined ? ( + + {t('cards_linked_to', linked.description, ledger.accounts.get(linked.account_id)?.name ?? '')} + + ) : null} + + + + + + + ); + const first = matches[0]; + return ( +
+ {to === undefined ? ( + + ) : ( + + {main} + + )} + {filter === 'to_handle' && first !== undefined ? ( + matches.length === 1 ? ( + + ) : ( + + ) + ) : null} +
+ ); +} diff --git a/web/src/features/cards/cardsApi.ts b/web/src/features/cards/cardsApi.ts new file mode 100644 index 0000000..2a9c85d --- /dev/null +++ b/web/src/features/cards/cardsApi.ts @@ -0,0 +1,125 @@ +/** + * The card endpoints only the card pages call (SPEC §14.3.4): the catalogue (E-36), connecting (E-37, E-38), the + * history setting (E-41), "Uppdatera" (E-42) and "Koppla från" (E-43). Every write merges its answer into the card + * snapshot and pulls (§15.5). E-44 lives with the snapshot in `api/cards.ts`, because W-5 and W-6 send it too. + */ +import { useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useCardWriter, type CardAttempt, type CardConnection } from '../../api/cards'; +import { unwrap } from '../../api/client'; +import { useApi } from '../../api/useApi'; + +export const PROVIDERS_QUERY_KEY = ['cards', 'providers'] as const; + +/** E-36: the card catalogue, `available` per caller (CP-13). Not part of the snapshot (it is not synced). */ +export function useCardProviders() { + const api = useApi(); + return useQuery({ + queryKey: PROVIDERS_QUERY_KEY, + queryFn: async ({ signal }) => (await unwrap(api.GET('/cards/providers', { signal }))).providers, + }); +} + +/** E-37 with `client` `web` (D-174): a new attempt whose `auth_url` the page opens in the same tab. */ +export function useStartCardAuth() { + const api = useApi(); + return useCallback( + (providerId: string, historyDays: number): Promise => + unwrap(api.POST('/cards/auth', { body: { provider_id: providerId, history_days: historyDays, client: 'web' } })), + [api], + ); +} + +/** E-38: one read of an attempt (W-21 polls it after the bank's return, CP-2). */ +export function useReadCardAttempt() { + const api = useApi(); + return useCallback( + (attemptId: string, signal?: AbortSignal): Promise => + unwrap(api.GET('/cards/auth/{attempt_id}', { params: { path: { attempt_id: attemptId } }, signal })), + [api], + ); +} + +export interface ConnectionWrites { + /** E-42: a user-present fetch; `fetched` false when the 60 s rule answered or the fetch still runs. */ + refresh: (connectionId: string) => Promise<{ connection: CardConnection; fetched: boolean }>; + /** E-41: a shorter history (a longer one is a renewal, CP-3). */ + setHistory: (connectionId: string, historyDays: number) => Promise; + /** E-43: disconnect (CP-12); `eraseHandled` also deletes the linked and ignored purchases. */ + disconnect: (connectionId: string, eraseHandled: boolean) => Promise; +} + +export function useConnectionWrites(): ConnectionWrites { + const api = useApi(); + const { apply, refresh: pull } = useCardWriter(); + const merged = useCallback( + async (pending: Promise, connectionOf: (value: T) => CardConnection): Promise => { + try { + const value = await pending; + apply({ connections: [connectionOf(value)] }); + return value; + } finally { + void pull(); + } + }, + [apply, pull], + ); + const refresh = useCallback( + (connectionId: string) => + merged( + unwrap( + api.POST('/cards/connections/{connection_id}/refresh', { + params: { path: { connection_id: connectionId } }, + }), + ), + (value) => value.connection, + ), + [api, merged], + ); + const setHistory = useCallback( + (connectionId: string, historyDays: number) => + merged( + unwrap( + api.PATCH('/cards/connections/{connection_id}', { + params: { path: { connection_id: connectionId } }, + body: { history_days: historyDays }, + }), + ), + (value) => value, + ), + [api, merged], + ); + const disconnect = useCallback( + (connectionId: string, eraseHandled: boolean) => + merged( + unwrap( + api.DELETE('/cards/connections/{connection_id}', { + params: { path: { connection_id: connectionId }, query: { erase_handled: eraseHandled } }, + }), + ), + (value) => value, + ), + [api, merged], + ); + return { refresh, setHistory, disconnect }; +} + +/** + * Leaving for the bank's page in the same tab (D-174). A seam rather than a direct `window.location` call so the + * tests can record the URL (jsdom does not navigate). Only an `https:` URL is followed: the URL comes from the server, + * and a `javascript:` or other scheme must never run in the page that holds the token (§15.2). + */ +export const bankNavigation = { + assign(url: string): void { + window.location.assign(url); + }, +}; + +export function isBankUrl(url: string): boolean { + try { + return new URL(url).protocol === 'https:'; + } catch (error) { + if (error instanceof TypeError) return false; + throw error; + } +} diff --git a/web/src/features/cards/i18n/en.json b/web/src/features/cards/i18n/en.json new file mode 100644 index 0000000..922503d --- /dev/null +++ b/web/src/features/cards/i18n/en.json @@ -0,0 +1,10 @@ +{ + "web_cards_load_failed": "Could not get your card purchases", + "web_cards_catalogue_failed": "Could not get the cards", + "web_cards_catalogue_loading": "Getting the cards…", + "web_cards_filters": "Filter", + "web_cards_fetch_failed_never": "Could not fetch purchases.", + "web_cards_already_linked": "That expense is already linked to another card purchase.", + "web_cards_bank_unavailable": "The bank is not answering right now. Try again in a while.", + "web_cards_other_empty": "No expenses within 14 days of the purchase." +} diff --git a/web/src/features/cards/i18n/sv.json b/web/src/features/cards/i18n/sv.json new file mode 100644 index 0000000..cf66c0f --- /dev/null +++ b/web/src/features/cards/i18n/sv.json @@ -0,0 +1,10 @@ +{ + "web_cards_load_failed": "Kunde inte hämta dina kortköp", + "web_cards_catalogue_failed": "Kunde inte hämta korten", + "web_cards_catalogue_loading": "Hämtar korten…", + "web_cards_filters": "Filter", + "web_cards_fetch_failed_never": "Kunde inte hämta köp.", + "web_cards_already_linked": "Utgiften är redan kopplad till ett annat kortköp.", + "web_cards_bank_unavailable": "Banken svarar inte just nu. Försök igen om en stund.", + "web_cards_other_empty": "Inga utgifter inom 14 dagar från köpet." +} diff --git a/web/src/features/cards/index.ts b/web/src/features/cards/index.ts new file mode 100644 index 0000000..f28a6d3 --- /dev/null +++ b/web/src/features/cards/index.ts @@ -0,0 +1,9 @@ +import type { RouteObject } from 'react-router'; +import { lazyPage } from '../../app/lazyPage'; + +/** Routes of K3's card purchases, relative to /app (inside the shell): W-21, W-22, W-23 (SPEC §15.6, D-174). */ +export const cardsRoutes: RouteObject[] = [ + { path: 'cards', ...lazyPage(() => import('./CardsPage')) }, + { path: 'settings/cards', ...lazyPage(() => import('./ConnectCardPage')) }, + { path: 'settings/cards/:id', ...lazyPage(() => import('./CardConnectionPage')) }, +]; diff --git a/web/src/features/cards/model.test.ts b/web/src/features/cards/model.test.ts new file mode 100644 index 0000000..46d4434 --- /dev/null +++ b/web/src/features/cards/model.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { applyCardRows, emptyCards, type CardSnapshot } from '../../api/cards'; +import { emptyLedger, type Ledger } from '../../api/ledger'; +import type { Expense } from '../../api/types'; +import { cardConnection, cardPurchase, P } from '../../test/cards'; +import { ANNA_ID, expense, JACOB_ID } from '../../test/fixtures'; +import { + anyFetching, + dayGroups, + fetchFailed, + filterCounts, + inFilter, + isExpired, + isExpiring, + isFirstFetch, + matchesFor, + otherExpensesFor, + purchaseState, + purchasesToHandle, +} from './model'; + +const NOW = Date.parse('2026-10-17T08:00:00.000Z'); +const E = (n: number) => `e${String(n)}`; +const exp = (id: string, patch: Partial = {}): Expense => ({ + ...expense(id, 'acc', 41_200, JACOB_ID, { [JACOB_ID]: 20_600, [ANNA_ID]: 20_600 }, 1), + ...patch, +}); + +function ledgerOf(expenses: Expense[]): Ledger { + return { ...emptyLedger(), expenses: new Map(expenses.map((e) => [e.id, e])) }; +} +function snapshotOf(purchases: ReturnType[]): CardSnapshot { + return applyCardRows(emptyCards(), { purchases }); +} + +describe('card filters (AC-22.4, CP-8)', () => { + it('puts a purchase in exactly one of to handle, linked, ignored; credits only under all', () => { + const debit = cardPurchase(P(1)); + const linked = cardPurchase(P(2), { expense_id: E(1) }); + const ignored = cardPurchase(P(3), { ignored: true }); + const credit = cardPurchase(P(4), { direction: 'credit' }); + expect([debit, linked, ignored, credit].map(purchaseState)).toEqual(['to_handle', 'linked', 'ignored', 'credit']); + expect(inFilter(credit, 'all')).toBe(true); + expect(inFilter(credit, 'to_handle')).toBe(false); + expect(filterCounts([debit, linked, ignored, credit])).toEqual({ to_handle: 1, linked: 1, ignored: 1, all: 4 }); + }); + + it('groups by day, newest first, and lists the purchases to handle for S-27', () => { + const a = cardPurchase(P(1), { purchase_date: '2026-10-15', created_at: '2026-10-15T10:00:00.000Z' }); + const b = cardPurchase(P(2), { purchase_date: '2026-10-16' }); + const c = cardPurchase(P(3), { purchase_date: '2026-10-15', created_at: '2026-10-15T12:00:00.000Z' }); + const gone = cardPurchase(P(4), { deleted_at: '2026-10-16T00:00:00.000Z' }); + expect(dayGroups([a, b, c]).map((g) => [g.date, g.purchases.map((p) => p.id)])).toEqual([ + ['2026-10-16', [P(2)]], + ['2026-10-15', [P(3), P(1)]], + ]); + expect(purchasesToHandle(snapshotOf([a, b, c, gone])).map((p) => p.id)).toEqual([P(2), P(3), P(1)]); + }); +}); + +describe('matches (CP-10)', () => { + it('excludes expenses linked from another purchase of the viewer, not from this one', () => { + const purchase = cardPurchase(P(1)); + const ledger = ledgerOf([exp(E(1)), exp(E(2), { expense_date: '2026-10-15' }), exp(E(3), { deleted_at: 'x' })]); + const other = cardPurchase(P(2), { expense_id: E(1) }); + expect(matchesFor(purchase, ledger, snapshotOf([purchase, other])).map((e) => e.id)).toEqual([E(2)]); + expect(matchesFor(purchase, ledger, snapshotOf([purchase])).map((e) => e.id)).toEqual([E(1), E(2)]); + expect(matchesFor({ ...purchase, ignored: true }, ledger, snapshotOf([purchase]))).toEqual([]); + expect(matchesFor({ ...purchase, amount_ore: null }, ledger, snapshotOf([purchase]))).toEqual([]); + }); + + it('"Välj en annan utgift": any amount within 14 days, newest first', () => { + const purchase = cardPurchase(P(1), { purchase_date: '2026-10-16' }); + const ledger = ledgerOf([ + exp(E(1), { amount_ore: 5, expense_date: '2026-10-02' }), + exp(E(2), { expense_date: '2026-10-01' }), + exp(E(3), { expense_date: '2026-10-30' }), + exp(E(4), { expense_date: '2026-10-31' }), + ]); + expect(otherExpensesFor(purchase, ledger, snapshotOf([purchase])).map((e) => e.id)).toEqual([E(3), E(1)]); + }); +}); + +describe('connection notices (CP-6, CP-14)', () => { + it('is expiring from 14 days before valid_until and expired after it', () => { + const in14 = cardConnection({ valid_until: new Date(NOW + 14 * 86_400_000).toISOString() }); + const in15 = cardConnection({ valid_until: new Date(NOW + 15 * 86_400_000).toISOString() }); + const past = cardConnection({ valid_until: new Date(NOW - 1).toISOString() }); + expect(isExpiring(in14, NOW)).toBe(true); + expect(isExpiring(in15, NOW)).toBe(false); + expect(isExpired(past, NOW)).toBe(true); + expect(isExpiring(past, NOW)).toBe(false); + expect(isExpired(cardConnection({ status: 'expired' }), NOW)).toBe(true); + expect(isExpired(cardConnection({ last_fetch_error: 'consent_expired' }), NOW)).toBe(true); + }); + + it('shows a failed fetch except for consent_expired; the first fetch until one finished', () => { + expect(fetchFailed(cardConnection({ last_fetch_error: 'rate_limited' }))).toBe(true); + expect(fetchFailed(cardConnection({ last_fetch_error: 'consent_expired' }))).toBe(false); + expect(isFirstFetch(cardConnection({ last_fetch_at: null }))).toBe(true); + expect(isFirstFetch(cardConnection({ last_fetch_at: null, last_fetch_error: 'failed' }))).toBe(false); + const fetching = applyCardRows(emptyCards(), { connections: [cardConnection({ fetching: true })] }); + expect(anyFetching(fetching)).toBe(true); + expect(anyFetching(applyCardRows(emptyCards(), { connections: [cardConnection()] }))).toBe(false); + }); +}); diff --git a/web/src/features/cards/model.ts b/web/src/features/cards/model.ts new file mode 100644 index 0000000..5c13201 --- /dev/null +++ b/web/src/features/cards/model.ts @@ -0,0 +1,159 @@ +/** + * Pure rules of the card pages over the card snapshot and the household ledger (SPEC §9.12): the four filters of + * S-23 (AC-22.4), the day groups, CP-10's matches and "Välj en annan utgift" list, and the connection's notices + * (CP-6 expiry, CP-14 fetch errors). + */ +import { live, type Ledger } from '../../api/ledger'; +import { livePurchases, type CardConnection, type CardPurchase, type CardSnapshot } from '../../api/cards'; +import type { Expense } from '../../api/types'; +import { parseDate, toEpochDays } from '../../lib/dates'; +import { MATCH_MAX, MATCH_WINDOW_DAYS, matchCardPurchase } from '../../lib/domain/matchCardPurchase'; + +export type CardFilter = 'to_handle' | 'linked' | 'ignored' | 'all'; +export const FILTERS: readonly CardFilter[] = ['to_handle', 'linked', 'ignored', 'all']; + +/** CP-3: the history choices, 30 days by default. */ +export const HISTORY_CHOICES = [7, 30, 90, 180] as const; +export const DEFAULT_HISTORY_DAYS = 30; +/** CP-6 / AC-22.11: the expiry notice shows from 14 days before `valid_until`. */ +export const EXPIRY_NOTICE_DAYS = 14; +/** CP-10: "Välj en annan utgift" lists expenses dated within 14 days of the purchase. */ +export const OTHER_WINDOW_DAYS = 14; +/** CP-2: the client polls E-38 every 2 seconds. */ +export const ATTEMPT_POLL_MS = 2_000; + +const DAY_MS = 86_400_000; + +/** Where a purchase belongs: a credit only under "Alla" (CP-8); linked, ignored or still to handle otherwise. */ +export type PurchaseState = 'credit' | 'linked' | 'ignored' | 'to_handle'; + +export function purchaseState(purchase: CardPurchase): PurchaseState { + if (purchase.direction === 'credit') return 'credit'; + if (purchase.expense_id !== null) return 'linked'; + if (purchase.ignored) return 'ignored'; + return 'to_handle'; +} + +export function inFilter(purchase: CardPurchase, filter: CardFilter): boolean { + return filter === 'all' || purchaseState(purchase) === filter; +} + +export function filterCounts(purchases: readonly CardPurchase[]): Record { + const counts: Record = { to_handle: 0, linked: 0, ignored: 0, all: purchases.length }; + for (const purchase of purchases) { + const state = purchaseState(purchase); + if (state !== 'credit') counts[state] += 1; + } + return counts; +} + +function compareText(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** Newest first (AC-22.4): purchase date, then when the server first stored it, then id. */ +export function comparePurchasesNewestFirst(a: CardPurchase, b: CardPurchase): number { + return ( + compareText(b.purchase_date, a.purchase_date) || compareText(b.created_at, a.created_at) || compareText(b.id, a.id) + ); +} + +/** S-27 / "Från kortköp": debit purchases neither linked nor ignored (SEK or foreign), newest first. */ +export function purchasesToHandle(snapshot: CardSnapshot): CardPurchase[] { + return livePurchases(snapshot) + .filter((p) => purchaseState(p) === 'to_handle') + .sort(comparePurchasesNewestFirst); +} + +export interface DayGroup { + date: string; + purchases: CardPurchase[]; +} + +/** The purchases of a filter grouped by `purchase_date`, newest day first, each day newest first. */ +export function dayGroups(purchases: readonly CardPurchase[]): DayGroup[] { + const groups: DayGroup[] = []; + for (const purchase of [...purchases].sort(comparePurchasesNewestFirst)) { + const last = groups.at(-1); + if (last?.date === purchase.purchase_date) last.purchases.push(purchase); + else groups.push({ date: purchase.purchase_date, purchases: [purchase] }); + } + return groups; +} + +/** Expense ids linked from a purchase of the viewer other than `purchaseId` (CP-10 "not linked from another"). */ +function linkedElsewhere(snapshot: CardSnapshot, purchaseId: string): Set { + const ids = new Set(); + for (const p of livePurchases(snapshot)) if (p.id !== purchaseId && p.expense_id !== null) ids.add(p.expense_id); + return ids; +} + +/** + * CP-10's suggested matches of a purchase to handle: same amount, ±3 days, not linked from another purchase of the + * viewer, at most 3 (`matchCardPurchase`). Empty for a credit, a foreign amount, a linked or an ignored purchase. + */ +export function matchesFor(purchase: CardPurchase, ledger: Ledger, snapshot: CardSnapshot): Expense[] { + if (purchaseState(purchase) !== 'to_handle') return []; + const linked = linkedElsewhere(snapshot, purchase.id); + const ids = matchCardPurchase( + { amountOre: purchase.amount_ore, date: purchase.purchase_date, direction: purchase.direction }, + live(ledger.expenses).map((e) => ({ + id: e.id, + amountOre: e.amount_ore, + date: e.expense_date, + createdAt: e.created_at, + linkedByViewer: linked.has(e.id), + })), + MATCH_WINDOW_DAYS, + MATCH_MAX, + ); + return ids.flatMap((id) => { + const expense = ledger.expenses.get(id); + return expense === undefined ? [] : [expense]; + }); +} + +/** + * CP-10 "Välj en annan utgift": non-deleted expenses not linked from another purchase of the viewer, dated within + * 14 days of the purchase, any amount, newest first (date, then `created_at`, then id). + */ +export function otherExpensesFor(purchase: CardPurchase, ledger: Ledger, snapshot: CardSnapshot): Expense[] { + const linked = linkedElsewhere(snapshot, purchase.id); + const day = toEpochDays(parseDate(purchase.purchase_date)); + return live(ledger.expenses) + .filter((e) => !linked.has(e.id) && Math.abs(toEpochDays(parseDate(e.expense_date)) - day) <= OTHER_WINDOW_DAYS) + .sort( + (a, b) => + compareText(b.expense_date, a.expense_date) || + compareText(b.created_at, a.created_at) || + compareText(b.id, a.id), + ); +} + +/** The connection has ended (CP-6): status `expired`, `valid_until` passed, or the bank said the consent ended. */ +export function isExpired(connection: CardConnection, nowMs: number): boolean { + if (connection.status === 'expired' || connection.last_fetch_error === 'consent_expired') return true; + return connection.valid_until !== null && Date.parse(connection.valid_until) <= nowMs; +} + +/** From 14 days before `valid_until` until it passes (AC-22.11). */ +export function isExpiring(connection: CardConnection, nowMs: number): boolean { + if (connection.valid_until === null || isExpired(connection, nowMs)) return false; + return Date.parse(connection.valid_until) - nowMs <= EXPIRY_NOTICE_DAYS * DAY_MS; +} + +/** S-23 "first fetch": connected, and no fetch has finished yet (neither a success nor a recorded error). */ +export function isFirstFetch(connection: CardConnection): boolean { + return connection.status === 'active' && connection.last_fetch_at === null && connection.last_fetch_error === null; +} + +/** CP-14: a failed fetch is shown while `last_fetch_error` is set, except `consent_expired` (the expired state). */ +export function fetchFailed(connection: CardConnection): boolean { + return connection.last_fetch_error !== null && connection.last_fetch_error !== 'consent_expired'; +} + +/** A fetch runs at the server for a connection (E-42 answered `fetching`, or the first fetch after connecting). */ +export function anyFetching(snapshot: CardSnapshot | undefined): boolean { + if (snapshot === undefined) return false; + return [...snapshot.connections.values()].some((c) => c.status !== 'disconnected' && (c.fetching || isFirstFetch(c))); +} diff --git a/web/src/features/cards/purchaseActions.ts b/web/src/features/cards/purchaseActions.ts new file mode 100644 index 0000000..58434cb --- /dev/null +++ b/web/src/features/cards/purchaseActions.ts @@ -0,0 +1,74 @@ +/** + * The S-23 sheet's writes (CP-9), sent at once with E-44 (the web keeps no queue): link, ignore and their undo + * snackbars ("Kopplad" / "Ignorerad" with "Ångra"), unlink and stop ignoring. A failure shows its reason as a + * snackbar and changes nothing. + */ +import { useCallback, useMemo } from 'react'; +import { useSetCardPurchase, type CardPurchase } from '../../api/cards'; +import { ApiError } from '../../api/client'; +import { apiErrorText } from '../../api/errors'; +import { useToast } from '../../components/Toast'; +import type { I18n } from '../../i18n/I18nProvider'; +import { useI18n } from '../../i18n/useI18n'; + +/** The text of a failed E-44: `expense_already_linked` says so (CP-9), anything else as every page says it. */ +export function cardWriteErrorText(error: unknown, t: I18n['t']): string { + if (error instanceof ApiError && error.code === 'expense_already_linked') return t('web_cards_already_linked'); + return apiErrorText(error, t); +} + +export interface PurchaseActions { + link: (purchase: CardPurchase, expenseId: string) => Promise; + ignore: (purchase: CardPurchase) => Promise; + unlink: (purchase: CardPurchase) => Promise; + unignore: (purchase: CardPurchase) => Promise; +} + +const CLEAR = { expense_id: null, ignored: false } as const; + +export function usePurchaseActions(): PurchaseActions { + const { t } = useI18n(); + const toast = useToast(); + const set = useSetCardPurchase(); + + const run = useCallback( + async (purchaseId: string, body: { expense_id: string | null; ignored: boolean }): Promise => { + try { + await set(purchaseId, body); + return true; + } catch (error) { + toast({ message: cardWriteErrorText(error, t) }); + return false; + } + }, + [set, toast, t], + ); + + const withUndo = useCallback( + async (purchase: CardPurchase, body: { expense_id: string | null; ignored: boolean }, message: string) => { + const done = await run(purchase.id, body); + if (done) { + toast({ + message, + action: { + label: t('common_undo'), + run: () => void run(purchase.id, { expense_id: purchase.expense_id, ignored: purchase.ignored }), + }, + }); + } + return done; + }, + [run, toast, t], + ); + + return useMemo( + () => ({ + link: (purchase, expenseId) => + withUndo(purchase, { expense_id: expenseId, ignored: false }, t('cards_snackbar_linked')), + ignore: (purchase) => withUndo(purchase, { expense_id: null, ignored: true }, t('cards_snackbar_ignored')), + unlink: (purchase) => run(purchase.id, CLEAR), + unignore: (purchase) => run(purchase.id, CLEAR), + }), + [withUndo, run, t], + ); +} diff --git a/web/src/features/cards/useWideLayout.ts b/web/src/features/cards/useWideLayout.ts new file mode 100644 index 0000000..76d43b8 --- /dev/null +++ b/web/src/features/cards/useWideLayout.ts @@ -0,0 +1,22 @@ +import { useSyncExternalStore } from 'react'; + +/** SPEC §15.1: a sidebar layout from 720 px; W-21 shows the purchase as a side panel there and as a dialog below. */ +const QUERY = '(min-width: 720px)'; + +function mediaQuery(): MediaQueryList | null { + return typeof window.matchMedia === 'function' ? window.matchMedia(QUERY) : null; +} + +function subscribe(onChange: () => void): () => void { + const media = mediaQuery(); + if (media === null) return () => undefined; + media.addEventListener('change', onChange); + return () => { + media.removeEventListener('change', onChange); + }; +} + +/** True from 720 px wide; false where the browser has no `matchMedia` (jsdom), which gives the dialog. */ +export function useWideLayout(): boolean { + return useSyncExternalStore(subscribe, () => mediaQuery()?.matches ?? false); +} diff --git a/web/src/features/expenses/CardPurchaseEditor.test.tsx b/web/src/features/expenses/CardPurchaseEditor.test.tsx new file mode 100644 index 0000000..35f3724 --- /dev/null +++ b/web/src/features/expenses/CardPurchaseEditor.test.tsx @@ -0,0 +1,100 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import type { PushedExpense } from '../../api/types'; +import { cardConnection, cardPurchase, P, putBody, withCards, type CardData } from '../../test/cards'; +import { errorResponse, type FakeServer } from '../../test/fakeServer'; +import { renderApp } from '../../test/render'; +import { householdServer, HUSET, JACOB_ID, pushedMutations, VARDAG } from './testLedger'; + +function server(cards: CardData): FakeServer { + return withCards(householdServer(), { connections: [cardConnection()], ...cards }); +} + +async function openFrom(path: string, srv: FakeServer) { + const result = renderApp(path, { server: srv }); + await screen.findByLabelText('Belopp'); + return { ...result, user: userEvent.setup() }; +} + +function pushed(srv: FakeServer): PushedExpense { + const push = srv.calls('POST', '/sync').at(-1); + if (push === undefined) throw new Error('nothing pushed'); + return pushedMutations(push)[0]?.entity as PushedExpense; +} + +describe('W-5 from a card purchase (CP-9, ?cardPurchase=)', () => { + it('prefills amount, merchant, date, the viewer as payer, the default account and the suggestion; saving links', async () => { + const srv = server({ purchases: [cardPurchase(P(1), { purchase_date: '2026-10-12' })] }); + const { router } = await openFrom(`/expenses/new?cardPurchase=${P(1)}`, srv); + expect(screen.getByRole('heading', { level: 1, name: 'Ny utgift' })).toBeInTheDocument(); + expect(screen.getByLabelText('Belopp')).toHaveValue('412,00'); + expect(screen.getByLabelText('Beskrivning')).toHaveValue('ICA MAXI LINDHAGEN'); + expect(screen.getByLabelText('Datum')).toHaveValue('2026-10-12'); + expect(screen.getByLabelText('Konto')).toHaveValue(VARDAG.id); + expect( + screen.getByRole('button', { name: 'Betalt av du. Klicka för att byta vem som betalade.' }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Delning/ })).toHaveTextContent('Kontots standard 50/50'); + // §9.11 for the merchant text: the keyword `ica` gives Mat & hushåll. + expect(screen.getByRole('radio', { name: 'Mat & hushåll' })).toHaveAttribute('aria-checked', 'true'); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Spara' })); + expect(await screen.findByText('Utgiften sparades')).toBeInTheDocument(); + const entity = pushed(srv); + expect(entity).toMatchObject({ + amount_ore: 41_200, + description: 'ICA MAXI LINDHAGEN', + expense_date: '2026-10-12', + payer_user_id: JACOB_ID, + account_id: VARDAG.id, + split_mode: 'default', + notes: '', + }); + const puts = srv.calls('PUT', `/cards/purchases/${P(1)}`); + expect(puts.map(putBody)).toEqual([{ expense_id: entity.id, ignored: false }]); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app/cards'); + }); + }); + + it('takes the account of W-21 opened from an account (S-27)', async () => { + await openFrom( + `/expenses/new?cardPurchase=${P(1)}&account=${HUSET.id}`, + server({ purchases: [cardPurchase(P(1))] }), + ); + expect(screen.getByLabelText('Konto')).toHaveValue(HUSET.id); + expect(screen.getByRole('button', { name: /Delning/ })).toHaveTextContent('Kontots standard 60/40'); + }); + + it('leaves the amount empty for a foreign currency and cuts the merchant to 100 code points', async () => { + const merchant = `${'Å'.repeat(99)}😀 MER TEXT`; + await openFrom( + `/expenses/new?cardPurchase=${P(1)}`, + server({ + purchases: [ + cardPurchase(P(1), { amount_ore: null, original_amount: '42.90', original_currency: 'EUR', merchant }), + ], + }), + ); + expect(screen.getByLabelText('Belopp')).toHaveValue(''); + expect(screen.getByLabelText('Beskrivning')).toHaveValue(`${'Å'.repeat(99)}😀`); + expect(screen.getByRole('button', { name: 'Spara' })).toBeDisabled(); + }); + + it('says so when the purchase is not in the card snapshot', async () => { + renderApp(`/expenses/new?cardPurchase=${P(9)}`, { server: server({ purchases: [] }) }); + expect(await screen.findByText('Kortköpet finns inte längre')).toBeInTheDocument(); + }); + + it('keeps the saved expense and says the link is missing when E-44 fails', async () => { + const srv = server({ + purchases: [cardPurchase(P(1))], + respond: () => errorResponse('not_found', 404), + }); + await openFrom(`/expenses/new?cardPurchase=${P(1)}`, srv); + await userEvent.setup().click(screen.getByRole('button', { name: 'Spara' })); + expect(await screen.findByText('Utgiften sparades men kunde inte kopplas till kortköpet')).toBeInTheDocument(); + expect(srv.calls('POST', '/sync')).toHaveLength(1); + }); +}); diff --git a/web/src/features/expenses/ExpenseDetailPage.test.tsx b/web/src/features/expenses/ExpenseDetailPage.test.tsx index 433013e..015ddbe 100644 --- a/web/src/features/expenses/ExpenseDetailPage.test.tsx +++ b/web/src/features/expenses/ExpenseDetailPage.test.tsx @@ -2,6 +2,7 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import type { PushedExpense } from '../../api/types'; +import { cardConnection, cardPurchase, P, putBody, withCards } from '../../test/cards'; import { errorResponse, FakeServer } from '../../test/fakeServer'; import { renderApp } from '../../test/render'; import { @@ -172,4 +173,30 @@ describe('W-6 expense detail (S-8)', () => { expect(screen.getByText('Account default 50/50')).toBeInTheDocument(); expect(timelineItems('History')[0]).toHaveTextContent(/^Anna created this · 16 Oct /); }); + + it('shows the card holder "Från kortköp" with "Ta bort koppling" (AC-22.10)', async () => { + const srv = withCards(householdServer({ expenses: [EXPENSE], auditEntries: [CREATED] }), { + connections: [cardConnection()], + purchases: [cardPurchase(P(1), { merchant: 'ICA MAXI', purchase_date: '2026-10-15', expense_id: ID })], + }); + const { user } = openDetail(srv); + expect(await screen.findByText('Från kortköp: ICA MAXI, 15 okt.')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Ta bort koppling' })); + await waitFor(() => { + expect(screen.queryByText('Från kortköp: ICA MAXI, 15 okt.')).not.toBeInTheDocument(); + }); + expect(srv.calls('PUT', `/cards/purchases/${P(1)}`).map(putBody)).toEqual([{ expense_id: null, ignored: false }]); + }); + + it('shows no card line without a linked purchase of the viewer (the partner sees none, CP-11)', async () => { + const srv = withCards(householdServer({ expenses: [EXPENSE], auditEntries: [CREATED] }), { + purchases: [cardPurchase(P(1), { expense_id: 'another-expense' })], + }); + openDetail(srv); + expect(await screen.findByRole('heading', { level: 1, name: 'ICA Maxi Lindhagen' })).toBeInTheDocument(); + await waitFor(() => { + expect(srv.calls('GET', '/cards/sync').length).toBeGreaterThan(0); + }); + expect(screen.queryByText(/Från kortköp/)).not.toBeInTheDocument(); + }); }); diff --git a/web/src/features/expenses/ExpenseDetailPage.tsx b/web/src/features/expenses/ExpenseDetailPage.tsx index 6b462e2..4746389 100644 --- a/web/src/features/expenses/ExpenseDetailPage.tsx +++ b/web/src/features/expenses/ExpenseDetailPage.tsx @@ -1,5 +1,6 @@ import { useMemo, useState, type ReactNode } from 'react'; import { useNavigate, useParams } from 'react-router'; +import { purchaseLinkedTo, useCards, useSetCardPurchase } from '../../api/cards'; import { useLedger, useLedgerWriter, type Ledger } from '../../api/ledger'; import type { Expense } from '../../api/types'; import { paths } from '../../app/routes'; @@ -16,7 +17,7 @@ import { useToast } from '../../components/Toast'; import { Icon } from '../../design/Icon'; import type { OutlineIconName } from '../../design/icons.generated'; import { useI18n } from '../../i18n/useI18n'; -import { detailDate, listDateTime, parseDate } from '../../lib/dates'; +import { detailDate, listDate, listDateTime, parseDate } from '../../lib/dates'; import { BP_TOTAL, formatMoney } from '../../lib/money'; import { pushedExpense } from './model'; import { usePushExpense } from './usePushExpense'; @@ -96,7 +97,12 @@ function ExpenseDetail({ ledger, expense, people }: { ledger: Ledger; expense: E const push = usePushExpense(); const [confirmDelete, setConfirmDelete] = useState(false); const [busy, setBusy] = useState(false); + const [unlinking, setUnlinking] = useState(false); const today = useToday(); + // AC-22.10: only the card holder's snapshot holds the purchase, so only they see the line. + const cards = useCards(); + const cardPurchase = cards.data === undefined ? undefined : purchaseLinkedTo(cards.data, expense.id); + const setCardPurchase = useSetCardPurchase(); const ctx = { ledger, viewerId: people.viewer.id, t, language, today }; const entries = useMemo(() => auditEntriesOf(ledger, 'expense', expense.id), [ledger, expense.id]); const lines = timelineLines(entries, ctx); @@ -117,6 +123,17 @@ function ExpenseDetail({ ledger, expense, people }: { ledger: Ledger; expense: E const deletedAt = deletion?.client_at ?? expense.deleted_at; const viewerOre = shares[people.viewer.id] ?? 0; + const unlink = async (purchaseId: string) => { + setUnlinking(true); + try { + await setCardPurchase(purchaseId, { expense_id: null, ignored: false }); + } catch (error) { + toast({ message: apiErrorText(error, t) }); + } finally { + setUnlinking(false); + } + }; + const remove = async () => { setBusy(true); const now = new Date().toISOString(); @@ -239,6 +256,31 @@ function ExpenseDetail({ ledger, expense, people }: { ledger: Ledger; expense: E )}
+ {cardPurchase === undefined || deleted ? null : ( + + +
+ + + {t( + 'expense_detail_from_card', + cardPurchase.merchant, + listDate(parseDate(cardPurchase.purchase_date), today, language), + )} + + +
+
+
+ )} `) and /app/expenses/:id/edit. */ +/** + * S-7 Expense editor (W-5): /app/expenses/new (optionally `?account=`; `?cardPurchase=` prefills it from a + * card purchase and links the purchase on save, CP-9) and /app/expenses/:id/edit. + */ export default function ExpenseEditorPage() { const { t } = useI18n(); const { id } = useParams(); const [searchParams] = useSearchParams(); const session = useSession(); const ledger = useLedger(); + const cardPurchaseId = id === undefined ? searchParams.get(QUERY.cardPurchase) : null; + const cards = useCards(undefined, { enabled: cardPurchaseId !== null }); const title = id === undefined ? t('expense_editor_new') : t('expense_editor_edit'); const back = id === undefined ? paths.home : paths.expense(id); - if (ledger.isPending) { + if (ledger.isPending || (cardPurchaseId !== null && cards.isPending)) { return ( <> @@ -146,6 +153,27 @@ export default function ExpenseEditorPage() { ); } + if (cardPurchaseId !== null && cards.isError) { + return ( + <> + + void cards.refetch()} + /> + + ); + } + const cardPurchase = cardPurchaseId === null ? undefined : cards.data?.purchases.get(cardPurchaseId); + if (cardPurchaseId !== null && (cardPurchase === undefined || cardPurchase.deleted_at !== null)) { + return ( + <> + + + + ); + } const viewerId = session?.userId ?? ''; const people = peopleOf(ledger.data, viewerId); if (people === null) { @@ -184,10 +212,11 @@ export default function ExpenseEditorPage() { } return ( @@ -198,6 +227,7 @@ function initialForm( ledger: Ledger, people: EditorPeople, existing: Expense | undefined, + cardPurchase: CardPurchase | undefined, requestedAccountId: string | null, language: 'sv' | 'en', ): Form { @@ -219,6 +249,25 @@ function initialForm( }; } const accountId = defaultAccountId(activeAccountsOf(ledger), requestedAccountId) ?? ''; + if (cardPurchase !== undefined) { + // CP-9: amount (empty for a foreign currency), the merchant text as description, the purchase date, the card + // holder (the viewer) as payer, the account of `?account=` or AC-8.6's default, the §9.11 suggestion for that + // description and account (a new expense, CS-1), split `default`, no notes. + const description = cardPrefillDescription(cardPurchase.merchant); + const amountOre = cardPurchase.amount_ore; + return { + amountText: amountOre === null ? '' : amountInputDisplay(amountInputOf(amountOre), language), + amountOre, + description, + categoryId: suggestFor(ledger, suggestionHistory(ledger), description, accountId), + pickedByHand: false, + accountId, + date: cardPurchase.purchase_date, + payerId: people.viewer.id, + split: newSplitDraft(ids, accountBpOf(ledger.accounts.get(accountId)), language), + notes: '', + }; + } return { amountText: '', amountOre: null, @@ -237,21 +286,24 @@ function ExpenseEditor({ ledger, people, existing, + cardPurchase, requestedAccountId, title, }: { ledger: Ledger; people: EditorPeople; existing: Expense | undefined; + cardPurchase: CardPurchase | undefined; requestedAccountId: string | null; title: string; }) { const { t, tp, language } = useI18n(); const toast = useToast(); const push = usePushExpense(); + const setCardPurchase = useSetCardPurchase(); const isNew = existing === undefined; - const leave = useLeave(isNew ? paths.home : paths.expense(existing.id)); - const [initial] = useState(() => initialForm(ledger, people, existing, requestedAccountId, language)); + const leave = useLeave(isNew ? (cardPurchase === undefined ? paths.home : paths.cards) : paths.expense(existing.id)); + const [initial] = useState(() => initialForm(ledger, people, existing, cardPurchase, requestedAccountId, language)); const [form, setForm] = useState
(initial); const [errors, setErrors] = useState({}); const [problem, setProblem] = useState(null); @@ -401,14 +453,28 @@ function ExpenseEditor({ setSaving(true); setProblem(null); const outcome = await push(entity); + // CP-9: "Spara" also links the purchase, sent at once after the accepted push (the expense exists on the server). + const linked = + outcome.kind === 'accepted' && cardPurchase !== undefined ? await link(cardPurchase, entity.id) : true; finish(outcome, () => { rememberEditorChoice(entity.account_id, entity.category_id); leaving.current = true; - toast({ message: t('snackbar_expense_saved') }); + toast({ message: linked ? t('snackbar_expense_saved') : t('web_expenses_card_link_failed') }); leave(); }); }; + /** E-44 for the new expense; a failure leaves the saved expense as it is and says the link is missing. */ + const link = async (purchase: CardPurchase, expenseId: string): Promise => { + try { + await setCardPurchase(purchase.id, { expense_id: expenseId, ignored: false }); + return true; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') throw error; + return false; + } + }; + const remove = async () => { if (existing === undefined || saving) return; const now = timestamp(); diff --git a/web/src/features/expenses/Expenses.module.css b/web/src/features/expenses/Expenses.module.css index caf71d5..3f8014a 100644 --- a/web/src/features/expenses/Expenses.module.css +++ b/web/src/features/expenses/Expenses.module.css @@ -135,3 +135,19 @@ overflow-wrap: anywhere; white-space: pre-wrap; } + +/* W-6's card line (AC-22.10): "Från kortköp: , " with "Ta bort koppling". */ +.cardLine { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s2) var(--s4); + min-height: var(--row-min); + padding: var(--s2) var(--s4); + color: var(--on-surface); +} +.cardLineText { + flex: 1; + min-width: 12rem; + font: var(--type-body-large); +} diff --git a/web/src/features/expenses/cardPrefill.ts b/web/src/features/expenses/cardPrefill.ts new file mode 100644 index 0000000..4bc5590 --- /dev/null +++ b/web/src/features/expenses/cardPrefill.ts @@ -0,0 +1,6 @@ +/** CP-9's description of an expense made from a card purchase: the merchant text, cut to 100 code points (§9.6). */ +import { DESCRIPTION_MAX } from './model'; + +export function cardPrefillDescription(merchant: string): string { + return Array.from(merchant.trim()).slice(0, DESCRIPTION_MAX).join('').trim(); +} diff --git a/web/src/features/expenses/i18n/en.json b/web/src/features/expenses/i18n/en.json index 600fa2a..4505ab3 100644 --- a/web/src/features/expenses/i18n/en.json +++ b/web/src/features/expenses/i18n/en.json @@ -1,4 +1,7 @@ { "web_expenses_missing_title": "Expense not found", - "web_expenses_missing_body": "It may have been added a moment ago. Update to fetch it." + "web_expenses_missing_body": "It may have been added a moment ago. Update to fetch it.", + "web_expenses_card_load_failed": "Could not get the card purchase", + "web_expenses_card_missing_title": "The card purchase is no longer there", + "web_expenses_card_link_failed": "The expense was saved but could not be linked to the card purchase" } diff --git a/web/src/features/expenses/i18n/sv.json b/web/src/features/expenses/i18n/sv.json index 9a532f0..878f23b 100644 --- a/web/src/features/expenses/i18n/sv.json +++ b/web/src/features/expenses/i18n/sv.json @@ -1,4 +1,7 @@ { "web_expenses_missing_title": "Utgiften finns inte", - "web_expenses_missing_body": "Den kan ha lagts till nyss. Uppdatera för att hämta den." + "web_expenses_missing_body": "Den kan ha lagts till nyss. Uppdatera för att hämta den.", + "web_expenses_card_load_failed": "Kunde inte hämta kortköpet", + "web_expenses_card_missing_title": "Kortköpet finns inte längre", + "web_expenses_card_link_failed": "Utgiften sparades men kunde inte kopplas till kortköpet" } diff --git a/web/src/features/settings/SettingsPage.test.tsx b/web/src/features/settings/SettingsPage.test.tsx index 6a1f186..1ef690a 100644 --- a/web/src/features/settings/SettingsPage.test.tsx +++ b/web/src/features/settings/SettingsPage.test.tsx @@ -1,6 +1,7 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; +import { cardConnection, withCards } from '../../test/cards'; import { errorResponse } from '../../test/fakeServer'; import { ANNA, GROCERIES, JACOB } from '../../test/fixtures'; import { renderApp, TEST_SESSION } from '../../test/render'; @@ -72,4 +73,28 @@ describe('W-13 Settings', () => { expect(server.calls('GET', '/me')).toHaveLength(2); }); }); + + it('has the row "Anslut kort" after Återkommande utgifter (S-13, D-172)', async () => { + renderApp('/settings', { server: withCards(settingsServer()) }); + const row = await screen.findByRole('link', { name: /Anslut kort.*Läs in dina kortköp/ }); + expect(row).toHaveAttribute('href', '/app/settings/cards'); + const links = screen.getAllByRole('link').map((l) => l.textContent); + expect(links.findIndex((l) => l.startsWith('Anslut kort'))).toBe( + links.findIndex((l) => l.startsWith('Återkommande utgifter')) + 1, + ); + }); + + it('says until when a connected card is valid', async () => { + renderApp('/settings', { server: withCards(settingsServer(), { connections: [cardConnection()] }) }); + expect( + await screen.findByRole('link', { name: /Anslut kort.*Ansluten · giltig till torsdag 22 april 2027/ }), + ).toBeInTheDocument(); + }); + + it('says "Har upphört" once the connection has ended', async () => { + renderApp('/settings', { + server: withCards(settingsServer(), { connections: [cardConnection({ status: 'expired' })] }), + }); + expect(await screen.findByRole('link', { name: /Anslut kort.*Har upphört/ })).toBeInTheDocument(); + }); }); diff --git a/web/src/features/settings/SettingsPage.tsx b/web/src/features/settings/SettingsPage.tsx index e62bb49..90b015b 100644 --- a/web/src/features/settings/SettingsPage.tsx +++ b/web/src/features/settings/SettingsPage.tsx @@ -1,3 +1,4 @@ +import { currentConnection, useCards } from '../../api/cards'; import { live, useLedger, type Ledger } from '../../api/ledger'; import { useUserPicture } from '../../api/pictures'; import type { User } from '../../api/types'; @@ -10,6 +11,7 @@ import { Avatar, avatarSlot } from '../../components/Avatar'; import { Card } from '../../components/Card'; import { SegmentedGroup, SegmentedItem } from '../../components/LedgerRow'; import { useI18n } from '../../i18n/useI18n'; +import { detailDate, localDateTime } from '../../lib/dates'; import { PageBody, SettingsRow, useHousehold } from './common'; import hub from './SettingsPage.module.css'; import { ErrorState, LoadingState } from '../../components/PageStates'; @@ -47,9 +49,10 @@ function Person({ user, slot, role }: { user: User; slot: 1 | 2; role: string }) * §8.4), the web's language and theme, and Log out, which forgets the token only (§15.2). */ export default function SettingsPage() { - const { t, tp } = useI18n(); + const { t, tp, language } = useI18n(); const { household, error, refetch, isPending } = useHousehold(); const ledger = useLedger(counts); + const cards = useCards(currentConnection); const top = ; if (household === undefined) { @@ -87,6 +90,16 @@ export default function SettingsPage() { ] .filter((part) => part !== null) .join(t('common_separator_comma')); + // S-13 (D-172): "Ansluten · giltig till " while a card is connected, "Har upphört" once it has ended + // (K2's `settings_cards_expired`, CP-6), else "Läs in dina kortköp". + const connection = cards.data; + const cardsSummary = cards.isPending + ? undefined + : connection === undefined + ? t('settings_cards_none') + : connection.status === 'expired' || connection.valid_until === null + ? t('settings_cards_expired') + : t('settings_cards_connected', detailDate(localDateTime(new Date(connection.valid_until)), language)); return ( <> @@ -138,6 +151,15 @@ export default function SettingsPage() { subtitle={recurringSummary} /> + + + diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index bd53e74..bdf6ab0 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -18,6 +18,7 @@ "account_editor_split_hint": "Applies to new expenses in the account. Each expense can change its own split.", "account_empty_body": "Add the first expense in {1}.", "account_empty_title": "No expenses yet", + "account_from_card_purchases": "From card purchases", "account_settle_this": "Settle this account", "account_unarchive": "Restore from archive", "account_unarchived_snackbar": "{1} was restored", @@ -93,6 +94,75 @@ "balance_partner_owes_you_amount": "{1} owes you {2}", "balance_you_owe_partner": "You owe {1}", "balance_you_owe_partner_amount": "You owe {1} {2}", + "cards_booked": "Booked", + "cards_connect": "Connect card", + "cards_connect_bankid": "Connect with BankID", + "cards_connect_failed": "The card could not be connected", + "cards_connect_intro": "Choose your card. Kvitt reads your purchases but can never pay or move money.", + "cards_connect_offline_body": "The list of cards comes from the server. Connect the phone to the internet.", + "cards_connect_timeout": "The connection attempt took too long", + "cards_connect_title": "Connect card", + "cards_connected": "Card connected", + "cards_connection_settings": "Card connection", + "cards_credit": "Deposit", + "cards_disconnect": "Disconnect", + "cards_disconnect_body": "Purchases you have not linked or ignored are deleted. Expenses you created stay.", + "cards_disconnect_erase_all": "Also delete linked and ignored purchases", + "cards_disconnect_title": "Disconnect {1}?", + "cards_empty_body": "See your purchases here and turn them into expenses in a few taps. Only you see them.", + "cards_empty_title": "Connect your card", + "cards_expired": "Your card connection has ended", + "cards_expiring": "Your card connection ends on {1}", + "cards_fetch_failed": "Could not fetch purchases. Last updated {1}", + "cards_filter_all": "All", + "cards_filter_empty": "No purchases here", + "cards_filter_ignored": "Ignored", + "cards_filter_linked": "Linked", + "cards_filter_to_handle": "To handle", + "cards_first_fetch": "Fetching your purchases…", + "cards_foreign": "Foreign currency", + "cards_foreign_amount": "{1} {2}", + "cards_history_days:one": "{1} day", + "cards_history_days:other": "{1} days", + "cards_history_label": "Fetch purchases from the last", + "cards_history_longer_note": "A longer period needs BankID again", + "cards_history_note": "You can shorten this later. A longer period needs BankID again.", + "cards_history_setting": "History when connecting", + "cards_last_updated": "Last updated {1}", + "cards_linked_to": "Linked to {1} · {2}", + "cards_match_many:one": "{1} possible expense", + "cards_match_many:other": "{1} possible expenses", + "cards_match_one": "Link to {1}", + "cards_nothing_to_handle": "Nothing to handle", + "cards_nothing_to_handle_body": "New card purchases show up here.", + "cards_pending": "Pending", + "cards_pick_empty": "No card purchases to handle", + "cards_pick_title": "From card purchases", + "cards_private_note": "Only you see your card purchases", + "cards_provider_connected": "Connected · valid until {1}", + "cards_provider_unavailable": "Not set up on the server", + "cards_refresh": "Refresh", + "cards_refreshing": "Refreshing…", + "cards_renew": "Renew with BankID", + "cards_sheet_create": "Create expense", + "cards_sheet_credit_note": "Deposits and refunds are not expenses. Change the original expense by hand if needed.", + "cards_sheet_foreign_note": "The amount is in {1}: type the amount in kronor.", + "cards_sheet_ignore": "Ignore", + "cards_sheet_matches": "Link to an existing expense", + "cards_sheet_no_matches": "No expense with the same amount around this date", + "cards_sheet_open_expense": "Open expense", + "cards_sheet_other": "Choose another expense", + "cards_sheet_unignore": "Stop ignoring", + "cards_sheet_unlink": "Remove link", + "cards_snackbar_ignored": "Ignored", + "cards_snackbar_linked": "Linked", + "cards_status_active": "Connected", + "cards_status_expired": "Ended", + "cards_title": "Card purchases", + "cards_updated": "Updated {1}", + "cards_valid_until": "Valid until {1}", + "cards_waiting": "Waiting for BankID…", + "cards_waiting_body": "Finish in the browser and in BankID, then come back here.", "category_default_children": "Children", "category_default_entertainment": "Entertainment", "category_default_fees": "Fees & taxes", @@ -219,6 +289,7 @@ "expense_detail_delete_title": "Delete the expense?", "expense_detail_deleted_banner": "{1} deleted this expense · {2}", "expense_detail_deleted_body": "It no longer counts in balances or analytics.", + "expense_detail_from_card": "From card purchase: {1}, {2}", "expense_detail_missing": "This expense is not on the phone yet", "expense_detail_missing_body": "It may have just been added on another phone. Sync to fetch it.", "expense_detail_notes": "Notes", @@ -226,6 +297,7 @@ "expense_detail_recurring": "Recurring: {1}", "expense_detail_split": "Split", "expense_detail_sync_now": "Sync now", + "expense_detail_unlink_card": "Remove link", "expense_editor_date": "Date", "expense_editor_delete": "Delete expense", "expense_editor_discard_title": "Discard the expense?", @@ -310,6 +382,7 @@ "ledger_settlement_partner_paid_you": "{1} paid you {2}", "ledger_settlement_you_paid_partner": "You paid {1} {2}", "nav_analytics": "Analytics", + "nav_cards": "Card purchases", "nav_home": "Home", "nav_label": "Navigation", "nav_settings": "Settings", @@ -382,6 +455,10 @@ "settings_about_title": "About Kvitt", "settings_about_up_to_date": "You have the latest version", "settings_about_version": "Kvitt {1} ({2})", + "settings_cards": "Connect card", + "settings_cards_connected": "Connected · valid until {1}", + "settings_cards_expired": "Ended", + "settings_cards_none": "Read your card purchases", "settings_categories": "Categories", "settings_categories_archived": "Archived ({1})", "settings_categories_drag_handle": "Move {1}", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index e7011d7..1d42882 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -18,6 +18,7 @@ "account_editor_split_hint": "Gäller nya utgifter i kontot. Varje utgift kan ändra sin egen delning.", "account_empty_body": "Lägg till den första utgiften i {1}.", "account_empty_title": "Inga utgifter än", + "account_from_card_purchases": "Från kortköp", "account_settle_this": "Gör upp det här kontot", "account_unarchive": "Återställ från arkiv", "account_unarchived_snackbar": "{1} återställdes", @@ -93,6 +94,75 @@ "balance_partner_owes_you_amount": "{1} är skyldig dig {2}", "balance_you_owe_partner": "Du är skyldig {1}", "balance_you_owe_partner_amount": "Du är skyldig {1} {2}", + "cards_booked": "Bokförd", + "cards_connect": "Anslut kort", + "cards_connect_bankid": "Anslut med BankID", + "cards_connect_failed": "Det gick inte att ansluta kortet", + "cards_connect_intro": "Välj ditt kort. Kvitt läser dina köp men kan aldrig betala eller flytta pengar.", + "cards_connect_offline_body": "Listan över kort hämtas från servern. Anslut telefonen till internet.", + "cards_connect_timeout": "Anslutningsförsöket tog för lång tid", + "cards_connect_title": "Anslut kort", + "cards_connected": "Kortet är anslutet", + "cards_connection_settings": "Kortanslutning", + "cards_credit": "Insättning", + "cards_disconnect": "Koppla från", + "cards_disconnect_body": "Köp som du inte har kopplat eller ignorerat raderas. Utgifter du har skapat finns kvar.", + "cards_disconnect_erase_all": "Radera även kopplade och ignorerade köp", + "cards_disconnect_title": "Koppla från {1}?", + "cards_empty_body": "Se dina köp här och gör om dem till utgifter med några tryck. Bara du ser dem.", + "cards_empty_title": "Anslut ditt kort", + "cards_expired": "Kortanslutningen har upphört", + "cards_expiring": "Kortanslutningen upphör {1}", + "cards_fetch_failed": "Kunde inte hämta köp. Senast uppdaterad {1}", + "cards_filter_all": "Alla", + "cards_filter_empty": "Inga köp här", + "cards_filter_ignored": "Ignorerade", + "cards_filter_linked": "Kopplade", + "cards_filter_to_handle": "Att hantera", + "cards_first_fetch": "Hämtar dina köp…", + "cards_foreign": "Utländsk valuta", + "cards_foreign_amount": "{1} {2}", + "cards_history_days:one": "{1} dag", + "cards_history_days:other": "{1} dagar", + "cards_history_label": "Hämta köp från de senaste", + "cards_history_longer_note": "En längre period kräver BankID igen", + "cards_history_note": "Du kan korta perioden senare. En längre period kräver BankID igen.", + "cards_history_setting": "Historik vid anslutning", + "cards_last_updated": "Senast uppdaterad {1}", + "cards_linked_to": "Kopplad till {1} · {2}", + "cards_match_many:one": "{1} möjlig utgift", + "cards_match_many:other": "{1} möjliga utgifter", + "cards_match_one": "Koppla till {1}", + "cards_nothing_to_handle": "Inget att hantera", + "cards_nothing_to_handle_body": "Nya kortköp visas här.", + "cards_pending": "Preliminär", + "cards_pick_empty": "Inga kortköp att hantera", + "cards_pick_title": "Från kortköp", + "cards_private_note": "Bara du ser dina kortköp", + "cards_provider_connected": "Ansluten · giltig till {1}", + "cards_provider_unavailable": "Inte konfigurerat på servern", + "cards_refresh": "Uppdatera", + "cards_refreshing": "Uppdaterar…", + "cards_renew": "Förnya med BankID", + "cards_sheet_create": "Skapa utgift", + "cards_sheet_credit_note": "Insättningar och återbetalningar är inga utgifter. Ändra den ursprungliga utgiften för hand vid behov.", + "cards_sheet_foreign_note": "Beloppet är i {1}: skriv beloppet i kronor.", + "cards_sheet_ignore": "Ignorera", + "cards_sheet_matches": "Koppla till befintlig utgift", + "cards_sheet_no_matches": "Ingen utgift med samma belopp runt det här datumet", + "cards_sheet_open_expense": "Öppna utgift", + "cards_sheet_other": "Välj en annan utgift", + "cards_sheet_unignore": "Sluta ignorera", + "cards_sheet_unlink": "Ta bort koppling", + "cards_snackbar_ignored": "Ignorerad", + "cards_snackbar_linked": "Kopplad", + "cards_status_active": "Ansluten", + "cards_status_expired": "Har upphört", + "cards_title": "Kortköp", + "cards_updated": "Uppdaterad {1}", + "cards_valid_until": "Giltig till {1}", + "cards_waiting": "Väntar på BankID…", + "cards_waiting_body": "Slutför i webbläsaren och i BankID och kom sedan tillbaka hit.", "category_default_children": "Barn", "category_default_entertainment": "Nöje", "category_default_fees": "Avgifter & skatter", @@ -219,6 +289,7 @@ "expense_detail_delete_title": "Ta bort utgiften?", "expense_detail_deleted_banner": "{1} tog bort den här utgiften · {2}", "expense_detail_deleted_body": "Den räknas inte längre i saldon eller statistik.", + "expense_detail_from_card": "Från kortköp: {1}, {2}", "expense_detail_missing": "Utgiften finns inte på telefonen än", "expense_detail_missing_body": "Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.", "expense_detail_notes": "Anteckning", @@ -226,6 +297,7 @@ "expense_detail_recurring": "Återkommande: {1}", "expense_detail_split": "Delning", "expense_detail_sync_now": "Synka nu", + "expense_detail_unlink_card": "Ta bort koppling", "expense_editor_date": "Datum", "expense_editor_delete": "Ta bort utgift", "expense_editor_discard_title": "Slänga utgiften?", @@ -310,6 +382,7 @@ "ledger_settlement_partner_paid_you": "{1} betalade dig {2}", "ledger_settlement_you_paid_partner": "Du betalade {1} {2}", "nav_analytics": "Statistik", + "nav_cards": "Kortköp", "nav_home": "Översikt", "nav_label": "Navigering", "nav_settings": "Inställningar", @@ -382,6 +455,10 @@ "settings_about_title": "Om Kvitt", "settings_about_up_to_date": "Du har den senaste versionen", "settings_about_version": "Kvitt {1} ({2})", + "settings_cards": "Anslut kort", + "settings_cards_connected": "Ansluten · giltig till {1}", + "settings_cards_expired": "Har upphört", + "settings_cards_none": "Läs in dina kortköp", "settings_categories": "Kategorier", "settings_categories_archived": "Arkiverade ({1})", "settings_categories_drag_handle": "Flytta {1}", diff --git a/web/src/lib/domain/matchCardPurchase.test.ts b/web/src/lib/domain/matchCardPurchase.test.ts new file mode 100644 index 0000000..d84ea57 --- /dev/null +++ b/web/src/lib/domain/matchCardPurchase.test.ts @@ -0,0 +1,77 @@ +/** SPEC §14.5.8's cases for `matchCardPurchase`; contracts/test-vectors/match_card_purchase.json runs in vectors.test.ts. */ +import { describe, expect, it } from 'vitest'; +import { + MATCH_MAX, + MATCH_WINDOW_DAYS, + matchCardPurchase, + type MatchableExpense, + type MatchablePurchase, +} from './matchCardPurchase'; + +const purchase = (patch: Partial = {}): MatchablePurchase => ({ + amountOre: 1000, + date: '2026-10-16', + direction: 'debit', + ...patch, +}); +const e = (id: string, amountOre: number, date: string, patch: Partial = {}): MatchableExpense => ({ + id, + amountOre, + date, + createdAt: '2026-10-01T12:00:00.000Z', + linkedByViewer: false, + ...patch, +}); +const match = (p: MatchablePurchase, expenses: MatchableExpense[]) => + matchCardPurchase(p, expenses, MATCH_WINDOW_DAYS, MATCH_MAX); + +describe('matchCardPurchase (CP-10)', () => { + it('has nothing without expenses and matches the exact amount on the same day', () => { + expect(match(purchase(), [])).toEqual([]); + expect(match(purchase(), [e('a', 1000, '2026-10-16')])).toEqual(['a']); + }); + + it('includes ±3 days and excludes 4, and a different amount by 1 öre', () => { + const expenses = [ + e('a', 1000, '2026-10-19'), + e('b', 1000, '2026-10-13'), + e('c', 1000, '2026-10-20'), + e('d', 1000, '2026-10-12'), + e('x', 1001, '2026-10-16'), + e('y', 999, '2026-10-16'), + ]; + expect(match(purchase(), expenses)).toEqual(['a', 'b']); + }); + + it('excludes an expense the viewer linked from another purchase', () => { + expect( + match(purchase(), [e('a', 1000, '2026-10-16', { linkedByViewer: true }), e('b', 1000, '2026-10-17')]), + ).toEqual(['b']); + }); + + it('orders by day difference, then created_at descending, then id; at most max', () => { + const expenses = [ + e('c', 1000, '2026-10-18', { createdAt: '2026-10-02T00:00:00.000Z' }), + e('b', 1000, '2026-10-15', { createdAt: '2026-10-03T00:00:00.000Z' }), + e('a', 1000, '2026-10-15', { createdAt: '2026-10-03T00:00:00.000Z' }), + e('z', 1000, '2026-10-16', { createdAt: '2026-09-01T00:00:00.000Z' }), + ]; + expect(match(purchase(), expenses)).toEqual(['z', 'a', 'b']); + expect( + match( + purchase(), + ['a', 'b', 'c', 'd'].map((id) => e(id, 1000, '2026-10-16')), + ), + ).toEqual(['a', 'b', 'c']); + }); + + it('gives nothing for a credit or a null amount', () => { + expect(match(purchase({ direction: 'credit' }), [e('a', 1000, '2026-10-16')])).toEqual([]); + expect(match(purchase({ amountOre: null }), [e('a', 1000, '2026-10-16')])).toEqual([]); + }); + + it('counts days across a month and a year boundary', () => { + const expenses = [e('a', 1000, '2026-12-29'), e('b', 1000, '2026-12-28'), e('c', 1000, '2027-01-04')]; + expect(match(purchase({ date: '2027-01-01' }), expenses)).toEqual(['a', 'c']); + }); +}); diff --git a/web/src/lib/domain/matchCardPurchase.ts b/web/src/lib/domain/matchCardPurchase.ts new file mode 100644 index 0000000..e506d16 --- /dev/null +++ b/web/src/lib/domain/matchCardPurchase.ts @@ -0,0 +1,61 @@ +/** + * SPEC §14.5.8 / §9.12 CP-10: the household expenses a card purchase may be linked to, computed in the browser from + * the snapshot as the phone computes them from Room (`:domain`'s `matchCardPurchase`). Pure: dates are passed in. + * Vectors: contracts/test-vectors/match_card_purchase.json (run by src/lib/vectors.test.ts). + */ +import { parseDate, toEpochDays } from '../dates'; + +export interface MatchablePurchase { + /** Null for a purchase in a foreign currency (CP-7): it gets no matches. */ + amountOre: number | null; + /** `YYYY-MM-DD`. */ + date: string; + direction: 'debit' | 'credit'; +} + +export interface MatchableExpense { + id: string; + amountOre: number; + /** `YYYY-MM-DD`. */ + date: string; + /** RFC 3339 timestamp, compared as text (the wire form is fixed-width UTC). */ + createdAt: string; + /** Linked from another purchase of the viewer. */ + linkedByViewer: boolean; +} + +/** CP-10's parameters: ±3 days, at most 3 shown. */ +export const MATCH_WINDOW_DAYS = 3; +export const MATCH_MAX = 3; + +function compareText(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * The ids of the expenses with the purchase's amount, at most `windowDays` days from its date and not linked by the + * viewer, ordered by the absolute day difference, then `createdAt` descending, then id ascending; at most `max`. + * Empty for a credit purchase or a null amount. Callers pass non-deleted expenses only. + */ +export function matchCardPurchase( + purchase: MatchablePurchase, + expenses: readonly MatchableExpense[], + windowDays: number, + max: number, +): string[] { + const amount = purchase.amountOre; + if (purchase.direction !== 'debit' || amount === null) return []; + const day = toEpochDays(parseDate(purchase.date)); + return expenses + .filter((e) => !e.linkedByViewer && e.amountOre === amount) + .map((e) => ({ expense: e, distance: Math.abs(toEpochDays(parseDate(e.date)) - day) })) + .filter((c) => c.distance <= windowDays) + .sort( + (a, b) => + a.distance - b.distance || + compareText(b.expense.createdAt, a.expense.createdAt) || + compareText(a.expense.id, b.expense.id), + ) + .slice(0, Math.max(0, max)) + .map((c) => c.expense.id); +} diff --git a/web/src/lib/vectors.test.ts b/web/src/lib/vectors.test.ts index 64969a4..fd32af6 100644 --- a/web/src/lib/vectors.test.ts +++ b/web/src/lib/vectors.test.ts @@ -2,7 +2,8 @@ * Runs every case of every file in contracts/test-vectors/ (SPEC §14.5.5) against the web's port of kvitt-domain, * like server/crates/kvitt-domain/tests/vectors.rs. The directory is read at test time, so a new file is picked up; * a function this port does not implement fails unless it is listed as not ported. Ports that live in a feature (W2's - * category suggestion, W4's analytics) run here too, so every vector file has one runner. + * category suggestion, W4's analytics) run here too, so every vector file has one runner; K3's card matches + * (`lib/domain/matchCardPurchase`) as well. */ import { readdirSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; @@ -17,6 +18,7 @@ import { type RangePreset, } from '../features/analytics/domain/analytics'; import { suggestCategory, type KeywordMatch } from '../features/expenses/suggest/suggest'; +import { matchCardPurchase, type MatchableExpense, type MatchablePurchase } from './domain/matchCardPurchase'; import { computeBalances, type BalanceExpense, type BalanceSettlement } from './balances'; import { overSettledAccounts, @@ -30,8 +32,7 @@ import { computeSplit, type SplitSpec } from './split'; /** Tests run from web/ (npm scripts), so the repository's contracts are one level up. */ const DIR = resolve(process.cwd(), '..', 'contracts', 'test-vectors'); /** Functions only the app implements (SPEC §14.5.4, §14.5.6); a web feature plan that ports one moves it below. */ -// match_card_purchase (SPEC §14.5.8): until K3 ports it. -const NOT_PORTED: string[] = ['match_card_purchase']; +const NOT_PORTED: string[] = []; const PORTED = [ 'compute_split', 'compute_balances', @@ -41,6 +42,7 @@ const PORTED = [ 'suggest_category', // W2: features/expenses/suggest 'resolve_range', // W4: features/analytics/domain/analytics 'spend_series', // W4: features/analytics/domain/analytics + 'match_card_purchase', // K3: lib/domain/matchCardPurchase (SPEC §14.5.8) ]; interface VectorCase { @@ -198,6 +200,34 @@ function run(fn: string, input: Record): unknown { }, }; } + case 'match_card_purchase': { + const purchase = input.purchase as { amount_ore: number | null; date: string; direction: string }; + const expenses = input.expenses as { + id: string; + amount_ore: number; + date: string; + created_at: string; + linked_by_viewer: boolean; + }[]; + return { + ok: matchCardPurchase( + { + amountOre: purchase.amount_ore, + date: purchase.date, + direction: purchase.direction as MatchablePurchase['direction'], + }, + expenses.map((e): MatchableExpense => ({ + id: e.id, + amountOre: e.amount_ore, + date: e.date, + createdAt: e.created_at, + linkedByViewer: e.linked_by_viewer, + })), + input.window_days as number, + input.max as number, + ), + }; + } default: throw new Error(`no web implementation for ${fn}`); } diff --git a/web/src/test/cards.ts b/web/src/test/cards.ts new file mode 100644 index 0000000..44ed507 --- /dev/null +++ b/web/src/test/cards.ts @@ -0,0 +1,115 @@ +/** + * Card wire objects for tests (SPEC §14.3.3 CardProvider, CardConnection, CardPurchase) and the card endpoints of a + * `FakeServer`: E-36 the catalogue, E-40 the snapshot (everything on the first pull, nothing new after), E-44 for the + * given purchases (the body merged into the row with a new `card_version`, or the answer of `respond`). + */ +import type { CardConnection, CardProvider, CardPurchase, CardSyncResponse } from '../api/cards'; +import { json, type FakeServer, type RecordedRequest } from './fakeServer'; +import { TS } from './fixtures'; + +let version = 1000; +const nextVersion = () => (version += 1); + +export const SJ_PRIO: CardProvider = { + id: 'sj_prio_mastercard', + name: 'SJ Prio Mastercard', + issuer: 'SEB Kort', + image_key: 'sj_prio_mastercard', + available: true, +}; + +export const CONNECTION_ID = '01929c5e-0000-7000-8000-00000000c001'; + +export function cardConnection(patch: Partial = {}): CardConnection { + return { + id: CONNECTION_ID, + provider_id: SJ_PRIO.id, + status: 'active', + history_days: 30, + valid_until: '2027-04-22T10:00:00.000Z', + last_fetch_at: TS, + last_fetch_error: null, + fetching: false, + created_at: TS, + updated_at: TS, + card_version: nextVersion(), + ...patch, + }; +} + +/** A purchase id `01929c5e-0000-7000-8000-0000000d00`. */ +export const P = (n: number) => `01929c5e-0000-7000-8000-0000000d${String(n).padStart(4, '0')}`; + +export function cardPurchase(id: string, patch: Partial = {}): CardPurchase { + return { + id, + connection_id: CONNECTION_ID, + status: 'booked', + direction: 'debit', + purchase_date: '2026-10-16', + booking_date: '2026-10-16', + amount_ore: 41_200, + original_amount: null, + original_currency: null, + merchant: 'ICA MAXI LINDHAGEN', + expense_id: null, + ignored: false, + created_at: TS, + updated_at: TS, + deleted_at: null, + card_version: nextVersion(), + ...patch, + }; +} + +export function cardPage(since: number, rows: Partial, hasMore = false): CardSyncResponse { + const versions = [...(rows.connections ?? []), ...(rows.purchases ?? [])].map((r) => r.card_version); + return { + since, + next_since: versions.length === 0 ? since : Math.max(...versions), + has_more: hasMore, + server_time: TS, + connections: [], + purchases: [], + ...rows, + }; +} + +export interface CardData { + connections?: CardConnection[]; + purchases?: CardPurchase[]; + providers?: CardProvider[]; + /** Answers E-44 instead of the default (merge and bump `card_version`). */ + respond?: (request: RecordedRequest, purchase: CardPurchase) => Response | undefined; +} + +/** E-44 bodies as sent. */ +export function putBody(request: RecordedRequest): { expense_id: string | null; ignored: boolean } { + return request.body as { expense_id: string | null; ignored: boolean }; +} + +/** Adds the card endpoints to `server` (they are not part of `GET /sync`, CP-11). */ +export function withCards(server: FakeServer, data: CardData = {}): FakeServer { + const purchases = new Map((data.purchases ?? []).map((p) => [p.id, p])); + server + .on('GET', '/cards/providers', () => json({ providers: data.providers ?? [SJ_PRIO] })) + .on('GET', '/cards/sync', (request) => { + const since = Number(new URLSearchParams(request.search).get('since') ?? '0'); + return json( + since === 0 + ? cardPage(0, { connections: data.connections ?? [], purchases: [...purchases.values()] }) + : cardPage(since, {}), + ); + }); + for (const purchase of purchases.values()) { + server.on('PUT', `/cards/purchases/${purchase.id}`, (request) => { + const current = purchases.get(purchase.id) ?? purchase; + const answer = data.respond?.(request, current); + if (answer !== undefined) return answer; + const row = { ...current, ...putBody(request), card_version: nextVersion() }; + purchases.set(row.id, row); + return json(row); + }); + } + return server; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index ce92d1a..f25e90d 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -51,6 +51,7 @@ const API_PREFIXES = [ '/settlements', '/apk', '/analytics', + '/cards', ]; const devApi = process.env.KVITT_DEV_API ?? 'http://localhost:8080';