8b09b06e14
* feat(skatteverket): ombudsregister grant verification, honest session expiry, daily ombud sync
Users reported the Skatteverket connection "just disappearing" with no
banner, needing BankID again every time. Two causes, both fixed here:
1. SKV's per-flow refresh token lives 65 minutes. /status and the
skv_disconnected notice called any stored refresh token "refreshable",
so a days-dead session reported healthy and the reconnect banner never
fired until a submission failed live. lib/skatteverket/session-lifetime
now decides refreshability (expires_at + 5 min, refresh cap) for both
surfaces; the settings panel states the one-hour session lifetime.
2. The durable fix is the ombud (system certificate) path, dormant since
July behind SKATTEVERKET_SYSTEM_AUTH_MODE. Skatteverket added scope
`obr` (Ombudshantering v2) to our application id on 2026-09-01, so grant
verification can now ask the ombudsregister instead of classifying 403s
from the read services:
- lib/ombud-client.ts: GET /ombud/autentisieratOmbud, GET /roller,
POST .../djuplank/utseombud, on the system identity, per the public
tjanstebeskrivning v2.0 (mirrored in dev_docs/skatteverket/ombudshantering).
Role codes are env-pinned (SKATTEVERKET_OMBUD_ROLL_LASOMBUD/_MOMS) or
matched on rollbeskrivning text; a deep link never mints with a
guessed code.
- grant-probe.ts: register first, read-service probes only as fallback.
- New daily cron /api/extensions/skatteverket/ombud/sync/cron (30 3 * * *):
one register call discovers every company that granted us, creates or
downgrades connection rows by org number, runs from shadow mode on,
and never mass-revokes on an empty register.
- POST /system-connection/deeplink + "Utse {app} som ombud" button:
the company lands in SKV's e-service with roles pre-selected.
- Default system scopes include `obr`; skvRequestWithAuth gains an
`accept` option (Ombudshantering requires the Accept header).
Still inert in prod until the org certificate and avtal land; the cron and
verify routes no-op while system auth is off or unconfigured.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
* fix(skatteverket): skeptic round on ombud sync, register 404 fallback, opt-in-only rows, mass-downgrade guard
Cron touches only existing connection rows (a tenant's own Verifiera or
deep-link opt-in; the deeplink route now records a pending row), so an
org-number twin never gets auto-verified, and rows the tenant revoked
locally stay revoked. A register 404 throws by default (spec: wrong URI)
and is empty only for the cron, which guards it. Decisions are planned
before any upsert; a run that would fully deny >= 3 rows and > 50% of the
granted ones applies no downgrade. Grants that classify as neither
behörighet are 'error', not 'denied'. Literal select in listConnections for
the phantom-column scanner. window.open without 'noopener' so the
pre-opened tab exists; opener nulled by hand. Deeplink route test added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
* fix(skatteverket): CodeRabbit round: exact role labels, paginate connections, deny never-listed rows, fail deeplink without opt-in row
Role descriptions match the whole label so 'Momsdeklaration,
deklarationsombud' is never read as the narrow moms role. listConnections
pages through fetchAllRows on (created_at, id). A pending row the register
never lists is written once as denied instead of staying 'Inte verifierad'.
The deeplink route returns 500 when the opt-in row cannot be stored, and the
panel navigates in-tab when the pre-opened tab was blocked.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
* fix(skatteverket): fence ombud grants on contested org numbers; cron honours unrecognised role codes
An org number claimed by more than one live company is contested: verify
and deep link answer 409 ORG_NUMBER_CONTESTED and the nightly sync changes
nothing on it, so a tenant that typed a victim's public org number cannot
inherit the victim's grant. The sync also skips huvudmän whose register
roles classify as neither behörighet (pinning problem, never a denial),
mirroring probeViaOmbudsregister.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
* fix(skatteverket): validate the ombud deep link host; withdraw grants on contested org numbers
The register's djuplank must be an https skatteverket.se URL before it is
returned or navigated to (the settings page follows it). The nightly sync
now withdraws a grant already recorded on an org number that more than one
live company claims, instead of only refusing new ones.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
403 lines
15 KiB
TypeScript
403 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 { isSkvSessionRefreshable } from '@/lib/skatteverket/session-lifetime'
|
|
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()
|
|
// Same rule as the extension's /status route: a refresh token past its
|
|
// 65-minute life does not count as refreshable (lib/skatteverket/session-lifetime).
|
|
const canRefresh = isSkvSessionRefreshable(
|
|
{
|
|
expiresAt,
|
|
hasRefreshToken: data.refresh_token !== null,
|
|
refreshCount: data.refresh_count as number | null,
|
|
},
|
|
now,
|
|
)
|
|
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,
|
|
)
|
|
}
|
|
}
|