feat(reconciliation): agent surfaces, skattekonto notice, bank icons and fair sync order (#1836)

* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation

PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door
calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link.

- lib/reconciliation/items.ts: listAccountItems per account_key, the page's
  buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored,
  upcoming), limit/offset; skattekonto from the engine, bank from the scoped
  transactions + unlinked GL lines (netted per entry).
- lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run,
  partial success with codes), unmatchLink, setItemIgnored; emits
  reconciliation.matched / reconciliation.unmatched.
- lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a
  skattekonto row (single line or entry net on 1630, live-link guard, race-safe
  update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry
  until its tests are ported.
- Dashboard routes /api/reconciliation/accounts[...]: list, status, items,
  links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply
  directly (a human clicked).
- v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six,
  withApiV1, new scopes reconciliation:read / reconciliation:write (write is a
  staging scope for SoD), Idempotency-Key + dry_run on writes, registered for
  OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes
  and their transactions:* scopes unchanged.
- MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path
  untouched), new gnubok_list_reconciliation_items (default catalog),
  gnubok_reconcile_match (stages reconciliation_match, preflight = status) and
  gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to
  stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry
  moved to search. Executors in commit.ts; risk tiers medium/low; migration pair
  20260823130000/130001 adds the two op types to the CHECK constraint (value
  list = live prod as of 2026-08-23 + the two); close_period loadout updated.

Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/
happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard
suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and
apiskill:check green; no type errors in changed files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard

The six new v1 reconciliation endpoints and the two new scopes were not
recorded in the spec snapshot, and setSkattekontoRowIgnored updated
through one conditional payload, which the phantom-column scanner cannot
read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): the Avstämning page, one body for every account with an outside truth

/reconciliation in Arbeta (after Transaktioner), on the approved layout:
an account rail on the left (bank accounts and the skattekonto, logo or
monogram, last fetch, status dot, URL-owned selection), and for the
selected account four tiles (outside, ledger, difference, unexplained),
the bridge that explains the difference, an actions row (link the
proposed pairs, book the unbooked skattekonto events, run the bank
matcher) and a full-width table banded by bucket with proposal rows
linkable one by one. Every read and write goes through the PR 2
dashboard routes, so the page shows exactly what the v1 API and the MCP
tools see.

Also: nav item, command palette entry, sv/en strings. Period picker,
manual match mode and sign-off are deliberately not here (PR 4/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): sign-off, period picker, Hem row and the three doors for it

"Markera som avstämd t.o.m. <datum>" as an append-only attestation:
account_reconciliations (who signed which account through which date,
with the numbers as they stood; reopen stamps instead of deletes; RLS
members write as themselves, viewers read). Policy in one place
(lib/reconciliation/signoff.ts): refused with an unexplained difference
unless forced with a note, refused past today or past the skattekonto
snapshot, refused at or before an active sign-off; reopen is the undo.
Every status read now carries the latest active sign-off and the rail
shows "avstämt t.o.m.".

Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen),
v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run,
registry + regenerated API skill), MCP gnubok_reconcile_signoff (search
catalog, stages reconciliation_signoff after a policy dry run; executor
+ risk tier + op-type CHECK migration pair). Events
reconciliation.signed_off / reconciliation.reopened, and the four
reconciliation events join the public webhook set (additive; API version
unchanged, changelog section added).

Page: räkenskapsår + range picker in the header (own preset memory,
opens on this month) scoping the bridge, the items and the default
sign-off date; sign-off dialog with the forced-with-note path; reopen
on hover. Hem: worklist category reconciliation_due ("Konton att stämma
av"), zero until the company has signed anything off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard

gnubok_reconcile_signoff carries the deliberately separate
reconciliation:signoff scope; the central viewer guard keys on the
:write/:approve/:manage suffixes, so a viewer could reach the tool (RLS
would still refuse the row, but the guard is the intended layer). Add
:signoff to the classifier; the strictness test that caught it now passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(providers): serve local rate-limiter waiters in arrival order

Two callers that both found the in-memory bucket empty each set their own
timeout; the timeouts expired at the same instant from different timer
lists and which woke first was platform-dependent. hydrateInvoices relies
on "started first, requested first" to serve open invoices before paid
ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI
(twice on #1817) while holding locally. A promise queue makes the local
waiters FIFO without changing the rate; the Upstash path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d)

* feat(reconciliation): agent surfaces: summary resource, attention category, reconcile-month skill, skattekonto notice, fair sync order

Accounted://reconciliation/summary: every reconcilable account with its
state, unexplained difference, open counts, last fetch and latest
sign-off, plus a next step; the rail as a resource, on the same service
function the page and v1 use. Accounted://attention gains
reconciliation_due (shared predicate with the Hem row). A reconcile-month
workflow skill and the reconcile_month loadout describe the account-keyed
flow (summary -> bridge -> buckets -> sign-off).

The skattekonto sync persists its reconciliation summary
(skattekonto_reconciliation_latest) so the new Hem notice skv_unexplained
("Skattekontot stämmer inte med bokföringen: X är oförklarat", link to
/reconciliation?account=skattekonto) costs one small read instead of a
bridge computation per render; it honours the drift tolerance and its id
carries the whole-krona amount so öre noise never resurfaces a dismissal.

The skattekonto sync cron orders eligible companies by stalest sync
(never-synced first) before its per-run cap, so the tail is no longer
starved by a fixed order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: retrigger preview build (builder OOM during Running TypeScript, not the diff)

* fix(reconciliation): visual pass round 1: full-width table, bank tile shows the period sum

From Jakob's first look at the page on real data:
- The items table now spans the full page width (the approved layout);
  the rail + tiles + bridge + actions stay in the two-column grid above
  it, which now lives inside AccountOverview (the rail rides in as a
  prop) so the table can break out below.
- The bank account's first tile said "okänt": it read external_balance
  (the reported bank balance, often unknown) while its label says
  Banktransaktioner i perioden. It now shows the bridge's period sum,
  matching the label, the difference and the bridge line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): bank brand icons in the rail

The rail resolves each bank account's icon from its connection's
bank_name (falling back to the account name) against square brand icons
committed under public/logos/banks/: the set covers every bank with a
live connection in prod as of 2026-08-24 (SEB, Lunar, Handelsbanken,
Swedbank, Nordea, Svea, Länsförsäkringar, Revolut, Wise, Danske, Klarna,
Northmill, PayPal, plus Stripe for named accounts). Word-boundary
matching so lookalike names never hijack a logo; anything unmatched (the
small sparbanker, file imports) keeps the monogram. The skattekonto
already had its Skatteverket mark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): label the bank period sum as netto

Jakob read 'Banktransaktioner i perioden 399 941 kr' as gross activity
(his is ~1,9 MSEK) and rightly asked why it was so low: the value is the
net movement (in - out), which is what the bridge compares against the
net booked movement on the ledger account. Verified against raw prod
data (237 rows, 1 169 126,40 in, -769 185,04 out = 399 941,36). The
tile and the bridge line now say '(netto)' / '(net)'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 14:08:53 +02:00
committed by GitHub
parent f40795896f
commit 150e2a3f14
43 changed files with 804 additions and 30 deletions
+1
View File
@@ -1180,3 +1180,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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<string, string | null>()
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,
+30 -7
View File
@@ -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 (
<AttnLine action={{ label: t('older_show'), onClick: () => void load() }}>
{t('load_failed')}
</AttnLine>
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
{rail}
<div className="min-w-0">
<AttnLine action={{ label: t('older_show'), onClick: () => void load() }}>
{t('load_failed')}
</AttnLine>
</div>
</div>
)
}
if (!status || !items) {
return (
<div className="space-y-6" aria-busy>
<div className="grid gap-8 lg:grid-cols-[220px_1fr]" aria-busy>
{rail}
<div className="min-w-0 space-y-6">
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-background p-4">
@@ -290,6 +299,7 @@ export function AccountOverview({ account, window, onChanged }: AccountOverviewP
</div>
<Skeleton className="h-4 w-72" />
<Skeleton className="h-40 w-full" />
</div>
</div>
)
}
@@ -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 (
<div className="space-y-6">
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
{rail}
<div className="min-w-0 space-y-6">
{/* Tiles: label + number, nothing else. */}
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border stagger-enter">
{tiles.map((tile) => (
@@ -472,6 +492,9 @@ export function AccountOverview({ account, window, onChanged }: AccountOverviewP
</p>
)}
</div>
</div>
{/* The table: full width, banded by bucket, paired proposal rows. */}
{items.items.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('all_clear')}</p>
@@ -180,19 +180,15 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re
return (
<div className="space-y-6">
{header}
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
<ReconciliationRail accounts={accounts} selectedKey={selected?.account_key ?? null} onSelect={select} />
<div className="min-w-0">
{selected && (
<AccountOverview
key={selected.account_key}
account={selected}
window={window}
onChanged={() => void load()}
/>
)}
</div>
</div>
{selected && (
<AccountOverview
key={selected.account_key}
account={selected}
rail={<ReconciliationRail accounts={accounts} selectedKey={selected.account_key} onSelect={select} />}
window={window}
onChanged={() => void load()}
/>
)}
</div>
)
}
@@ -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()
})
})
@@ -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<string, unknown> = {}) {
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<string, unknown>
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<Record<string, unknown>>
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<string, unknown>
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/)
})
})
@@ -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',
])
@@ -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.',
@@ -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) {
@@ -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 {
@@ -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:<cash_account_id>) 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',
},
}
},
}
@@ -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)`
@@ -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:<cash_account_id>\` 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=<account_key>\` 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' },
}
@@ -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',
+3
View File
@@ -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
})
+49 -1
View File
@@ -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()
})
})
+2
View File
@@ -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),
+57
View File
@@ -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<Notice | null> {
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,
)
}
}
+12
View File
@@ -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',
]
@@ -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)
}
})
})
@@ -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
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { skattekontoUnexplainedFrom, type SkattekontoReconciliationLatest } from '../skattekonto-latest'
function latest(overrides: Partial<SkattekontoReconciliationLatest> = {}): 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)
})
})
+40
View File
@@ -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<readonly [RegExp, string]> = [
[/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 | undefined>): 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
}
+21 -3
View File
@@ -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<ReturnType<typeof getBankReconciliationStatu
const lines: BridgeLine[] = [
{
key: 'bank_transactions',
label_sv: 'Banktransaktioner i perioden',
label_en: 'Bank transactions in the period',
label_sv: 'Banktransaktioner i perioden (netto)',
label_en: 'Bank transactions in the period (net)',
amount: status.bank_transaction_total,
count: null,
items_bucket: null,
@@ -252,6 +253,23 @@ export async function listReconciliationAccounts(
log.warn('sign-off read failed', { companyId, error: err instanceof Error ? err.message : String(err) })
}
// Bank logos resolve from the connection's bank_name (the same name the
// connect flow shows). A failed read only costs the logos.
const bankNameByConnection = new Map<string, string>()
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<ReconciliationAccount> => {
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,
+48
View File
@@ -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
}
@@ -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<string, string | null>([
['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<string, string | null>([
['a', 'not-a-date'],
['b', '2026-08-22T01:00:00Z'],
])
expect(orderByStalestSync(work, last).map((w) => w.companyId)).toEqual(['a', 'c', 'd', 'b'])
})
})
+21
View File
@@ -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<T extends { companyId: string }>(
work: readonly T[],
lastSyncedAtByCompany: ReadonlyMap<string, string | null | undefined>,
): 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)
}
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 B