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:
@@ -1254,6 +1254,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR.
|
||||
[2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay.
|
||||
[2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published.
|
||||
[2026-08-26] A dead Skatteverket connection gets ONE ochre line, on /skattekonto (page level, no longer nested in the saldo branch where a company with no snapshot never saw it) and on /transactions (all source filters, reversing the 2026-08-14 filter gate): deliberately a banner and nothing else: no page takeover, no removed sync button, nothing hidden. Prod on this date has 151 of 162 connected companies with every token dead (97 of the 101 tokens touched in 30 days are needs_reconsent), because SKV personal sessions live 65 minutes and the nightly cron flags the row the next morning. At that rate a flag that HIDES anything hides it from nearly everyone, so the first cut of this change (reconnect StartCard replacing the page, sync button swapped for a reconnect link, shortfall and empty-state suppressed) was rejected by all three skeptics: it would have removed the saldo, the Kronofogden line and the page's only route to the bankgiro and OCR at exactly the moment the account is short. The banner is additive and stays honest at 93%. Known and accepted: /status is caller-scoped while sync is company-scoped (resolve-auth prefers a live row), so in a multi-member company a member whose own token died sees the line while the company can still sync. Left alone because prod has 3 such companies and the banner hides nothing; fix it by adding a company-scoped field to /status if that ever changes.
|
||||
[2026-08-26] Reference data (fiscal periods, cash accounts, settings, accounts, dimensions, templates, customers, suppliers, articles) moves behind SWR hooks in lib/reference-data with company-scoped keys, a server seed from the dashboard layout (periods, cash accounts, settings only; the chart of accounts can be hundreds of KB and is warmed lazily instead), explicit invalidateReferenceData() after writes, and a raw-reference-fetch ratchet in check:guards. Chosen over Cache-Control on the API routes (a browser HTTP cache would keep serving stale bodies after a mutate) and over Next 16 cacheComponents/partialPrefetching (38 of 81 dashboard pages are client components whose data lives in client fetches, so an app shell prefetch cannot carry it). Expected side effect: period.list and settings.get volume in the op-completed logs drops toward zero because those reads become browser-side Supabase selects; that is by design, not a broken route.
|
||||
[2026-08-26] AGENTS.md now defers to CLAUDE.md for every shared rule instead of duplicating it: the copy had drifted within weeks (no inline-rättelse path, cookie-first tenancy order, 100+ MCP tools). The four Codex-only constraints Emil added 2026-07-21 (erp-base staging-only migrations, prod writes and main pushes need his explicit approval, no local Docker) were kept in a labelled section rather than removed, because the erp-base Supabase project exists (ref pwxtzglxptnnvjrpixpg) and they describe his environment, not stale product facts; only the project-name spelling was corrected from erpbase.
|
||||
[2026-08-26] MCP tool counts in docs say "150+" (connect-claude, READMEs, rules, registry entry) instead of deriving the number from the tools array: lib/docs/content/connect-claude.ts is core code and core must never import from @/extensions/ (CI builds core with zero extensions), and an exact hand-written number (90+, 100+, 120) had drifted three times already. Regenerate by counting `name: 'gnubok_` in server.ts when the order of magnitude changes.
|
||||
|
||||
@@ -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) }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8059,6 +8059,7 @@
|
||||
"import_statement_action": "Import statement",
|
||||
"imported_not_connected_attn": "Showing imported account events. Connect Skatteverket for automatic sync and balance.",
|
||||
"attn_reconnect_action": "Reconnect",
|
||||
"attn_reconnect_body": "Your Skatteverket connection has expired. Reconnect with BankID to fetch the balance and events.",
|
||||
"attn_shortfall": "The charge on {date} is {charge}. The balance is {missing} short.",
|
||||
"attn_show_payment": "Show payment details",
|
||||
"band_upcoming": "Upcoming",
|
||||
|
||||
@@ -8059,6 +8059,7 @@
|
||||
"import_statement_action": "Importera kontoutdrag",
|
||||
"imported_not_connected_attn": "Visar importerade kontohändelser. Anslut Skatteverket för automatisk synk och saldo.",
|
||||
"attn_reconnect_action": "Anslut igen",
|
||||
"attn_reconnect_body": "Anslutningen mot Skatteverket har gått ut. Anslut igen med BankID för att hämta saldo och händelser.",
|
||||
"attn_shortfall": "Dragningen {date} är {charge}. Saldot saknar {missing}.",
|
||||
"attn_show_payment": "Visa betalningsuppgifter",
|
||||
"band_upcoming": "Kommande",
|
||||
|
||||
Reference in New Issue
Block a user