Files
accounted/lib/notices/categories.ts
T
150e2a3f14 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>
2026-08-24 14:08:53 +02:00

393 lines
15 KiB
TypeScript

/**
* Per-category notice predicates: the single owner of every degraded-state
* detection. Surfaces (Hem, /api/notices, per-page attn lines) must call
* these, or the exported pure helpers, instead of inlining their own checks;
* see lib/notices/types.ts for each category's pending/done definition.
*
* Every predicate soft-fails to null with a logged error: a broken health
* check must never take down the dashboard or the home page.
*/
import { createHash } from 'node:crypto'
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'
// The pure decision layer lives in ./predicates (client-safe: 'use client'
// pages import it directly, since this file pulls in server-only modules).
// Re-exported here so server code has one import path for the whole domain.
export * from './predicates'
const log = createLogger('notices')
function logAndNull(
category: string,
companyId: string,
error: { message?: string } | null,
): null {
// companyId is a structured field so repeated failures can be correlated
// to a tenant in monitoring (mirrors lib/worklist logAndZero).
log.error(`notice predicate failed: ${category}`, { companyId, reason: error?.message })
return null
}
/**
* Bound a multi-part id discriminator. A single part stays human-readable
* (a connection id plus its status/expiry); several parts, each embedding a
* uuid, collapse to `<count>@<first 8 hex of sha256 over the sorted parts>`,
* so the id stays far below the dismiss schema cap (lib/api/schemas.ts) no
* matter how many connections fold into one notice, and is stable across
* row orderings. Server-only (node:crypto), which this module already is.
*/
function boundedDiscriminator(parts: string[]): string {
if (parts.length === 1) return parts[0]
const digest = createHash('sha256')
.update([...parts].sort().join(','))
.digest('hex')
.slice(0, 8)
return `${parts.length}@${digest}`
}
// ── Predicates (one query each; soft-fail to null) ──
/**
* bank_connection_broken: connections whose status is already terminal
* ('expired' | 'error'): same predicate as BankSyncStatusChip's "attention"
* state. Disjoint from bank_connection_expiring by the status filter.
*/
export async function detectBrokenBankConnections(
supabase: SupabaseClient,
companyId: string,
): Promise<Notice | null> {
try {
const { data, error } = await supabase
.from('bank_connections')
.select('id, status, bank_name')
.eq('company_id', companyId)
.in('status', ['expired', 'error'])
if (error) return logAndNull('bank_connection_broken', companyId, error)
const rows = data ?? []
if (rows.length === 0) return null
const discriminator = boundedDiscriminator(rows.map((r) => `${r.id}=${r.status}`))
// A NULL bank_name switches to the unnamed message variant instead of
// interpolating a fallback word, which would leak Swedish into English.
const bankName = rows.length === 1 ? ((rows[0].bank_name as string | null) || null) : null
return {
id: `bank_connection_broken:${discriminator}`,
category: 'bank_connection_broken',
severity: 'error',
messageKey:
rows.length === 1
? bankName
? 'bank_broken_one'
: 'bank_broken_one_unnamed'
: 'bank_broken_many',
messageParams:
rows.length === 1
? bankName
? { bank: bankName }
: undefined
: { count: rows.length },
actionKey: 'bank_broken_action',
actionHref: '/settings/banking',
}
} catch (err) {
return logAndNull(
'bank_connection_broken',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* bank_connection_expiring: active connections whose PSD2 consent runs out
* within 14 days. Only status = 'active' rows are considered, so a
* connection that has ALREADY failed never shows as both broken and
* expiring (broken supersedes expiring for the same connection).
*/
export async function detectExpiringBankConnections(
supabase: SupabaseClient,
companyId: string,
now: Date = new Date(),
): Promise<Notice | null> {
try {
const { data, error } = await supabase
.from('bank_connections')
.select('id, bank_name, consent_expires')
.eq('company_id', companyId)
.eq('status', 'active')
.not('consent_expires', 'is', null)
if (error) return logAndNull('bank_connection_expiring', companyId, error)
const expiring = expiringBankConnectionsFrom(data ?? [], now)
if (expiring.length === 0) return null
// The consent date, not days_left, discriminates the id: the countdown
// ticking from 14 to 13 days must not resurrect a dismissed notice.
const consentByid = new Map(
(data ?? []).map((r) => [r.id as string, r.consent_expires as string | null]),
)
const discriminator = boundedDiscriminator(
expiring.map((c) => `${c.id}=${consentByid.get(c.id) ?? ''}`),
)
return {
id: `bank_connection_expiring:${discriminator}`,
category: 'bank_connection_expiring',
severity: 'warning',
messageKey: expiring.length === 1 ? 'bank_expiring_one' : 'bank_expiring_many',
messageParams:
expiring.length === 1
? { bank: expiring[0].bank_name, days: expiring[0].days_left }
: { count: expiring.length },
actionKey: 'bank_expiring_action',
actionHref: '/settings/banking',
}
} catch (err) {
return logAndNull(
'bank_connection_expiring',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* skv_disconnected: a stored Skatteverket connection that can no longer
* authenticate. Mirrors the skatteverket extension's /status route exactly
* (needs_reconsent flag, or expired with no usable refresh token) and runs
* the shared skvStatusNeedsReconnect decision over the row. Connections are
* per (user, company), so the predicate needs the caller's user id.
* Refresh-token ciphertext is read only for a null check and never returned.
*/
export async function detectSkvDisconnected(
supabase: SupabaseClient,
userId: string,
companyId: string,
now: Date = new Date(),
): Promise<Notice | null> {
try {
if ((process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true') return null
const { data, error } = await supabase
.from('skatteverket_tokens')
.select('status, expires_at, refresh_token, refresh_count, last_error_at')
.eq('user_id', userId)
.eq('company_id', companyId)
.maybeSingle()
if (error) return logAndNull('skv_disconnected', companyId, error)
if (!data) return null
const status = (data.status as string | null) ?? 'active'
const expiresAt = data.expires_at as string | null
const expired = expiresAt !== null && new Date(expiresAt).getTime() < now.getTime()
const canRefresh = data.refresh_token !== null && ((data.refresh_count as number | null) ?? 0) < 10
const needsReconsent = status === 'needs_reconsent'
if (!skvStatusNeedsReconnect({ connected: true, needsReconsent, expired, canRefresh })) {
return null
}
// needs_reconsent rows discriminate on when the terminal error was
// detected; refresh-exhausted rows on when the token expired: either way
// a NEW failure after a successful re-consent mints a new id.
const discriminator = needsReconsent
? `needs_reconsent@${(data.last_error_at as string | null) ?? ''}`
: `expired@${expiresAt ?? ''}`
return {
id: `skv_disconnected:${discriminator}`,
category: 'skv_disconnected',
severity: 'error',
messageKey: 'skv_disconnected',
actionKey: 'skv_disconnected_action',
actionHref: '/settings/tax',
}
} catch (err) {
return logAndNull(
'skv_disconnected',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/** Brand names stay untranslated; the sentence around them is localised. */
const BACKUP_PROVIDER_LABELS: Record<string, string> = {
google_drive: 'Google Drive',
dropbox: 'Dropbox',
}
interface BackupConnectionValue {
status?: 'active' | 'needs_reauth'
needs_reauth_at?: string
}
interface BackupScheduleValue {
last_auto_sync_status?: 'success' | 'error' | null
last_auto_sync_at?: string | null
}
/**
* backup_failing: a connected cloud-backup destination with a dead token or
* an errored last auto-sync. Reads the cloud-backup extension's rows in
* extension_data directly (core must not import from @/extensions/, so the
* keys and value shapes are mirrored here, same as the old
* BackupHealthBanner mirrored the status API's shape). Multiple failing
* providers fold into ONE notice: a working Drive backup does not make a
* broken Dropbox backup acceptable, but two failures must not stack two
* lines either.
*/
export async function detectBackupFailing(
supabase: SupabaseClient,
companyId: string,
): Promise<Notice | null> {
try {
if (!ENABLED_EXTENSION_IDS.has('cloud-backup')) return null
const { data, error } = await supabase
.from('extension_data')
.select('key, value')
.eq('company_id', companyId)
.eq('extension_id', 'cloud-backup')
.in('key', [
'google_drive_connection',
'google_drive_schedule',
'dropbox_connection',
'dropbox_schedule',
])
if (error) return logAndNull('backup_failing', companyId, error)
const byKey = new Map((data ?? []).map((r) => [r.key as string, r.value]))
const failing: { provider: string; reason: 'reauth' | 'sync_error' }[] = []
for (const provider of ['google_drive', 'dropbox']) {
const connection = byKey.get(`${provider}_connection`) as BackupConnectionValue | undefined
if (!connection) continue
const schedule = byKey.get(`${provider}_schedule`) as BackupScheduleValue | undefined
if (connection.status === 'needs_reauth') {
failing.push({ provider, reason: 'reauth' })
} else if (schedule?.last_auto_sync_status === 'error') {
failing.push({ provider, reason: 'sync_error' })
}
}
if (failing.length === 0) return null
const names = failing
.map((f) => BACKUP_PROVIDER_LABELS[f.provider] ?? f.provider)
.join(' + ')
const allNeedReauth = failing.every((f) => f.reason === 'reauth')
// Deliberately NO timestamp in the discriminator: the cron re-stamps
// last_auto_sync_at on every failed run (and can re-stamp needs_reauth_at
// on retries), which would resurrect a dismissed notice daily while the
// SAME incident persists. The id is stable per (provider, reason) and the
// opposite direction (a NEW failure after a healthy spell must resurface)
// is guaranteed by the stale-dismissal reaping in aggregate.ts; contract
// in lib/notices/types.ts.
const discriminator = failing
.map((f) => `${f.provider}=${f.reason}`)
.sort()
.join(',')
return {
id: `backup_failing:${discriminator}`,
category: 'backup_failing',
severity: 'error',
messageKey: allNeedReauth ? 'backup_reauth' : 'backup_failing',
messageParams: { provider: names },
actionKey: 'backup_action',
actionHref: '/import#cloud-backup',
}
} catch (err) {
return logAndNull(
'backup_failing',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* other_account_hint: the wrong-login nudge (#1231). Delegates the detection
* to lib/company/other-account-hint (which already fails soft to false); this
* wrapper only shapes it as the lowest-priority notice. The id carries no
* state discriminator: the condition is "this account is empty while another
* holds the bookkeeping", which either holds or stops holding.
*/
export async function detectOtherAccountHint(
supabase: SupabaseClient,
companyId: string,
): Promise<Notice | null> {
try {
const show = await shouldShowOtherAccountHint(supabase)
if (!show) return null
return {
id: 'other_account_hint',
category: 'other_account_hint',
severity: 'warning',
messageKey: 'other_account_hint',
actionKey: 'other_account_hint_action',
// Client surfaces override this with a sign-out handler; the href is
// the no-JS fallback destination.
actionHref: '/login',
}
} catch (err) {
return logAndNull(
'other_account_hint',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* 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,
)
}
}