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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
f40795896f
commit
150e2a3f14
@@ -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
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user