fix(skatteverket): show a banner when the connection is dead (#1925)

* fix(skatteverket): make a dead Skatteverket connection impossible to miss

A company whose SKV session had expired got no usable signal anywhere.
/skattekonto/saldo answers 200 for a token already flagged needs_reconsent
(it keeps the stale snapshot visible on purpose), so the page looked healthy
on load, and the reconnect AttnLine lived inside the `data` branch: a company
that had never completed a sync had no snapshot, so clicking "Synkronisera nu"
set the reconnect message and rendered absolutely nothing. The 401 branch
returns before the toast, so the click was a silent no-op. On /transactions
the same banner was gated on the 'skatteverket' source filter, which most
users never select.

Skattekonto page:
- probe /status on load through the shared skvStatusNeedsReconnect predicate,
  so the prompt appears before the user clicks anything
- render the reconnect line at page level, regardless of `data`
- swap the "Synkronisera nu" header action for "Anslut igen": the sync cannot
  succeed while the session is dead, and offering it is what made the failure
  look like nothing happening
- give the expired case its own StartCard instead of the onboarding one, which
  reads as "you never set this up" to someone who did
- drop the "klicka pa Synkronisera nu" hint while a reconnect is pending

Transactions page: show the reconnect line on every source filter.

Max one attn line per page is preserved: reconnect outranks the imported-rows
line and the saldo shortfall, the latter because a shortfall computed from a
stale snapshot must not outrank "the data is stale".

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

* fix(skatteverket): show a banner when the connection is dead

Cut back to a banner and nothing else, after three skeptics refuted the
first cut.

/skattekonto: the reconnect line lived inside the `data` branch of the
saldo section, so a company with no snapshot (never completed a sync) saw
nothing at all when the sync it just clicked died on an expired session.
The 401 path returns before the toast, so the click was a silent no-op.
Move the line to page level and probe /status on load so it also appears
without clicking, since /skattekonto/saldo answers 200 for a token flagged
needs_reconsent and nothing else in the payload reveals a dead session.

/transactions: show the same line on every source filter.

Nothing is hidden or removed while the flag is true: the saldo tiles, the
Kronofogden line, "Synkronisera nu", the empty state and the shortfall
warning all stay exactly as they were. Prod has 151 of 162 connected
companies with every token dead, so a flag that hides anything hides it
from nearly everyone, and the shortfall warning's action is the page's
only route to the bankgiro and OCR.

Also: a healthy probe clears a message left by an earlier failed sync
(previously only syncNow's success path did, so a transient 401 kept the
banner up until a remount), and the existing visibility refetch now covers
the reconnect state so the banner does not survive the consent that fixed
it.

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

* fix(skatteverket): guard status probe against stale responses

A fire-and-forget /status probe from an earlier reload could resolve after
a later one and overwrite needsReconnect with an outdated result, restoring
the reconnect banner right after a successful sync. Each reload now bumps a
probe sequence and a result is applied only while it is still the latest.

Also puts the 2026-08-26 decision record in the required
[date] decision: why format.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-26 15:56:25 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4af7469523
commit dcb5d329a3
5 changed files with 91 additions and 26 deletions
+79 -22
View File
@@ -44,7 +44,11 @@ import {
} from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { rowsNeedingInterestDate } from '@/lib/skatteverket/interest-period'
import { skvAuthErrorNeedsReconnect } from '@/lib/notices/predicates'
import {
skvAuthErrorNeedsReconnect,
skvStatusNeedsReconnect,
type SkvStatusLike,
} from '@/lib/notices/predicates'
import {
AlertCircle,
Copy,
@@ -108,10 +112,11 @@ export default function SkattekontoPage() {
)
const [notConnected, setNotConnected] = useState(false)
const [loadError, setLoadError] = useState(false)
// Set when a sync fails with an auth error while a connection exists
// (expired session, missing scope, revoked token). Rendered as a banner —
// the stored data below stays visible and usable.
// Reason string from a failed call; the flag is the same state found
// proactively by the /status probe. Two pieces so reload() stays
// dependency-free. The banner renders on either.
const [reconnectMessage, setReconnectMessage] = useState<string | null>(null)
const [needsReconnect, setNeedsReconnect] = useState(false)
const [matchOpenFor, setMatchOpenFor] = useState<StoredSkattekontoTransaction | null>(
null,
)
@@ -124,9 +129,43 @@ export default function SkattekontoPage() {
const { dialogProps: ignoreConfirmProps, confirm: confirmIgnore } =
useDestructiveConfirm()
// The /status probe is fire-and-forget, so a slow response from an earlier
// reload can land after a later one and overwrite the fresher banner state.
// Each reload bumps the sequence; a probe only applies its result while it
// is still the latest.
const statusProbeSeqRef = useRef(0)
const reload = useCallback(async () => {
setLoading(true)
setLoadError(false)
// /skattekonto/saldo answers 200 for a token flagged needs_reconsent (it
// keeps the stale snapshot visible on purpose), so nothing in the payload
// below reveals a dead session. Probe /status for it. Any failure just
// leaves the banner off.
const probeSeq = ++statusProbeSeqRef.current
void (async () => {
try {
const res = await fetch('/api/extensions/ext/skatteverket/status')
if (probeSeq !== statusProbeSeqRef.current) return
if (!res.ok) {
setNeedsReconnect(false)
return
}
const s = (await res.json()) as SkvStatusLike
if (probeSeq !== statusProbeSeqRef.current) return
// Shared reconnect predicate (lib/notices): the same decision the
// transactions page and the Hem notice make, never a local variant.
const stale = skvStatusNeedsReconnect(s)
setNeedsReconnect(stale)
// Also clears a message left by an earlier failed sync: otherwise only
// syncNow's success path ever cleared it, so a transient 401 kept the
// banner up until a full remount.
if (!stale) setReconnectMessage(null)
} catch {
if (probeSeq !== statusProbeSeqRef.current) return
setNeedsReconnect(false)
}
})()
try {
const [saldoRes, txRes] = await Promise.all([
fetch('/api/extensions/ext/skatteverket/skattekonto/saldo'),
@@ -178,18 +217,18 @@ export default function SkattekontoPage() {
// Auto-recover from the "inte anslutet" empty state when the user returns
// to this tab: the connect flow lives in Inställningar (often completed in
// another tab or after a mobile BankID app-switch), so no in-window signal
// can reach this page. Only fires while notConnected is showing: a routine
// tab switch on a healthy page must not flash the loading state. Throttled
// so rapid tab toggling doesn't hammer the API.
const notConnectedRef = useRef(false)
// can reach this page. Also fires while the reconnect banner is showing:
// same journey, and otherwise the banner survives the consent that fixed
// it. Throttled so rapid tab toggling doesn't hammer the API.
const staleConnectionRef = useRef(false)
useEffect(() => {
notConnectedRef.current = notConnected
}, [notConnected])
staleConnectionRef.current = notConnected || needsReconnect || reconnectMessage !== null
}, [notConnected, needsReconnect, reconnectMessage])
const lastVisibilityReloadRef = useRef(0)
useEffect(() => {
function onVisible() {
if (document.visibilityState !== 'visible') return
if (!notConnectedRef.current) return
if (!staleConnectionRef.current) return
const now = Date.now()
if (now - lastVisibilityReloadRef.current < 5_000) return
lastVisibilityReloadRef.current = now
@@ -220,7 +259,7 @@ export default function SkattekontoPage() {
setReconnectMessage(
typeof json.error === 'string' && json.error
? json.error
: 'Anslutningen mot Skatteverket behöver förnyas. Anslut igen med BankID.',
: t('attn_reconnect_body'),
)
} else {
setNotConnected(true)
@@ -238,6 +277,7 @@ export default function SkattekontoPage() {
return
}
setReconnectMessage(null)
setNeedsReconnect(false)
toast({
title: 'Skattekonto synkroniserat',
description: `${json.data.booked} bokförda, ${json.data.upcoming} kommande`,
@@ -479,6 +519,13 @@ export default function SkattekontoPage() {
tx !== null &&
tx.booked.length + tx.overdue.length + tx.upcoming.length + tx.ignored_count > 0
// Drives ONE ochre line and nothing else: nothing on this page is hidden or
// removed while it is true. needs_reconsent is the resting state for the
// personal-token cohort (93% of connected companies on 2026-08-26), so
// anything that HIDES on this flag hides from nearly everyone.
const showReconnect = needsReconnect || reconnectMessage !== null
const reconnectBody = reconnectMessage ?? t('attn_reconnect_body')
if (notConnected && !hasLocalRows) {
return (
<div className="space-y-8">
@@ -548,16 +595,25 @@ export default function SkattekontoPage() {
}
/>
{/* File-imported rows without a connection: no saldo to show, but the
booking/matching flows below work on the local table. One ochre
sentence with the connect action, per the attn convention. */}
{notConnected && (
{/* Page level, not nested in the saldo section: the line used to live
inside the `data` branch below, so a company with no snapshot got no
feedback at all when the sync it just clicked died. Coexists with the
shortfall line under convention 6's 2026-08-19 addendum (one
lib/notices notice plus one page-domain attn line). */}
{showReconnect ? (
<AttnLine action={{ label: t('attn_reconnect_action'), href: '/settings/tax' }}>
{reconnectBody}
</AttnLine>
) : notConnected ? (
// File-imported rows without a connection: no saldo to show, but the
// booking/matching flows below work on the local table. One ochre
// sentence with the connect action, per the attn convention.
<AttnLine
action={{ label: tStart('skattekonto_primary'), href: '/settings/tax' }}
>
{t('imported_not_connected_attn')}
</AttnLine>
)}
) : null}
{/* Saldo as compact stat tiles (house metric-card idiom, KPIHeroCards).
Hidden entirely for unconnected companies rendering imported rows:
@@ -645,11 +701,12 @@ export default function SkattekontoPage() {
</div>
</div>
{reconnectMessage ? (
<AttnLine action={{ label: t('attn_reconnect_action'), href: '/settings/tax' }}>
{reconnectMessage}
</AttnLine>
) : shortfall !== null && nextCharge ? (
{/* The reconnect line moved to page level; this slot keeps the
shortfall notice, which needs `data`. Deliberately still shown
while a reconnect is pending: it is the warning that prevents
kostnadsränta, and its action is this page's only route to the
bankgiro and OCR. */}
{shortfall !== null && nextCharge ? (
<AttnLine
action={{ label: t('attn_show_payment'), onClick: () => setShowPayment(true) }}
>
+9 -4
View File
@@ -3600,10 +3600,15 @@ export default function TransactionsPage() {
<TransactionStatusBar onOpenCreateDialog={() => setIsDialogOpen(true)} />
{skvNeedsReconnect && effectiveSourceFilter === 'skatteverket' ? (
// Only when the user is actually looking at skattekonto rows: as a
// permanent page-wide line it read as noise (feedback 2026-08-14).
// The skattekonto page keeps its own reconnect line.
{skvNeedsReconnect ? (
// Shown on every source filter, not just 'skatteverket'. The original
// gate (feedback 2026-08-14) predates the deletion of the
// connection-expired email (DECISIONS 2026-08-25), which left the
// banner as the ONLY proactive channel a web-only user has for a dead
// Skatteverket connection. A filter most users never select is not a
// channel, and hiding it is what let a dead connection sit unnoticed
// for days. The line is not noise: it renders only while the
// connection is actually broken and disappears the moment it works.
<AttnLine action={{ label: t('skv_reconnect_cta'), href: '/settings/tax' }}>
{t('skv_reconnect_body')}
</AttnLine>