diff --git a/DECISIONS.md b/DECISIONS.md index 8649252d..3601660f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1180,3 +1180,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-23] Reconciliation doors (PR 2): dashboard routes, the v1 API and the MCP tools all call lib/reconciliation/{service,items,actions}.ts; no door re-implements a link. Policy lives in the door: page + REST apply directly, MCP stages (reconciliation_match / reconciliation_unmatch pending operations, executors in commit.ts). The MCP write tools are catalogVisibility search (and gnubok_link_transaction_to_journal_entry moved to search) because the tools/list payload ceiling (59 900 tokens) left no room for them in the default catalog; the reads (status with account_key, items) stay default and the items description points at the write. The skattekonto link now has its canonical implementation in core lib/skatteverket/skattekonto-link.ts (needed by core doors; core must not import the extension); the extension route still uses its own matchSkattekontoToEntry until its queued-mock tests are ported, then it delegates. New scopes reconciliation:read/write; gnubok_get_reconciliation_status keeps reports:read and the legacy bank routes keep transactions:* so no existing key is cut off. [2026-08-23] Avstämning page (PR 3) ships without the period picker, the manual two-pane match mode and the sign-off button: the page renders the approved 'Vald riktning' layout (rail + tiles + bridge + actions + banded table) over the PR 2 dashboard routes only, so that it is verifiable on its own; period + sign-off arrive together in PR 4 (both are period-bound), manual N:M matching with residual booking in PR 5. Bank accounts get the same generic body plus links to the existing bank view for the matcher run rather than embedding the 1900-line BankReconciliationView: one body for every account kind is the point of the page, and embedding would have doubled the header. [2026-08-23] Reconciliation sign-off (PR 4) is an append-only attestation table (account_reconciliations) with a reopen stamp, not a flag on the account: who signed what through which date, with the numbers as they stood, is the thing an auditor and the Hem row read, so it must survive a later change of mind. Sign-off is refused with an unexplained difference unless forced with a note (the note is what the next reader sees). Separate scope reconciliation:signoff (write is not enough): an integration that links rows should not be able to attest. The worklist category reconciliation_due is gated on adoption (zero until the company has signed anything off) so the nudge reaches the people who reconcile monthly without becoming a new chore for everyone. Webhook events added additively without bumping API_V1_VERSION: the dated version is reserved for breaking changes; a new event type breaks no existing subscriber. +[2026-08-23] Reconciliation agent surfaces (PR 5): the Hem notice for a skattekonto that disagrees with the ledger reads a summary the sync persists (extension_data skattekonto_reconciliation_latest) instead of recomputing the bridge on every render; the same persisted summary feeds nothing else yet. The attention resource's reconciliation_due category and the Hem row share lib/worklist countReconciliationDue (one predicate). Manual N:M matching with residual booking, dropping the local MatchDialog on /skattekonto, restyling the bank view and eval scenarios are deferred to PR 6: they need the two-pane UI and a visual pass, and none of the agent surfaces depend on them. The skattekonto sync cron now orders eligible companies by stalest sync before its 50-per-run cap (never-synced first) instead of raising the cap: a fixed order plus a cap starved the tail. diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts index 942d932f..1f166aa3 100644 --- a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts +++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { verifyCronSecret } from '@/lib/auth/cron' import { getCompanyIdsWithCapability } from '@/lib/entitlements/has-capability' +import { orderByStalestSync } from '@/lib/skatteverket/sync-order' import { CAPABILITY } from '@/lib/entitlements/keys' import { createExtensionContext } from '@/lib/extensions/context-factory' import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync' @@ -146,9 +147,29 @@ export async function GET(request: Request) { // Limit the eligible work list, not the raw token list. Expired trials and // disabled modules must not occupy all 50 positions ahead of paying firms. - const entitledWork = work - .filter(item => entitledCompanyIds.has(item.companyId)) - .slice(0, MAX_COMPANIES_PER_RUN) + // Within the eligible list, never-synced and longest-ago-synced companies + // go first: a fixed order plus a cap starves the tail forever. + const eligibleWork = work.filter(item => entitledCompanyIds.has(item.companyId)) + let lastSyncedAtByCompany = new Map() + try { + const rows = await fetchAllRows( + ({ from, to }) => supabase + .from('extension_data') + .select('company_id, value') + .eq('extension_id', 'skatteverket') + .eq('key', SKATTEKONTO_LAST_SYNCED_AT_KEY) + .order('company_id', { ascending: true }) + .range(from, to), + ) + lastSyncedAtByCompany = new Map( + (rows ?? []).map(r => [r.company_id as string, (r.value as string | null) ?? null]), + ) + } catch (error) { + console.warn('[skattekonto-sync-cron] last-synced read failed; keeping token order', { + message: error instanceof Error ? error.message : String(error), + }) + } + const entitledWork = orderByStalestSync(eligibleWork, lastSyncedAtByCompany).slice(0, MAX_COMPANIES_PER_RUN) console.info('[skattekonto-sync-cron] Work list built', { candidates: work.length, diff --git a/components/reconciliation/AccountOverview.tsx b/components/reconciliation/AccountOverview.tsx index 8acb346e..02850034 100644 --- a/components/reconciliation/AccountOverview.tsx +++ b/components/reconciliation/AccountOverview.tsx @@ -1,6 +1,6 @@ 'use client' -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import dynamic from 'next/dynamic' import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' @@ -65,13 +65,15 @@ export interface ReconciliationWindow { interface AccountOverviewProps { account: ReconciliationAccount + /** The account rail. Rendered inside the summary grid so the items table below can span the full page width (the approved layout). */ + rail: ReactNode /** The selected period: scopes the bank bridge and the item windows; its end is the default sign-off date. */ window: ReconciliationWindow /** Called after any write so the rail can refresh its status dots. */ onChanged: () => void } -export function AccountOverview({ account, window, onChanged }: AccountOverviewProps) { +export function AccountOverview({ account, rail, window, onChanged }: AccountOverviewProps) { const t = useTranslations('reconciliation') const locale = useLocale() const { toast } = useToast() @@ -271,15 +273,22 @@ export function AccountOverview({ account, window, onChanged }: AccountOverviewP if (loadError) { return ( - void load() }}> - {t('load_failed')} - +
+ {rail} +
+ void load() }}> + {t('load_failed')} + +
+
) } if (!status || !items) { return ( -
+
+ {rail} +
{[0, 1, 2, 3].map((i) => (
@@ -290,6 +299,7 @@ export function AccountOverview({ account, window, onChanged }: AccountOverviewP
+
) } @@ -304,7 +314,14 @@ export function AccountOverview({ account, window, onChanged }: AccountOverviewP { key: 'external', label: isSkv ? t('tile_external_skv') : t('tile_external_bank'), - value: money(status.external_balance), + // The bank tile is the period sum (what its label says), which lives on + // the bridge; external_balance is the reported bank balance and is often + // unknown, which rendered as "okänt" next to a bridge that knows better. + value: money( + isSkv + ? status.external_balance + : (status.bridge.find((l) => l.key === 'bank_transactions')?.amount ?? status.external_balance), + ), sub: fetchedAt ? t('tile_synced', { date: formatDate(fetchedAt) }) : t('rail_never_synced'), }, { @@ -366,6 +383,9 @@ export function AccountOverview({ account, window, onChanged }: AccountOverviewP return (
+
+ {rail} +
{/* Tiles: label + number, nothing else. */}
{tiles.map((tile) => ( @@ -472,6 +492,9 @@ export function AccountOverview({ account, window, onChanged }: AccountOverviewP

)} +
+
+ {/* The table: full width, banded by bucket, paired proposal rows. */} {items.items.length === 0 ? (

{t('all_clear')}

diff --git a/components/reconciliation/ReconciliationWorkspace.tsx b/components/reconciliation/ReconciliationWorkspace.tsx index de18a048..55a10f8e 100644 --- a/components/reconciliation/ReconciliationWorkspace.tsx +++ b/components/reconciliation/ReconciliationWorkspace.tsx @@ -180,19 +180,15 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re return (
{header} -
- -
- {selected && ( - void load()} - /> - )} -
-
+ {selected && ( + } + window={window} + onChanged={() => void load()} + /> + )}
) } diff --git a/extensions/general/mcp-server/__tests__/attention.test.ts b/extensions/general/mcp-server/__tests__/attention.test.ts index 75308f24..c2dbde97 100644 --- a/extensions/general/mcp-server/__tests__/attention.test.ts +++ b/extensions/general/mcp-server/__tests__/attention.test.ts @@ -330,3 +330,30 @@ describe('Accounted://attention', () => { expect(result.summary.total_items).toBe(3) }) }) + +describe('Accounted://attention: reconciliation_due', () => { + it('adds the category when signed-off accounts have fallen behind the previous month end', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueEmpty(enqueue) + // countReconciliationDue: sign-offs (adoption + coverage), cash accounts, skattekonto rows. + enqueue({ + data: [{ account_key: 'skattekonto', through_date: '2020-01-31', reopened_at: null }], + }) + enqueue({ data: [{ id: '11111111-1111-4111-8111-111111111111', iban: null, currency: 'SEK', updated_at: null }] }) + enqueue({ count: 3 }) + const out = (await attentionResource.read(ctx(supabase))) as AttentionResponse + const cat = out.categories.find((c) => c.key === 'reconciliation_due') + expect(cat).toBeDefined() + // The bank account and the skattekonto are both due. + expect(cat?.count).toBe(2) + expect(cat?.next?.resource).toBe('Accounted://reconciliation/summary') + }) + + it('stays silent for a company that never signed anything off', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueEmpty(enqueue) + enqueue({ data: [] }) + const out = (await attentionResource.read(ctx(supabase))) as AttentionResponse + expect(out.categories.find((c) => c.key === 'reconciliation_due')).toBeUndefined() + }) +}) diff --git a/extensions/general/mcp-server/__tests__/reconciliation-summary-resource.test.ts b/extensions/general/mcp-server/__tests__/reconciliation-summary-resource.test.ts new file mode 100644 index 00000000..ea3a76cb --- /dev/null +++ b/extensions/general/mcp-server/__tests__/reconciliation-summary-resource.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' + +const listMock = vi.fn() +vi.mock('@/lib/reconciliation/service', () => ({ + listReconciliationAccounts: (...args: unknown[]) => listMock(...args), + getAccountStatus: vi.fn(), +})) + +import { reconciliationSummaryResource } from '../resources/reconciliation-summary' + +const CASH = '11111111-1111-4111-8111-111111111111' + +function account(overrides: Record = {}) { + return { + account_key: `bank:${CASH}`, + kind: 'bank', + account_number: '1930', + name: 'Företagskonto', + currency: 'SEK', + logo_url: null, + source: { type: 'psd2', synced_at: '2026-08-22T06:00:00Z', stale: false }, + status: { + state: 'open', + as_of: '2026-08-23T00:00:00Z', + unexplained_difference: 250, + open_counts: { proposed: 2, unmatched_external: 1, unmatched_ledger: 0 }, + }, + superseded_by: null, + signed_off_through: '2026-06-30', + ...overrides, + } +} + +describe('Accounted://reconciliation/summary', () => { + beforeEach(() => { + vi.clearAllMocks() + listMock.mockReset() + }) + + it('lists accounts with state, counts and sign-off, totals them, and points at the account with proposals', async () => { + const { supabase } = createQueuedMockSupabase() + listMock.mockResolvedValue([ + account(), + account({ + account_key: 'skattekonto', + kind: 'skattekonto', + account_number: '1630', + name: 'Skattekonto', + status: { state: 'reconciled', as_of: '2026-08-23T04:00:00Z', unexplained_difference: 0, open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 } }, + signed_off_through: '2026-07-31', + }), + // A reconnect duplicate is listed but not counted. + account({ account_key: 'bank:22222222-2222-4222-8222-222222222222', superseded_by: `bank:${CASH}` }), + ]) + const out = (await reconciliationSummaryResource.read({ + supabase: supabase as never, + companyId: 'company-1', + userId: 'user-1', + scopes: [], + query: new URLSearchParams('date_from=2026-07-01&date_to=2026-07-31'), + })) as Record + expect(listMock).toHaveBeenCalledWith(supabase, 'company-1', { withStatus: true, windowFrom: '2026-07-01', windowTo: '2026-07-31' }) + expect(out.totals).toMatchObject({ accounts: 2, reconciled: 1, open: 1, proposed: 2, unmatched_external: 1 }) + const accounts = out.accounts as Array> + expect(accounts).toHaveLength(3) + expect(accounts[0]).toMatchObject({ account_key: `bank:${CASH}`, state: 'open', signed_off_through: '2026-06-30' }) + expect(out.next).toMatchObject({ tool: 'gnubok_reconcile_match', args: { account_key: `bank:${CASH}`, use_proposals: true, dry_run: true } }) + }) + + it('suggests signing off when everything is reconciled, and rejects a malformed window', async () => { + const { supabase } = createQueuedMockSupabase() + listMock.mockResolvedValue([ + account({ status: { state: 'reconciled', as_of: '2026-08-23T00:00:00Z', unexplained_difference: 0, open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 } } }), + ]) + const out = (await reconciliationSummaryResource.read({ + supabase: supabase as never, + companyId: 'company-1', + userId: 'user-1', + scopes: [], + })) as Record + expect(out.next).toMatchObject({ tool: 'gnubok_reconcile_signoff' }) + + await expect( + reconciliationSummaryResource.read({ + supabase: supabase as never, + companyId: 'company-1', + userId: 'user-1', + scopes: [], + query: new URLSearchParams('date_from=20260701'), + }), + ).rejects.toThrow(/YYYY-MM-DD/) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/resources.test.ts b/extensions/general/mcp-server/__tests__/resources.test.ts index cccf1af3..a33d724f 100644 --- a/extensions/general/mcp-server/__tests__/resources.test.ts +++ b/extensions/general/mcp-server/__tests__/resources.test.ts @@ -4,7 +4,7 @@ import { dataResources, findResource, parseResourceQuery } from '../resources' describe('mcp resource registry', () => { it('exposes all data resources with required fields', () => { - expect(dataResources).toHaveLength(9) + expect(dataResources).toHaveLength(10) const uris = dataResources.map((r) => r.uri).sort() expect(uris).toEqual([ 'Accounted://attention', @@ -15,6 +15,7 @@ describe('mcp resource registry', () => { 'Accounted://ledger/context', 'Accounted://period/active', 'Accounted://recent-activity', + 'Accounted://reconciliation/summary', 'Accounted://settings/vat-treatments', ]) diff --git a/extensions/general/mcp-server/recommended-tools.ts b/extensions/general/mcp-server/recommended-tools.ts index 48cc593b..80a26896 100644 --- a/extensions/general/mcp-server/recommended-tools.ts +++ b/extensions/general/mcp-server/recommended-tools.ts @@ -75,6 +75,23 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [ 'gnubok_approve_pending_operation', ], }, + { + workflow: 'reconcile_month', + description: 'Reconcile every account with an outside truth (bank accounts, skattekonto) for a month and sign it off.', + skill: 'reconcile-month', + tools: [ + 'gnubok_get_reconciliation_status', + 'gnubok_list_reconciliation_items', + 'gnubok_reconcile_match', + 'gnubok_reconcile_unmatch', + // Rows with no counterpart: book them (bank side) or link to the + // verifikat that already holds the affärshändelse. + 'gnubok_categorize_transaction', + 'gnubok_link_transaction_to_journal_entry', + 'gnubok_reconcile_signoff', + 'gnubok_approve_pending_operation', + ], + }, { workflow: 'invoice_run', description: 'Create and send customer invoices.', diff --git a/extensions/general/mcp-server/resources/attention.ts b/extensions/general/mcp-server/resources/attention.ts index 583549d5..db9fc980 100644 --- a/extensions/general/mcp-server/resources/attention.ts +++ b/extensions/general/mcp-server/resources/attention.ts @@ -1,5 +1,6 @@ import type { McpResource } from './types' import { ACTION_NEEDED_THRESHOLD_DAYS } from '@/lib/deadlines/status-engine' +import { countReconciliationDue } from '@/lib/worklist/categories' type Severity = 'critical' | 'warning' | 'info' @@ -341,6 +342,25 @@ export const attentionResource: McpResource = { }) } + // ── Accounts not signed off through the previous month end ────── + // Cheap by construction (lib/worklist countReconciliationDue: no bridge + // computation) and zero until the company has signed anything off. + const reconciliationDue = await countReconciliationDue(supabase, companyId, now) + if (reconciliationDue > 0) { + categories.push({ + key: 'reconciliation_due', + label_sv: 'Konton som inte är avstämda t.o.m. förra månadsskiftet', + severity: 'warning', + count: reconciliationDue, + samples: [], + next: { + description: + 'Läs Accounted://reconciliation/summary för bryggan per konto; koppla föreslagna par, bokför det som saknas och signera med gnubok_reconcile_signoff när oförklarat är 0.', + resource: 'Accounted://reconciliation/summary', + }, + }) + } + // ── Period lock approaching ───────────────────────────────────── const lockDate = companySettingsRow.data?.bookkeeping_locked_through ?? null if (lockDate && activePeriodRow.data) { diff --git a/extensions/general/mcp-server/resources/index.ts b/extensions/general/mcp-server/resources/index.ts index ba4cf208..ed624d50 100644 --- a/extensions/general/mcp-server/resources/index.ts +++ b/extensions/general/mcp-server/resources/index.ts @@ -8,6 +8,7 @@ import { vatTreatmentsResource } from './vat-treatments' import { attentionResource } from './attention' import { ledgerContextResource } from './ledger-context' import { bookingPacksResource } from './booking-packs' +import { reconciliationSummaryResource } from './reconciliation-summary' export const dataResources: McpResource[] = [ companyCurrentResource, @@ -19,6 +20,7 @@ export const dataResources: McpResource[] = [ attentionResource, ledgerContextResource, bookingPacksResource, + reconciliationSummaryResource, ] export function findResource(uri: string): McpResource | null { diff --git a/extensions/general/mcp-server/resources/reconciliation-summary.ts b/extensions/general/mcp-server/resources/reconciliation-summary.ts new file mode 100644 index 00000000..cbe6863b --- /dev/null +++ b/extensions/general/mcp-server/resources/reconciliation-summary.ts @@ -0,0 +1,99 @@ +import type { McpResource } from './types' +import { listReconciliationAccounts } from '@/lib/reconciliation/service' +import { ISO_DATE_RE } from '@/lib/invariants' + +/** + * Accounted://reconciliation/summary + * + * Every account with an outside truth (bank accounts, the skattekonto) with + * its reconciliation state, open counts and latest sign-off, in one read: the + * rail of the Avstämning page as a resource. Optional ?date_from / ?date_to + * scope the bank bridges (the skattekonto bridge is anchored at its saldo + * snapshot). Same service function the page and the v1 API use, so the + * agent sees exactly what the user sees. + */ +export const reconciliationSummaryResource: McpResource = { + uri: 'Accounted://reconciliation/summary', + name: 'Reconciliation Summary', + description: + 'Per-account reconciliation state for the active company: bank accounts (bank:) and the skattekonto, each with state (reconciled / open / stale / not_configured), unexplained_difference, open counts (proposed, unmatched_external, unmatched_ledger), last outside fetch, and the latest sign-off date. Optional ?date_from=YYYY-MM-DD&date_to=YYYY-MM-DD scope the bank bridges. Read this before gnubok_get_reconciliation_status / gnubok_list_reconciliation_items to pick the account that needs work.', + mimeType: 'application/json', + read: async ({ supabase, companyId, query }) => { + const dateFrom = query?.get('date_from') ?? undefined + const dateTo = query?.get('date_to') ?? undefined + if ((dateFrom && !ISO_DATE_RE.test(dateFrom)) || (dateTo && !ISO_DATE_RE.test(dateTo))) { + throw new Error('date_from / date_to must be YYYY-MM-DD') + } + const accounts = await listReconciliationAccounts(supabase, companyId, { + withStatus: true, + windowFrom: dateFrom, + windowTo: dateTo, + }) + + const rows = accounts.map((a) => ({ + account_key: a.account_key, + kind: a.kind, + name: a.name, + account_number: a.account_number, + currency: a.currency, + state: a.status?.state ?? 'not_configured', + unexplained_difference: a.status?.unexplained_difference ?? null, + open_counts: a.status?.open_counts ?? { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 }, + as_of: a.status?.as_of ?? null, + synced_at: a.source.synced_at, + stale: a.source.stale, + signed_off_through: a.signed_off_through ?? null, + superseded_by: a.superseded_by, + })) + + const live = rows.filter((r) => !r.superseded_by) + const totals = { + accounts: live.length, + reconciled: live.filter((r) => r.state === 'reconciled').length, + open: live.filter((r) => r.state === 'open' || r.state === 'stale').length, + not_configured: live.filter((r) => r.state === 'not_configured').length, + proposed: live.reduce((s, r) => s + r.open_counts.proposed, 0), + unmatched_external: live.reduce((s, r) => s + r.open_counts.unmatched_external, 0), + unmatched_ledger: live.reduce((s, r) => s + r.open_counts.unmatched_ledger, 0), + } + + // Point at the account with the most open work; proposals first since + // they are one staged call away from done. + const target = + [...live] + .filter((r) => r.state === 'open' || r.state === 'stale') + .sort( + (x, y) => + y.open_counts.proposed - x.open_counts.proposed || + y.open_counts.unmatched_external + + y.open_counts.unmatched_ledger - + (x.open_counts.unmatched_external + x.open_counts.unmatched_ledger), + )[0] ?? null + + return { + generated_at: new Date().toISOString(), + window: { from: dateFrom ?? null, to: dateTo ?? null }, + totals, + accounts: rows, + next: target + ? target.open_counts.proposed > 0 + ? { + description: `${target.name}: ${target.open_counts.proposed} föreslagna par väntar. Koppla dem, sedan bokför det som saknas.`, + tool: 'gnubok_reconcile_match', + args: { account_key: target.account_key, use_proposals: true, dry_run: true }, + } + : { + description: `${target.name}: läs raderna bakom bryggan och bokför eller koppla dem.`, + tool: 'gnubok_list_reconciliation_items', + args: { account_key: target.account_key }, + } + : { + description: + totals.accounts === 0 + ? 'Inga konton med en sanning utanför bokföringen (koppla bank eller Skatteverket).' + : 'Alla konton är förklarade. Signera månaden med gnubok_reconcile_signoff per konto.', + tool: totals.accounts === 0 ? undefined : 'gnubok_reconcile_signoff', + }, + } + }, +} diff --git a/extensions/general/mcp-server/skills/index.ts b/extensions/general/mcp-server/skills/index.ts index c4145419..08ed3a0b 100644 --- a/extensions/general/mcp-server/skills/index.ts +++ b/extensions/general/mcp-server/skills/index.ts @@ -8,6 +8,7 @@ import { payrollMonthlySkill } from './payroll-monthly' import { bankReconciliationSkill } from './bank-reconciliation' import { kreditfakturaProcessSkill } from './kreditfaktura-process' import { customerOnboardingSkill } from './customer-onboarding' +import { reconcileMonthSkill } from './reconcile-month' import { loadAtomsAsSkills, loadReferenceById } from './atoms' /** Static workflow skills the server ships with. Tier: 'workflow'. */ @@ -20,6 +21,7 @@ export const workflowSkills: Skill[] = [ bankReconciliationSkill, kreditfakturaProcessSkill, customerOnboardingSkill, + reconcileMonthSkill, ] /** @deprecated Use `workflowSkills` for the static set, or `loadAllSkills(supabase)` diff --git a/extensions/general/mcp-server/skills/reconcile-month.ts b/extensions/general/mcp-server/skills/reconcile-month.ts new file mode 100644 index 00000000..ad7615f3 --- /dev/null +++ b/extensions/general/mcp-server/skills/reconcile-month.ts @@ -0,0 +1,70 @@ +import type { Skill } from './types' + +const body = `# Reconcile a Month: Accounted + +Reconcile every account that has a truth outside the ledger (bank accounts and the skattekonto) for a month, then sign it off. This is the account-keyed flow: one engine, the same numbers the user sees on /reconciliation. + +## When to use + +- "Stäm av månaden" / "Stäm av banken och skattekontot" +- "Är juli avstämt?" / "Markera juli som avstämd" +- Before \`close_period\` and before the momsdeklaration + +## The model in one paragraph + +Each account is identified by an \`account_key\`: \`bank:\` or \`skattekonto\`. For each account the engine compares the outside balance (bank balance / Skatteverket saldo) with the ledger (19xx / 1630) and explains the difference line by line: unmatched outside rows, unmatched ledger lines, ignored rows, and for the skattekonto the opening difference before the fetched history. \`unexplained_difference\` is the number that matters: when it is 0 the account is reconciled. Matched pairs cancel out; a link never writes to the ledger. + +## Workflow + +### Step 1: Read the summary + +Read \`Accounted://reconciliation/summary\` (optionally \`?date_from&date_to\`). Pick the accounts whose \`state\` is \`open\` or \`stale\`. \`stale\` means the outside side is older than 7 days: ask the user to fetch (bank sync / skattekonto sync) before trusting the bridge. + +### Step 2: Read the bridge for one account + +\`gnubok_get_reconciliation_status({ account_key })\` returns the bridge: outside balance, ledger balance, difference, unexplained_difference, the explanatory lines, counts per bucket, and the latest sign-off. Judge on \`unexplained_difference\`, never on \`difference\`. + +### Step 3: Work the buckets, in this order + +\`gnubok_list_reconciliation_items({ account_key, bucket })\`: + +1. **proposed**: outside rows with a proposed verifikat (exact twin on amount/date). Link them in one staged call: \`gnubok_reconcile_match({ account_key, use_proposals: true, dry_run: true })\`, then without dry_run. The response lists \`applied[]\` and \`skipped[{code}]\`: a skip is information, not an error (ALREADY_LINKED, PAIR_NOT_CLOSED, ENTRY_NOT_FOUND). +2. **unmatched_external**: outside rows with no counterpart. Bank rows: book them (\`gnubok_categorize_transaction\`, or \`gnubok_link_transaction_to_journal_entry\` when the affärshändelse is already on a verifikat). Skattekonto rows: the user books them from /skattekonto or /reconciliation (the rule-based booking lives there); tell the user which rows and amounts. A row that will never be booked (a duplicate, a noise line) is ignored from the page, not by you. +3. **unmatched_ledger**: verifikat lines on the account with nothing outside. Within 5 days of the snapshot they may simply be waiting for the outside side (\`awaiting_external\`). Older ones are usually a wrong account or a missing outside row: show them to the user with voucher numbers; do not reverse anything on your own. +4. **matched** and **ignored** explain the bridge and need no work. + +Re-read the status after each round. Stop when \`unexplained_difference\` is 0, or when what remains needs a human decision. + +### Step 4: Sign off + +When the account is reconciled through the month end: \`gnubok_reconcile_signoff({ account_key, through_date: "YYYY-MM-DD", dry_run: true })\`, then without dry_run. It stages; the user approves. Refusals are policy, not failures: NOT_RECONCILED (something is still unexplained), NOT_FETCHED_THROUGH (skattekonto snapshot is older than the date), ALREADY_SIGNED_OFF (reopen first), NOTE_REQUIRED (force needs a note). Signing with \`force: true\` and a note is the user's call, never yours by default. + +### Step 5: Report + +Per account: outside vs ledger, what was linked, what the user still has to book, and the sign-off date. Point at \`/reconciliation?account=\` for anything that needs a hand. + +## Rules + +- Links and sign-offs never touch the ledger; booking does, and always stages. +- One outside row links to one verifikat in this version; other shapes come back as UNSUPPORTED_PAIR_SHAPE. A fee or rounding difference needs a residual booking by the user first. +- Never judge on \`difference\`; the bridge explains it. Judge on \`unexplained_difference\`. +- A skattekonto sign-off date cannot pass the saldo snapshot; ask for a fetch. + +## Tools used + +- \`gnubok_get_reconciliation_status\`, \`gnubok_list_reconciliation_items\` (read) +- \`gnubok_reconcile_match\`, \`gnubok_reconcile_unmatch\`, \`gnubok_reconcile_signoff\` (staged writes) +- \`gnubok_categorize_transaction\`, \`gnubok_link_transaction_to_journal_entry\` (bank-side booking) +- \`gnubok_approve_pending_operation\` (when the user approves in chat) +- Resource: \`Accounted://reconciliation/summary\` +` + +export const reconcileMonthSkill: Skill = { + slug: 'reconcile-month', + name: 'Reconcile a Month', + summary: 'Stäm av månaden: read the per-account bridge, link proposed pairs, get the rest booked, and sign each account off through the month end.', + tags: ['monthly', 'reconciliation', 'bank', 'skattekonto', 'sign-off'], + body, + tier: 'workflow', + applicability: { entity_type: 'both' }, +} diff --git a/extensions/general/skatteverket/lib/skattekonto-sync.ts b/extensions/general/skatteverket/lib/skattekonto-sync.ts index 2b00e804..53b3cc71 100644 --- a/extensions/general/skatteverket/lib/skattekonto-sync.ts +++ b/extensions/general/skatteverket/lib/skattekonto-sync.ts @@ -8,6 +8,11 @@ import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-serv import { fetchAllRows } from '@/lib/supabase/fetch-all' import { settleAgiTaxPayments } from './agi-tax-settlement' import { refreshSkattekontoProposals } from './skattekonto-proposals' +import { getSkattekontoReconciliationStatus } from '@/lib/reconciliation/skattekonto-reconciliation' +import { + SKATTEKONTO_RECONCILIATION_LATEST_KEY, + type SkattekontoReconciliationLatest, +} from '@/lib/reconciliation/skattekonto-latest' import { getSaldo, getTransaktioner } from './skattekonto-client' import { SkatteverketAuthError, type SkvAuth } from './api-client' import type { @@ -385,6 +390,33 @@ export async function syncSkattekonto( await ctx.settings.set(BALANCE_SNAPSHOT_KEY, snapshot) await ctx.settings.set(LAST_SYNCED_AT_KEY, new Date().toISOString()) + // Persist the reconciliation summary so the Hem notice and the attention + // resource can read "skattekontot stämmer inte med bokföringen" cheaply + // instead of recomputing the bridge on every render. Best effort. + try { + const status = await getSkattekontoReconciliationStatus(ctx.supabase, ctx.companyId) + if (status) { + const latest: SkattekontoReconciliationLatest = { + as_of: status.as_of, + computed_at: new Date().toISOString(), + external_balance: status.external_balance, + ledger_balance: status.ledger_balance, + unexplained_difference: status.unexplained_difference, + counts: { + proposed: status.counts.proposed, + unmatched_external: status.counts.unmatched_external, + unmatched_ledger: status.counts.unmatched_ledger, + }, + } + await ctx.settings.set(SKATTEKONTO_RECONCILIATION_LATEST_KEY, latest) + } + } catch (err) { + log.warn('reconciliation summary not persisted', { + companyId: ctx.companyId, + message: err instanceof Error ? err.message : String(err), + }) + } + // Emit events. await ctx.emit({ type: 'skattekonto.synced', diff --git a/lib/notices/__tests__/aggregate.test.ts b/lib/notices/__tests__/aggregate.test.ts index e0e70cad..1668f50f 100644 --- a/lib/notices/__tests__/aggregate.test.ts +++ b/lib/notices/__tests__/aggregate.test.ts @@ -8,6 +8,7 @@ const detectMocks = vi.hoisted(() => ({ skv: vi.fn(), backup: vi.fn(), expiring: vi.fn(), + unexplained: vi.fn(), other: vi.fn(), })) @@ -16,6 +17,7 @@ vi.mock('../categories', () => ({ detectSkvDisconnected: detectMocks.skv, detectBackupFailing: detectMocks.backup, detectExpiringBankConnections: detectMocks.expiring, + detectSkvUnexplained: detectMocks.unexplained, detectOtherAccountHint: detectMocks.other, })) @@ -39,6 +41,7 @@ beforeEach(() => { detectMocks.skv.mockResolvedValue(null) detectMocks.backup.mockResolvedValue(null) detectMocks.expiring.mockResolvedValue(null) + detectMocks.unexplained.mockResolvedValue(null) detectMocks.other.mockResolvedValue(null) mockResult({ data: [] }) // notice_dismissals: none }) diff --git a/lib/notices/__tests__/categories.test.ts b/lib/notices/__tests__/categories.test.ts index 72eae542..ffda4f94 100644 --- a/lib/notices/__tests__/categories.test.ts +++ b/lib/notices/__tests__/categories.test.ts @@ -6,7 +6,7 @@ import { createQueuedMockSupabase } from '@/tests/helpers' const otherAccountHintMock = vi.hoisted(() => vi.fn()) vi.mock('@/lib/extensions/_generated/enabled-extensions', () => ({ - ENABLED_EXTENSION_IDS: new Set(['cloud-backup']), + ENABLED_EXTENSION_IDS: new Set(['cloud-backup', 'skatteverket']), })) vi.mock('@/lib/company/other-account-hint', () => ({ shouldShowOtherAccountHint: otherAccountHintMock, @@ -18,6 +18,7 @@ import { detectExpiringBankConnections, detectOtherAccountHint, detectSkvDisconnected, + detectSkvUnexplained, expiringBankConnectionsFrom, skvAuthErrorNeedsReconnect, skvStatusNeedsReconnect, @@ -428,3 +429,50 @@ describe('never-throws contract', () => { await expect(detectOtherAccountHint(throwing, COMPANY)).resolves.toBeNull() }) }) + +describe('detectSkvUnexplained', () => { + const latest = (unexplained: number | null, external: number | null = 1000) => ({ + key: 'skattekonto_reconciliation_latest', + value: { + as_of: '2026-08-19T04:00:00Z', + computed_at: '2026-08-19T04:00:05Z', + external_balance: external, + ledger_balance: 900, + unexplained_difference: unexplained, + counts: { proposed: 0, unmatched_external: 1, unmatched_ledger: 0 }, + }, + }) + + it('returns null without a persisted summary or within tolerance', async () => { + enqueue({ data: [] }) + await expect(detectSkvUnexplained(supabase, COMPANY)).resolves.toBeNull() + reset() + enqueue({ data: [latest(0.4)] }) + await expect(detectSkvUnexplained(supabase, COMPANY)).resolves.toBeNull() + }) + + it('surfaces the unexplained amount with a whole-krona discriminator and the reconciliation link', async () => { + enqueue({ data: [latest(-1234.56)] }) + const notice = await detectSkvUnexplained(supabase, COMPANY) + expect(notice).toMatchObject({ + id: 'skv_unexplained:-1235', + category: 'skv_unexplained', + severity: 'warning', + messageKey: 'skv_unexplained', + actionKey: 'skv_unexplained_action', + actionHref: '/reconciliation?account=skattekonto', + }) + expect(String(notice?.messageParams?.amount)).toMatch(/1.?234/) + expect(mockSupabase.from).toHaveBeenCalledWith('extension_data') + }) + + it('honours the configured drift tolerance', async () => { + enqueue({ data: [latest(40), { key: 'skattekonto_drift_tolerance', value: 50 }] }) + await expect(detectSkvUnexplained(supabase, COMPANY)).resolves.toBeNull() + }) + + it('soft-fails to null on a query error', async () => { + enqueue({ error: { message: 'boom' } }) + await expect(detectSkvUnexplained(supabase, COMPANY)).resolves.toBeNull() + }) +}) diff --git a/lib/notices/aggregate.ts b/lib/notices/aggregate.ts index cfe38b88..3ba6e332 100644 --- a/lib/notices/aggregate.ts +++ b/lib/notices/aggregate.ts @@ -7,6 +7,7 @@ import { detectExpiringBankConnections, detectOtherAccountHint, detectSkvDisconnected, + detectSkvUnexplained, } from './categories' const log = createLogger('notices') @@ -44,6 +45,7 @@ export async function getCompanyNotices( detectSkvDisconnected(supabase, userId, companyId, now), detectBackupFailing(supabase, companyId), detectExpiringBankConnections(supabase, companyId, now), + detectSkvUnexplained(supabase, companyId), detectOtherAccountHint(supabase, companyId), ]), fetchDismissedIds(supabase, companyId, userId), diff --git a/lib/notices/categories.ts b/lib/notices/categories.ts index b34e0775..1e30f2cf 100644 --- a/lib/notices/categories.ts +++ b/lib/notices/categories.ts @@ -13,6 +13,15 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { createLogger } from '@/lib/logger' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { shouldShowOtherAccountHint } from '@/lib/company/other-account-hint' +import { formatCurrency } from '@/lib/utils' +import { + DEFAULT_SKATTEKONTO_TOLERANCE_SEK, + SKATTEKONTO_DRIFT_TOLERANCE_KEY, + SKATTEKONTO_EXTENSION_ID, + SKATTEKONTO_RECONCILIATION_LATEST_KEY, + skattekontoUnexplainedFrom, + type SkattekontoReconciliationLatest, +} from '@/lib/reconciliation/skattekonto-latest' import { expiringBankConnectionsFrom, skvStatusNeedsReconnect } from './predicates' import type { Notice } from './types' @@ -333,3 +342,51 @@ export async function detectOtherAccountHint( ) } } + +/** + * skv_unexplained: the skattekonto's latest reconciliation summary (written + * by the skatteverket extension on every sync) shows an unexplained + * difference above the drift tolerance. Reads extension_data directly (core + * must not import from @/extensions/; the key and value shape live in + * lib/reconciliation/skattekonto-latest.ts, which the extension imports). + * The id carries the signed whole-krona amount, so öre-level movement does + * not resurface a dismissed notice while a materially different difference + * does. + */ +export async function detectSkvUnexplained( + supabase: SupabaseClient, + companyId: string, +): Promise { + try { + if (!ENABLED_EXTENSION_IDS.has(SKATTEKONTO_EXTENSION_ID)) return null + const { data, error } = await supabase + .from('extension_data') + .select('key, value') + .eq('company_id', companyId) + .eq('extension_id', SKATTEKONTO_EXTENSION_ID) + .in('key', [SKATTEKONTO_RECONCILIATION_LATEST_KEY, SKATTEKONTO_DRIFT_TOLERANCE_KEY]) + if (error) return logAndNull('skv_unexplained', companyId, error) + const byKey = new Map((data ?? []).map((r) => [r.key as string, r.value])) + const latest = byKey.get(SKATTEKONTO_RECONCILIATION_LATEST_KEY) as SkattekontoReconciliationLatest | undefined + const toleranceRaw = byKey.get(SKATTEKONTO_DRIFT_TOLERANCE_KEY) + const tolerance = typeof toleranceRaw === 'number' ? toleranceRaw : DEFAULT_SKATTEKONTO_TOLERANCE_SEK + const unexplained = skattekontoUnexplainedFrom(latest, tolerance) + if (unexplained == null) return null + const whole = Math.round(unexplained) + return { + id: `skv_unexplained:${whole >= 0 ? '+' : '-'}${Math.abs(whole)}`, + category: 'skv_unexplained', + severity: 'warning', + messageKey: 'skv_unexplained', + messageParams: { amount: formatCurrency(unexplained, 'SEK') }, + actionKey: 'skv_unexplained_action', + actionHref: '/reconciliation?account=skattekonto', + } + } catch (err) { + return logAndNull( + 'skv_unexplained', + companyId, + err instanceof Error ? { message: err.message } : null, + ) + } +} diff --git a/lib/notices/types.ts b/lib/notices/types.ts index cc729b42..26a8f2a0 100644 --- a/lib/notices/types.ts +++ b/lib/notices/types.ts @@ -48,6 +48,17 @@ export const NOTICE_CATEGORIES = [ * connection expires (moves to bank_connection_broken). */ 'bank_connection_expiring', + /** + * The skattekonto does not agree with the ledger after the latest sync. + * Pending: the skatteverket extension's persisted reconciliation summary + * (extension_data key skattekonto_reconciliation_latest, written + * on every sync) has |unexplained_difference| above the drift + * tolerance (skattekonto_drift_tolerance, default 1 SEK). + * Done: the next sync computes an unexplained difference within + * tolerance (rows linked, booked or ignored), or the summary is + * gone (extension disconnected). + */ + 'skv_unexplained', /** * The signed-in account looks bookkeeping-empty while a same-orgnr company * with real bookkeeping exists in another account (#1231). @@ -70,6 +81,7 @@ export const NOTICE_PRIORITY: readonly NoticeCategory[] = [ 'skv_disconnected', 'backup_failing', 'bank_connection_expiring', + 'skv_unexplained', 'other_account_hint', ] diff --git a/lib/reconciliation/__tests__/bank-logos.test.ts b/lib/reconciliation/__tests__/bank-logos.test.ts new file mode 100644 index 00000000..f05efc47 --- /dev/null +++ b/lib/reconciliation/__tests__/bank-logos.test.ts @@ -0,0 +1,46 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, it, expect } from 'vitest' +import { bankLogoUrl } from '../bank-logos' + +describe('bankLogoUrl', () => { + it('maps every prod bank name (2026-08-24 inventory) to an icon', () => { + const cases: Array<[string, string]> = [ + ['SEB', 'seb'], + ['Lunar', 'lunar'], + ['Handelsbanken', 'handelsbanken'], + ['Swedbank', 'swedbank'], + ['Nordea', 'nordea'], + ['Nordea Corporate', 'nordea'], + ['Svea Bank', 'svea'], + ['Länsförsäkringar Bank', 'lansforsakringar'], + ['Revolut', 'revolut'], + ['Wise', 'wise'], + ['Danske Bank', 'danske'], + ['Klarna', 'klarna'], + ['Northmill', 'northmill'], + ['PayPal', 'paypal'], + ] + for (const [name, slug] of cases) { + expect(bankLogoUrl(name), name).toBe(`/logos/banks/${slug}.png`) + } + }) + + it('falls back through candidates, avoids substring false positives, and returns null for unknowns', () => { + expect(bankLogoUrl(null, undefined, 'Swedbank Företagskonto')).toBe('/logos/banks/swedbank.png') + // "seb"/"wise" only match as words: no logo hijacking from lookalikes. + expect(bankLogoUrl('Riseberga Sparbank')).toBeNull() + expect(bankLogoUrl('Otherwise AB')).toBeNull() + expect(bankLogoUrl('Sparbanken Sjuhärad')).toBeNull() + expect(bankLogoUrl('Mock ASPSP')).toBeNull() + expect(bankLogoUrl()).toBeNull() + }) + + it('every mapped icon file exists in public/logos/banks', () => { + const files = new Set(readdirSync(join(process.cwd(), 'public', 'logos', 'banks'))) + const slugs = ['handelsbanken', 'swedbank', 'seb', 'nordea', 'lunar', 'svea', 'lansforsakringar', 'revolut', 'wise', 'danske', 'klarna', 'northmill', 'paypal', 'stripe'] + for (const slug of slugs) { + expect(files.has(`${slug}.png`), slug).toBe(true) + } + }) +}) diff --git a/lib/reconciliation/__tests__/service.test.ts b/lib/reconciliation/__tests__/service.test.ts index 4129d90c..9fbcd0d0 100644 --- a/lib/reconciliation/__tests__/service.test.ts +++ b/lib/reconciliation/__tests__/service.test.ts @@ -87,6 +87,7 @@ describe('listReconciliationAccounts', () => { ], }) enqueue({ data: [] }) // latest sign-offs (none) + enqueue({ data: [{ id: 'conn-1', bank_name: 'Swedbank' }] }) // bank names for logos // latestBankSyncAt per account (withStatus=false skips bankStatus): three maybeSingle reads enqueue({ data: { created_at: '2026-08-19T06:00:00Z' } }) enqueue({ data: { created_at: '2026-06-01T06:00:00Z' } }) @@ -120,6 +121,8 @@ describe('listReconciliationAccounts', () => { expect(byKey[bankAccountKey(ID_B)].superseded_by).toBe(bankAccountKey(ID_A)) expect(byKey[bankAccountKey(ID_A)].superseded_by).toBeNull() expect(byKey[bankAccountKey(ID_C)].superseded_by).toBeNull() + // The connection's bank name resolves to the committed brand icon. + expect(byKey[bankAccountKey(ID_A)].logo_url).toBe('/logos/banks/swedbank.png') // Sync age drives staleness (7 days). expect(byKey[bankAccountKey(ID_A)].source).toMatchObject({ type: 'psd2', stale: false }) expect(byKey[bankAccountKey(ID_B)].source.stale).toBe(true) @@ -137,6 +140,7 @@ describe('listReconciliationAccounts', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: [cashAccount(ID_A, { is_primary: true })] }) enqueue({ data: [] }) // latest sign-offs (none) + enqueue({ data: [] }) // bank names for logos enqueue({ data: null }) skattekontoStatusMock.mockResolvedValue(null) @@ -151,6 +155,7 @@ describe('listReconciliationAccounts', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: [cashAccount(ID_A, { is_primary: true, currency: 'SEK' })] }) enqueue({ data: [] }) // latest sign-offs (none) + enqueue({ data: [] }) // bank names for logos bankStatusMock.mockResolvedValue(bankStatus({ unmatched_transaction_count: 2, unmatched_transaction_total: -1046, is_reconciled: false })) enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) // latestBankSyncAt inside bankStatus enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) // latestBankSyncAt for the account row diff --git a/lib/reconciliation/__tests__/skattekonto-latest.test.ts b/lib/reconciliation/__tests__/skattekonto-latest.test.ts new file mode 100644 index 00000000..69974c1d --- /dev/null +++ b/lib/reconciliation/__tests__/skattekonto-latest.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { skattekontoUnexplainedFrom, type SkattekontoReconciliationLatest } from '../skattekonto-latest' + +function latest(overrides: Partial = {}): SkattekontoReconciliationLatest { + return { + as_of: '2026-08-20T04:00:00Z', + computed_at: '2026-08-20T04:00:05Z', + external_balance: 1000, + ledger_balance: 900, + unexplained_difference: 100, + counts: { proposed: 0, unmatched_external: 1, unmatched_ledger: 0 }, + ...overrides, + } +} + +describe('skattekontoUnexplainedFrom', () => { + it('returns null without a summary, with an unknown outside balance, or within tolerance', () => { + expect(skattekontoUnexplainedFrom(null)).toBeNull() + expect(skattekontoUnexplainedFrom(undefined)).toBeNull() + expect(skattekontoUnexplainedFrom(latest({ external_balance: null, unexplained_difference: null }))).toBeNull() + expect(skattekontoUnexplainedFrom(latest({ unexplained_difference: 0 }))).toBeNull() + expect(skattekontoUnexplainedFrom(latest({ unexplained_difference: -0.5 }))).toBeNull() + }) + + it('returns the signed amount above tolerance and honours a custom tolerance', () => { + expect(skattekontoUnexplainedFrom(latest({ unexplained_difference: 100 }))).toBe(100) + expect(skattekontoUnexplainedFrom(latest({ unexplained_difference: -12.5 }))).toBe(-12.5) + expect(skattekontoUnexplainedFrom(latest({ unexplained_difference: 12.5 }), 50)).toBeNull() + // A nonsense tolerance falls back to the default (1 SEK). + expect(skattekontoUnexplainedFrom(latest({ unexplained_difference: 2 }), -5)).toBe(2) + }) +}) diff --git a/lib/reconciliation/bank-logos.ts b/lib/reconciliation/bank-logos.ts new file mode 100644 index 00000000..75914e7a --- /dev/null +++ b/lib/reconciliation/bank-logos.ts @@ -0,0 +1,40 @@ +/** + * Bank logos for the reconciliation rail. Resolved by name (the connection's + * bank_name from the connect flow, falling back to the account name) against + * the brand icons committed under public/logos/banks/: the set covers every + * bank with a live connection in prod as of 2026-08-24. No match (the small + * sparbanker, file-imported accounts) falls back to the monogram; that is a + * presentation default, never an error. + */ + +const BANK_LOGO_PATTERNS: ReadonlyArray = [ + [/handelsbanken/, 'handelsbanken'], + [/swedbank/, 'swedbank'], + [/\bseb\b/, 'seb'], + [/nordea/, 'nordea'], + [/\blunar\b/, 'lunar'], + [/\bsvea\b/, 'svea'], + [/l[aä]nsf[oö]rs[aä]kringar/, 'lansforsakringar'], + [/revolut/, 'revolut'], + [/\bwise\b/, 'wise'], + [/danske/, 'danske'], + [/klarna/, 'klarna'], + [/northmill/, 'northmill'], + [/paypal/, 'paypal'], + [/stripe/, 'stripe'], +] + +/** + * First matching brand icon for any of the candidate names (checked in + * order), or null for the monogram fallback. + */ +export function bankLogoUrl(...names: Array): string | null { + for (const name of names) { + if (!name) continue + const haystack = name.toLowerCase() + for (const [pattern, slug] of BANK_LOGO_PATTERNS) { + if (pattern.test(haystack)) return `/logos/banks/${slug}.png` + } + } + return null +} diff --git a/lib/reconciliation/service.ts b/lib/reconciliation/service.ts index bec105f2..9517df99 100644 --- a/lib/reconciliation/service.ts +++ b/lib/reconciliation/service.ts @@ -13,6 +13,7 @@ import { type ReconciliationStatus, } from './schemas' import { getLatestSignoff, getLatestSignoffs } from './signoff-store' +import { bankLogoUrl } from './bank-logos' const log = createLogger('reconciliation/service') @@ -87,8 +88,8 @@ function bankBridge(status: Awaited() + const connectionIds = [...new Set(cashAccounts.map((a) => a.bank_connection_id).filter((x): x is string => !!x))] + if (connectionIds.length > 0) { + const { data: connRows, error: connError } = await supabase + .from('bank_connections') + .select('id, bank_name') + .in('id', connectionIds) + if (connError) { + log.warn('bank_name read failed; monograms instead of logos', { companyId, error: connError.message }) + } + for (const r of (connRows ?? []) as Array<{ id: string; bank_name: string | null }>) { + if (r.bank_name) bankNameByConnection.set(r.id, r.bank_name) + } + } + const bankAccounts = await Promise.all( cashAccounts.map(async (a): Promise => { let status: ReconciliationStatus | null = null @@ -279,7 +297,7 @@ export async function listReconciliationAccounts( account_number: a.ledger_account, name: a.name ?? `Bankkonto ${a.ledger_account}`, currency: a.currency ?? 'SEK', - logo_url: null, + logo_url: bankLogoUrl(a.bank_connection_id ? bankNameByConnection.get(a.bank_connection_id) : null, a.name), source: { type: a.bank_connection_id ? 'psd2' : a.source === 'file' ? 'bank_file' : 'manual', synced_at: syncedAt, diff --git a/lib/reconciliation/skattekonto-latest.ts b/lib/reconciliation/skattekonto-latest.ts new file mode 100644 index 00000000..88d605c3 --- /dev/null +++ b/lib/reconciliation/skattekonto-latest.ts @@ -0,0 +1,48 @@ +/** + * The skattekonto reconciliation summary persisted at every sync, so cheap + * readers (the Hem notice, the attention resource) can say "the skattekonto + * does not agree with the ledger by X" without recomputing the bridge on + * every render. Written by the skatteverket extension's sync (which imports + * this file: extensions may import core, never the other way around), read + * from extension_data (extension_id 'skatteverket') by core. + */ + +export const SKATTEKONTO_EXTENSION_ID = 'skatteverket' + +/** extension_data key under which the sync stores the latest summary. */ +export const SKATTEKONTO_RECONCILIATION_LATEST_KEY = 'skattekonto_reconciliation_latest' + +/** extension_data key of the user's drift tolerance (SEK); mirrors the extension's setting. */ +export const SKATTEKONTO_DRIFT_TOLERANCE_KEY = 'skattekonto_drift_tolerance' + +/** Default tolerance when none is configured; mirrors the extension's DEFAULT_TOLERANCE_SEK. */ +export const DEFAULT_SKATTEKONTO_TOLERANCE_SEK = 1 + +export interface SkattekontoReconciliationLatest { + /** ISO timestamp of the saldo snapshot the summary was computed against. */ + as_of: string + /** ISO timestamp the summary was computed (the sync run). */ + computed_at: string + external_balance: number | null + ledger_balance: number | null + unexplained_difference: number | null + counts: { + proposed: number + unmatched_external: number + unmatched_ledger: number + } +} + +/** + * Pure: the unexplained amount worth surfacing, or null when the skattekonto + * agrees with the ledger (within tolerance), the summary is missing, or the + * outside balance is unknown. + */ +export function skattekontoUnexplainedFrom( + latest: SkattekontoReconciliationLatest | null | undefined, + tolerance: number = DEFAULT_SKATTEKONTO_TOLERANCE_SEK, +): number | null { + if (!latest || latest.unexplained_difference == null || latest.external_balance == null) return null + const tol = Number.isFinite(tolerance) && tolerance > 0 ? tolerance : DEFAULT_SKATTEKONTO_TOLERANCE_SEK + return Math.abs(latest.unexplained_difference) > tol ? latest.unexplained_difference : null +} diff --git a/lib/skatteverket/__tests__/sync-order.test.ts b/lib/skatteverket/__tests__/sync-order.test.ts new file mode 100644 index 00000000..d7e34dcf --- /dev/null +++ b/lib/skatteverket/__tests__/sync-order.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { orderByStalestSync } from '../sync-order' + +describe('orderByStalestSync', () => { + const work = [ + { companyId: 'a', userId: 'u' }, + { companyId: 'b', userId: 'u' }, + { companyId: 'c', userId: 'u' }, + { companyId: 'd', userId: 'u' }, + ] + + it('puts never-synced companies first, then the longest-ago synced, and keeps ties stable', () => { + const last = new Map([ + ['a', '2026-08-23T01:00:00Z'], + ['b', '2026-08-22T01:00:00Z'], + ['c', null], + // d: no row at all + ]) + expect(orderByStalestSync(work, last).map((w) => w.companyId)).toEqual(['c', 'd', 'b', 'a']) + }) + + it('keeps the incoming order when nothing is known', () => { + expect(orderByStalestSync(work, new Map()).map((w) => w.companyId)).toEqual(['a', 'b', 'c', 'd']) + }) + + it('treats an unparseable timestamp as never synced', () => { + const last = new Map([ + ['a', 'not-a-date'], + ['b', '2026-08-22T01:00:00Z'], + ]) + expect(orderByStalestSync(work, last).map((w) => w.companyId)).toEqual(['a', 'c', 'd', 'b']) + }) +}) diff --git a/lib/skatteverket/sync-order.ts b/lib/skatteverket/sync-order.ts new file mode 100644 index 00000000..12e4871a --- /dev/null +++ b/lib/skatteverket/sync-order.ts @@ -0,0 +1,21 @@ +/** + * Fair ordering for the skattekonto sync cron: with more connected companies + * than one run can process (MAX_COMPANIES_PER_RUN), a fixed order would + * starve the tail forever. Never-synced companies go first, then the ones + * synced longest ago; ties keep the incoming order (stable sort). + */ +export function orderByStalestSync( + work: readonly T[], + lastSyncedAtByCompany: ReadonlyMap, +): T[] { + const rank = (item: T): number => { + const iso = lastSyncedAtByCompany.get(item.companyId) + if (!iso) return Number.NEGATIVE_INFINITY + const ms = Date.parse(iso) + return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY + } + return work + .map((item, index) => ({ item, index, rank: rank(item) })) + .sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank)) + .map((x) => x.item) +} diff --git a/messages/en.json b/messages/en.json index 01b4e5dd..a11756cd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6549,6 +6549,8 @@ "bank_expiring_action": "Renew the consent", "other_account_hint": "This account is empty, but bookkeeping for the same organisation number exists in another Accounted account. Did you sign in with the wrong login?", "other_account_hint_action": "Switch account", + "skv_unexplained": "The tax account does not agree with the ledger: {amount} is unexplained after the latest fetch.", + "skv_unexplained_action": "Reconcile", "more_count": "+{count} more", "dismiss": "Hide" }, @@ -7813,7 +7815,7 @@ "empty_connect_skv": "Connect Skatteverket", "load_failed": "Could not load the reconciliation. Try again in a moment.", "tile_external_skv": "Balance at Skatteverket", - "tile_external_bank": "Bank transactions in the period", + "tile_external_bank": "Bank transactions in the period (net)", "tile_ledger": "Booked on {account}", "tile_ledger_bank": "Booked on {account} in the period", "tile_difference": "Difference", diff --git a/messages/sv.json b/messages/sv.json index f4bfdbb3..33d8091f 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6549,6 +6549,8 @@ "bank_expiring_action": "Förnya samtycket", "other_account_hint": "Det här kontot är tomt, men bokföring för samma organisationsnummer finns i ett annat Accounted-konto. Loggade du in med fel inloggning?", "other_account_hint_action": "Byt konto", + "skv_unexplained": "Skattekontot stämmer inte med bokföringen: {amount} är oförklarat efter senaste hämtningen.", + "skv_unexplained_action": "Stäm av", "more_count": "+{count} till", "dismiss": "Dölj" }, @@ -7813,7 +7815,7 @@ "empty_connect_skv": "Koppla Skatteverket", "load_failed": "Kunde inte hämta avstämningen. Försök igen om en stund.", "tile_external_skv": "Saldo hos Skatteverket", - "tile_external_bank": "Banktransaktioner i perioden", + "tile_external_bank": "Banktransaktioner i perioden (netto)", "tile_ledger": "Bokfört på {account}", "tile_ledger_bank": "Bokfört på {account} i perioden", "tile_difference": "Differens", diff --git a/public/logos/banks/danske.png b/public/logos/banks/danske.png new file mode 100644 index 00000000..fd898788 Binary files /dev/null and b/public/logos/banks/danske.png differ diff --git a/public/logos/banks/handelsbanken.png b/public/logos/banks/handelsbanken.png new file mode 100644 index 00000000..a04152b7 Binary files /dev/null and b/public/logos/banks/handelsbanken.png differ diff --git a/public/logos/banks/klarna.png b/public/logos/banks/klarna.png new file mode 100644 index 00000000..5b744853 Binary files /dev/null and b/public/logos/banks/klarna.png differ diff --git a/public/logos/banks/lansforsakringar.png b/public/logos/banks/lansforsakringar.png new file mode 100644 index 00000000..39e1f611 Binary files /dev/null and b/public/logos/banks/lansforsakringar.png differ diff --git a/public/logos/banks/lunar.png b/public/logos/banks/lunar.png new file mode 100644 index 00000000..23a62042 Binary files /dev/null and b/public/logos/banks/lunar.png differ diff --git a/public/logos/banks/nordea.png b/public/logos/banks/nordea.png new file mode 100644 index 00000000..dd68e281 Binary files /dev/null and b/public/logos/banks/nordea.png differ diff --git a/public/logos/banks/northmill.png b/public/logos/banks/northmill.png new file mode 100644 index 00000000..f5205fdd Binary files /dev/null and b/public/logos/banks/northmill.png differ diff --git a/public/logos/banks/paypal.png b/public/logos/banks/paypal.png new file mode 100644 index 00000000..eb6ee9c9 Binary files /dev/null and b/public/logos/banks/paypal.png differ diff --git a/public/logos/banks/revolut.png b/public/logos/banks/revolut.png new file mode 100644 index 00000000..bfe6fa1f Binary files /dev/null and b/public/logos/banks/revolut.png differ diff --git a/public/logos/banks/seb.png b/public/logos/banks/seb.png new file mode 100644 index 00000000..4c16b3a6 Binary files /dev/null and b/public/logos/banks/seb.png differ diff --git a/public/logos/banks/stripe.png b/public/logos/banks/stripe.png new file mode 100644 index 00000000..ea8bd344 Binary files /dev/null and b/public/logos/banks/stripe.png differ diff --git a/public/logos/banks/svea.png b/public/logos/banks/svea.png new file mode 100644 index 00000000..452005ce Binary files /dev/null and b/public/logos/banks/svea.png differ diff --git a/public/logos/banks/swedbank.png b/public/logos/banks/swedbank.png new file mode 100644 index 00000000..eea76c07 Binary files /dev/null and b/public/logos/banks/swedbank.png differ diff --git a/public/logos/banks/wise.png b/public/logos/banks/wise.png new file mode 100644 index 00000000..a9b9aa3b Binary files /dev/null and b/public/logos/banks/wise.png differ