Bug/skv auth (#1010)
* fix(settings): open Skatteverket connect in a popup and fix the skahmst scope check The full-page OAuth round-trip left Skatteverket's pages and the consumed callback URL in browser history directly beneath /settings/tax, so closing settings walked Back into a dead OAuth chain and re-prompted BankID auth. The connect buttons now use the AGIPanel popup + postMessage pattern (the callback already supports window.opener); the page never navigates and the panel refetches status on success. Full-page navigation remains only as the popup-blocked fallback. Also fixes the reconnect-button condition: it checked for a scope literally named 'skattekonto', but SKV grants 'skahmst', which kept "Anslut igen" permanently visible on healthy connections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(skatteverket): extract per-declaration AGI kvittens reconciliation Behavior-identical extraction of the kvittens cron's core (fetch kvittens, promote declaration to submitted, stamp salary_runs, clear cached submission, complete deadline, notify) into lib/agi-kvittens-reconcile.ts so the upcoming post-connect refresh can reuse it. Auth-error mapping (needs_reconsent, grant revocation, APIGW config gaps) and run-level logging stay in the cron, which is why SkatteverketAuthError propagates out of the helper on purpose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): auto-settle production-format AGI skattekonto draws The settlement matcher only understood the SKV test environment's single combined row ("Arbetsgivardeklaration YYYYMM"). Production books the draw as two rows with Swedish month names: "Avdragen skatt maj 2026" (= total_tax) and "Arbetsgivaravgift maj 2026" (= total_avgifter), so auto-settlement has never fired against production data and the salary page kept showing paid periods as unpaid. parseAgiPeriod now also reads the month-name form (which additionally lets match suggestions resolve the period on prod rows, including beslut rows). Settlement classifies draws with start-anchored regexes so correction rows like "Beslut 260703 arbetsgivaravgift mars 2026" can never qualify, and settles a period on either the combined row matching the whole declared amount or exactly one tax row + exactly one avgift row matching their respective totals to the ore, dated to the later of the pair. Anything non-exact (partial draws, duplicates) still falls back to the manual mark-paid button. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skatteverket): refresh skattekonto + kvittenser right after OAuth consent Nothing fetched Skatteverket data after a (re)connect: tokens were stored and the user redirected, with the next fetch left to the nightly cron. SKV's per-flow tokens (and refresh tokens) live ~65 minutes, so the crons usually find them dead; right after consent is the one reliable window for a personal-token fetch, which is why reconnecting never made stale numbers recover. The callback now awaits runPostConnectRefresh after storing tokens: a skattekonto sync (upsert + auto-settlement + balance snapshot) plus a kvittens re-check for this company's pending_signature AGI declarations. Awaited on purpose so popup-close means the data is already fresh and UI refetch listeners never race a background job; every step is best-effort and a refresh failure can never fail the connect that just succeeded. The callback's non-popup fallback also switches to window.location.replace so the consumed callback URL (one-shot code + state) drops out of history instead of re-running into a guaranteed CSRF error on Back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): reload the salary dashboard after a Skatteverket reconnect Listens for the BankID popup's skatteverket-oauth-success message and re-runs load(). Because the OAuth callback awaits the skattekonto sync and AGI auto-settlement before responding, the refetch already sees settled tax-payment state: the "Skatt att betala" card flips to paid the moment the popup closes, without leaving the page. Also logs the three decisions behind this series in DECISIONS.md (awaited post-connect refresh over after(), exact-pair settlement over per-period summing, popup flow over in-place history repair). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): verify OAuth popup source identity before trusting postMessage The three 'skatteverket-oauth-success' listeners accepted any same-origin message, letting any same-origin script trigger a data reload or a fake success toast (OWASP ASVS V3.2, SOC 2 CC6.1). The two components that open the popup now keep its handle in a ref and require event.source to be that exact window; a window reference cannot be forged by same-origin scripts, which is strictly stronger than a nonce threaded through the OAuth flow. The salary dashboard never opens the popup, so its raw message listener is replaced by a 'skatteverket-connection-updated' CustomEvent dispatched only after a component has source-verified the popup (and after disconnect, so connection-state consumers stay in sync). Also extends the connect consent copy (sv + en) to disclose that connecting immediately fetches skattekonto data and checks pending AGI receipts (GDPR Art.5(1)(b) transparency). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): CSP nonce and no-store on OAuth callback, deadline on post-connect refresh Both callback HTML responses now carry a per-response nonce CSP (default-src 'none'; script-src 'nonce-...'; base-uri 'none'; form-action 'none') so injected markup could never execute, plus Cache-Control: no-store because the callback URL carries a one-shot authorization code. The jsLiteral/appUrl injection invariants are documented at the definition site. The awaited post-connect refresh is bounded by a 30-second Promise.race deadline so a hung SKV call cannot hold the OAuth callback open; on timeout the refresh continues best-effort and the user still gets the success response. Refresh failures and timeouts now log through the structured logger with companyId + userId so they are attributable in log aggregation (ASVS V16, ISO 27001 A.8.15, SOC 2 CC8.1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): tenant guard and redacting logs in kvittens reconcile, slim cron response The agi_declarations update now also filters on company_id (ASVS V8.2.1), matching the salary_runs pattern. The reconciler and the cron's error paths log through the structured logger so third-party error strings pass personnummer redaction; uuidKvittens is dropped from log context (GDPR Art.5(1)(f) minimization, declarationId suffices). response_data gains submittedAtEstimated so the signeradTid fallback can never be mistaken for the legal filing time, and the submitted_by comment now states explicitly that it records the technical submitter while response_data.signeradAv is the authoritative legal signatory (BFL 5 kap 6 par, BFNAR 2013:2 kap 8). The cron HTTP response omits companyId per result row and sends Cache-Control: no-store; the extension_data delete documents why the period-scoped cache key needs no declaration-id guard. Cron tests observe the logger via a mock without weakening any assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): diagnosable settlement refusals and signeradAv ROPA documentation Settlement refusals (candidates present but amounts mismatch) now emit a structured info log with declared-vs-drawn ore amounts per kind, so a rounding divergence between stored declaration totals and SKV's actual draw is diagnosable instead of silently falling back to the manual button. No transaction texts are logged (they can carry personal data). parseAgiPeriod documents the beslut-row audit: correction rows parse to their period on purpose for match-suggestion boosting; settlement never uses parseAgiPeriod (anchored classifiers + parseNumericAgiPeriod only) and the only callers require an exact amount+side 1630 match first. .compliance/ropa.yaml documents signeradAv (signer personnummer in the SKV kvittens stored in agi_declarations.response_data): lawful basis Art.6(1)(c) via BFL 5 kap 6 par / BFNAR 2013:2 kap 8, 7-year retention per BFL 7 kap 2 par, access via company-membership RLS. DECISIONS.md records the accepted-with-documentation calls from the compliance review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): add 'already_claimed' status to reconcile outcomes and enhance logging for pending lookups --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f04dc4c4e0
commit
aab7e47c35
+10
-3
@@ -13,15 +13,21 @@ processing_activities:
|
||||
purpose: >-
|
||||
Lämna in lagstadgad arbetsgivardeklaration på individnivå (AGI) till
|
||||
Skatteverket varje månad (Skatteförfarandelagen 26 kap.). Innefattar
|
||||
huvuduppgift (HU) och individuppgifter (IU) per anställd.
|
||||
huvuduppgift (HU) och individuppgifter (IU) per anställd. Skatteverkets
|
||||
kvittens sparas i response_data inklusive signeradAv: personnummer för
|
||||
den som BankID-signerade inlämningen. Ändamålet är bevis för vem som
|
||||
juridiskt undertecknade deklarationen samt behandlingshistorik; BFL
|
||||
5 kap. 6 § och BFNAR 2013:2 kap. 8 kräver att räkenskapsinformationen
|
||||
bevarar den faktiska ingivaren, medan själva inlämningen styrs av SFL.
|
||||
lawful_basis: art_6_1_c # legal obligation
|
||||
special_category_basis: null
|
||||
controller: gnubok-tenant
|
||||
processor: anthropic-na # software supplier; Skatteverket is recipient, not processor
|
||||
data_subjects:
|
||||
- employee
|
||||
- company_signatory # firmatecknare/deklarationsombud (BankID-signerare av kvittensen)
|
||||
data_categories:
|
||||
- user.government_id # personnummer
|
||||
- user.government_id # personnummer (anställda i IU; signerarens i response_data.signeradAv)
|
||||
- user.name
|
||||
- user.financial.employment # gross salary, tax withheld, benefits
|
||||
- user.financial.tax # avgifter, sjuklönekostnad
|
||||
@@ -37,10 +43,11 @@ processing_activities:
|
||||
not applicable; no third-country transfer.
|
||||
retention:
|
||||
duration: 7y
|
||||
basis: bfl_7_kap # BFL 7 kap.: räkenskapsinformation
|
||||
basis: bfl_7_kap # BFL 7 kap. 2 §: räkenskapsinformation
|
||||
stored_in:
|
||||
- agi_declarations.xml_content
|
||||
- agi_declarations.individuppgifter
|
||||
- agi_declarations.response_data # SKV kvittens incl. signeradAv (signerarens pnr); access via company-membership RLS
|
||||
- skatteverket_api_audit_log
|
||||
security_measures:
|
||||
- encryption_at_rest_supabase
|
||||
|
||||
@@ -109,3 +109,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-12] Kvittens email dedup: notification_log row is now inserted FIRST as an atomic claim (partial unique index 20260712113000 on user_id+reference_id where notification_type = 'skv_kvittens'; 23505 = already claimed, claim released on send failure), and non-uuid reference ids (the VAT cron's composite key) are mapped to a deterministic SHA-256-derived uuid inside kvittens-notification.ts: reference_id is a uuid column, so the old string key silently failed both the dedup select and the insert (22P02); normalizing in-module beats widening the shared column to text or changing the cron's key formula.
|
||||
[2026-07-12] applyPaymentLinkToInvoice (shared send-route payment-link helper) lives in lib/extensions/payment-links.ts, not extensions/general/stripe/lib/payment-links.ts as the review suggested: both send routes reach payment links through the core registry bridge, and a core route importing the Stripe extension directly would break the zero-extensions core build; per-route logging differences are preserved via logPrefix/logContext options.
|
||||
[2026-07-12] Global/app error boundaries recover via a guarded hard `window.location.reload()`, not React `reset()`: reset() re-renders against the same stale server payload/bundle and re-throws, whereas a reload re-runs middleware (fresh rotated Supabase auth cookie) and fetches a fresh bundle (ChunkLoadError after a deploy), matching the browser-navigation self-heal these transients already relied on. A per-path, per-tab-session sessionStorage flag (a monotonic one-shot, not a time window, which could still loop when a failing render takes longer than the window) bounds it to one auto-reload per path so a persistent error shows the manual fallback instead of looping.
|
||||
[2026-07-13] Skatteverket post-connect refresh (skattekonto sync + kvittens re-check) is AWAITED in the OAuth callback before responding, not deferred via after(): SKV per-flow tokens live ~65 minutes so right-after-consent is the only reliable personal-token window, and awaiting means popup-close = data fresh, so the salary/skattekonto pages' refetch listeners never race a background job. The SKV client's own request timeouts bound the added latency; failures are swallowed (best-effort) so a refresh error can never fail the connect that just succeeded.
|
||||
[2026-07-13] AGI auto-settlement matches production's split draw as an exact pair (exactly one "Avdragen skatt <manad> <ar>" == total_tax AND exactly one "Arbetsgivaravgift <manad> <ar>" == total_avgifter, anchored regexes) rather than summing all rows parsed to the period: summing would let "Beslut ..." correction rows poison the total, and duplicate rows are ambiguous by definition. Anything non-exact falls back to the manual mark-paid button (determinism over inference).
|
||||
[2026-07-13] Settings Skatteverket connect switched to the AGIPanel popup+postMessage pattern (full-page redirect kept only as popup-blocked fallback): the full-page round-trip leaves SKV pages + the consumed callback URL in browser history directly beneath /settings/tax, so closing settings (router.back()/browser Back) walked into a dead OAuth chain and re-prompted SKV auth. Fixing the history stack in-place (location.replace) only shrinks the problem; not navigating at all removes it.
|
||||
[2026-07-13] postMessage hardening uses event.source identity (popup handle in a ref) plus a verified rebroadcast CustomEvent ('skatteverket-connection-updated') instead of the nonce the SOC2 finding suggested: a window reference cannot be forged by same-origin scripts, so the source check is strictly stronger than a nonce threaded through the OAuth flow, and pages that never open the popup (salary dashboard) consume the rebroadcast from the component that did verify it.
|
||||
[2026-07-13] Kvittens cron HTTP response drops companyId per row (GDPR minimization) but keeps declarationId: it is an opaque UUID useless without DB access, the endpoint is cron-secret gated + Cache-Control: no-store, and losing it would make per-run ops debugging blind. The extension_data delete race flagged by the swarm is documented-and-accepted: the agi_submission_<period> key is period-scoped by design and agi_declarations is UNIQUE per company+period, so no two declarations share a key.
|
||||
[2026-07-13] signeradAv (personnummer in agi_declarations.response_data) is documented in .compliance/ropa.yaml under the existing agi.submit entry (Art.6(1)(c), BFL 7 kap 2 par retention) rather than moved to a dedicated column with column-level grants: it is part of the SKV kvittens payload preserved verbatim as rakenskapsinformation, and submitted_by is now explicitly documented as the technical submitter with response_data.signeradAv as the authoritative legal signatory.
|
||||
|
||||
@@ -117,6 +117,22 @@ export default function SalaryPage() {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Reload after an in-page Skatteverket reconnect. The raw postMessage from
|
||||
// the BankID popup is only trusted by the component that opened and
|
||||
// source-verified the popup (SkatteverketConnectPanel / AGIPanel); this
|
||||
// page never opens the popup itself, so it consumes the verified rebroadcast
|
||||
// instead. The OAuth callback awaits the skattekonto sync + AGI
|
||||
// auto-settlement before responding, so this refetch already sees fresh
|
||||
// tax-payment state instead of racing a background job.
|
||||
useEffect(() => {
|
||||
function handleConnectionUpdated() {
|
||||
load()
|
||||
}
|
||||
window.addEventListener('skatteverket-connection-updated', handleConnectionUpdated)
|
||||
return () =>
|
||||
window.removeEventListener('skatteverket-connection-updated', handleConnectionUpdated)
|
||||
}, [load])
|
||||
|
||||
// Next open AGI deadline instance - generated by the tax-deadline engine
|
||||
// when the company pays salaries; same source as the /deadlines page.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,6 +13,25 @@ vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
// The route and the reconcile helper log through '@/lib/logger'. The real
|
||||
// logger suppresses info/warn under NODE_ENV=test, so warn/error output is
|
||||
// observed via these recorders instead of console spies. Console spies stay
|
||||
// for the route's remaining console.* lines (APIGW warn, summary, skip).
|
||||
const { warnRecorder, errorRecorder } = vi.hoisted(() => ({
|
||||
warnRecorder: vi.fn(),
|
||||
errorRecorder: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logger', () => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: warnRecorder,
|
||||
error: errorRecorder,
|
||||
child: (): unknown => logger,
|
||||
}
|
||||
return { createLogger: () => logger }
|
||||
})
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/agi-client', () => ({
|
||||
agiGetKvittenser: vi.fn(),
|
||||
}))
|
||||
@@ -191,9 +210,12 @@ describe('AGI kvittenser cron', () => {
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.grantRevoked).toBe(0)
|
||||
expect(body.results[0].status).toBe('signed')
|
||||
// Tenant identifiers stay in internal log context only.
|
||||
expect(body.results[0]).not.toHaveProperty('companyId')
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledTimes(1)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(errorRecorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports signed and sends the notification when completeTaxDeadline throws', async () => {
|
||||
@@ -234,9 +256,10 @@ describe('AGI kvittenser cron', () => {
|
||||
referenceId: 'decl-1',
|
||||
})
|
||||
|
||||
// The failure is a warning, not an error.
|
||||
// The failure is a warning (via the structured logger), not an error.
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
const warnMessages = warnSpy.mock.calls.map(c => String(c[0]))
|
||||
expect(errorRecorder).not.toHaveBeenCalled()
|
||||
const warnMessages = warnRecorder.mock.calls.map(c => String(c[0]))
|
||||
expect(warnMessages.some(m => m.includes('completeTaxDeadline failed'))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -264,7 +287,8 @@ describe('AGI kvittenser cron', () => {
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results[0].status).toBe('signed')
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
const warnMessages = warnSpy.mock.calls.map(c => String(c[0]))
|
||||
expect(errorRecorder).not.toHaveBeenCalled()
|
||||
const warnMessages = warnRecorder.mock.calls.map(c => String(c[0]))
|
||||
expect(warnMessages.some(m => m.includes('sendKvittensNotification failed'))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -302,10 +326,11 @@ describe('AGI kvittenser cron', () => {
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results[0]).toMatchObject({
|
||||
declarationId: 'decl-1',
|
||||
companyId: 'comp-1',
|
||||
status: 'apigw_config',
|
||||
error: 'ACCESS_DENIED',
|
||||
})
|
||||
// companyId is internal log context, never response payload.
|
||||
expect(body.results[0]).not.toHaveProperty('companyId')
|
||||
|
||||
// Warn carries the actionable config hint plus the context to act on it.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
@@ -320,6 +345,7 @@ describe('AGI kvittenser cron', () => {
|
||||
|
||||
// The whole point: no error-level log for a config gap retries cannot heal.
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(errorRecorder).not.toHaveBeenCalled()
|
||||
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
|
||||
|
||||
// The config gap stays visible in the run summary.
|
||||
@@ -360,11 +386,13 @@ describe('AGI kvittenser cron', () => {
|
||||
'decl-3',
|
||||
])
|
||||
expect(body.results.every((r: { status: string }) => r.status === 'apigw_config')).toBe(true)
|
||||
expect(body.results.every((r: Record<string, unknown>) => !('companyId' in r))).toBe(true)
|
||||
|
||||
// But the identical config-gap warning is logged exactly once per run.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain('Utvecklarportalen')
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(errorRecorder).not.toHaveBeenCalled()
|
||||
|
||||
const summaryLine = logSpy.mock.calls.map(c => String(c[0])).find(m => m.includes('Processed'))
|
||||
expect(summaryLine).toContain('3 apigw config gaps')
|
||||
@@ -384,7 +412,9 @@ describe('AGI kvittenser cron', () => {
|
||||
expect(body.results[0]).toMatchObject({ status: 'expired_token', error: 'SESSION_EXPIRED' })
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'SESSION_EXPIRED')
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(errorRecorder).not.toHaveBeenCalled()
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
expect(warnRecorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still records expired_token for TOKEN_REVOKED without reconsent flagging', async () => {
|
||||
@@ -400,6 +430,7 @@ describe('AGI kvittenser cron', () => {
|
||||
expect(body.results[0]).toMatchObject({ status: 'expired_token', error: 'TOKEN_REVOKED' })
|
||||
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(errorRecorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still logs at error level for other SkatteverketAuthError codes', async () => {
|
||||
@@ -414,9 +445,10 @@ describe('AGI kvittenser cron', () => {
|
||||
expect(body.errors).toBe(1)
|
||||
expect(body.apigwConfig).toBe(0)
|
||||
expect(body.results[0]).toMatchObject({ status: 'error', error: 'Du har inte behörighet.' })
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||
expect(String(errorSpy.mock.calls[0][0])).toContain('Reconciliation failed')
|
||||
expect(errorRecorder).toHaveBeenCalledTimes(1)
|
||||
expect(String(errorRecorder.mock.calls[0][0])).toContain('Reconciliation failed')
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
expect(warnRecorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still logs at error level for generic errors', async () => {
|
||||
@@ -428,6 +460,7 @@ describe('AGI kvittenser cron', () => {
|
||||
|
||||
expect(body.errors).toBe(1)
|
||||
expect(body.results[0]).toMatchObject({ status: 'error', error: 'fetch failed' })
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||
expect(errorRecorder).toHaveBeenCalledTimes(1)
|
||||
expect(String(errorRecorder.mock.calls[0][0])).toContain('Reconciliation failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
|
||||
import { resolveReadAuth, currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
|
||||
import { currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
|
||||
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { reconcileAgiDeclaration } from '@/extensions/general/skatteverket/lib/agi-kvittens-reconcile'
|
||||
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
@@ -17,6 +16,14 @@ ensureInitialized()
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
// Failure logs route through the structured logger so third-party error
|
||||
// strings pass its redaction. The APIGW warn / budget + summary logs /
|
||||
// capability skip stay on console.*: their content is fixed internal strings.
|
||||
const log = createLogger('agi-kvittenser-cron')
|
||||
|
||||
// Cron responses must never be cached: they report a point-in-time run.
|
||||
const NO_STORE_HEADERS = { 'Cache-Control': 'no-store' } as const
|
||||
|
||||
/**
|
||||
* GET /api/extensions/skatteverket/agi/kvittenser/cron
|
||||
*
|
||||
@@ -45,13 +52,19 @@ export async function GET(request: Request) {
|
||||
if (authError) return authError
|
||||
|
||||
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
|
||||
return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 })
|
||||
return NextResponse.json(
|
||||
{ message: 'Skatteverket extension disabled', processed: 0 },
|
||||
{ headers: NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Supabase configuration' },
|
||||
{ status: 500, headers: NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
@@ -64,25 +77,33 @@ export async function GET(request: Request) {
|
||||
.limit(100)
|
||||
|
||||
if (pendingError) {
|
||||
console.error('[agi-kvittenser-cron] Failed to fetch pending declarations', {
|
||||
log.error('Failed to fetch pending declarations', {
|
||||
message: pendingError.message,
|
||||
code: pendingError.code,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to fetch pending declarations' }, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch pending declarations' },
|
||||
{ status: 500, headers: NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
if (!pending || pending.length === 0) {
|
||||
return NextResponse.json({ message: 'No pending signatures', processed: 0 })
|
||||
return NextResponse.json(
|
||||
{ message: 'No pending signatures', processed: 0 },
|
||||
{ headers: NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 50_000
|
||||
|
||||
// No companyId here: results echo back in the HTTP response body, so
|
||||
// tenant identifiers stay in internal log context only (declarationId
|
||||
// is enough to find the row).
|
||||
type Result = {
|
||||
declarationId: string
|
||||
companyId: string
|
||||
period: string
|
||||
status: 'signed' | 'still_pending' | 'no_token' | 'no_company_settings' | 'expired_token' | 'grant_revoked' | 'apigw_config' | 'error'
|
||||
status: 'signed' | 'still_pending' | 'already_claimed' | 'no_token' | 'no_company_settings' | 'expired_token' | 'grant_revoked' | 'apigw_config' | 'error'
|
||||
error?: string
|
||||
}
|
||||
const results: Result[] = []
|
||||
@@ -107,158 +128,25 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Auth resolution prefers system credentials (verified lasombud grant)
|
||||
// and falls back to the company's user token: kvittens polling is the
|
||||
// canonical case for the hybrid model, since the user signed at SKV
|
||||
// and their 65-minute session is usually long dead by the time the
|
||||
// kvittens exists.
|
||||
const resolved = await resolveReadAuth(supabase, companyId, { requires: 'lasombud' })
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === 'needs_reconsent') {
|
||||
// A connection flagged needs_reconsent cannot heal on its own
|
||||
// (SKV's per-flow refresh tokens live 65 minutes): skip quietly
|
||||
// instead of failing the same declaration every run.
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: 'needs_reconsent' })
|
||||
} else {
|
||||
results.push({ declarationId, companyId, period, status: 'no_token' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!settings?.org_number) {
|
||||
results.push({ declarationId, companyId, period, status: 'no_company_settings' })
|
||||
continue
|
||||
}
|
||||
|
||||
const arbetsgivare = formatRedovisare(
|
||||
settings.org_number as string,
|
||||
settings.entity_type as 'enskild_firma' | 'aktiebolag',
|
||||
// The shared reconciler resolves auth (system grant → user token),
|
||||
// fetches the kvittens, and on a hit promotes the declaration +
|
||||
// stamps salary_runs / deadline / notification. Auth errors propagate
|
||||
// to the catch below, which owns the cron-specific side effects.
|
||||
const outcome = await reconcileAgiDeclaration(
|
||||
supabase,
|
||||
{
|
||||
id: declarationId,
|
||||
company_id: companyId,
|
||||
salary_run_id: (decl.salary_run_id as string | null) ?? null,
|
||||
period_year: decl.period_year as number,
|
||||
period_month: decl.period_month as number,
|
||||
},
|
||||
{ reconciledBy: 'cron' },
|
||||
)
|
||||
|
||||
const kvittRes = await agiGetKvittenser(resolved.auth, arbetsgivare, period)
|
||||
if (!kvittRes.ok) {
|
||||
results.push({
|
||||
declarationId, companyId, period,
|
||||
status: 'error',
|
||||
error: kvittRes.error,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const kvittens = kvittRes.data.kvittenser?.[0]
|
||||
if (!kvittens?.uuidKvittens) {
|
||||
results.push({ declarationId, companyId, period, status: 'still_pending' })
|
||||
continue
|
||||
}
|
||||
|
||||
// The presence of uuidKvittens confirms SKV signed and accepted
|
||||
// the AGI. signeradTid is the precise signing moment; if SKV omits
|
||||
// it we fall back to reconciliation time + warn so the discrepancy
|
||||
// is investigable. Leaving NULL would hide that the filing occurred
|
||||
// at all, which itself misstates behandlingshistorik (BFNAR 2013:2
|
||||
// kap 8 / BFL 5 kap 6§). The fallback only applies on this code
|
||||
// path because we're inside the kvittens-found branch above.
|
||||
const submittedAt = kvittens.signeradTid || new Date().toISOString()
|
||||
if (!kvittens.signeradTid) {
|
||||
console.warn('[agi-kvittenser-cron] kvittens missing signeradTid; using reconciliation time', {
|
||||
declarationId, companyId, period, uuidKvittens: kvittens.uuidKvittens,
|
||||
})
|
||||
}
|
||||
|
||||
// submitted_by is the token-owning auth.users row: the human who
|
||||
// connected via BankID. The legally load-bearing signer identity
|
||||
// is kvittens.signeradAv (a personnummer), which the token user_id
|
||||
// does NOT necessarily match (e.g. if the connected user is a
|
||||
// bookkeeper but the deklarationsombud signed). We preserve the
|
||||
// full kvittens in response_data so the audit trail (BFL 5 kap 6§,
|
||||
// BFNAR 2013:2 kap 8) records the actual BankID signer regardless
|
||||
// of who triggered the reconciliation.
|
||||
await supabase
|
||||
.from('agi_declarations')
|
||||
.update({
|
||||
status: 'submitted',
|
||||
kvittensnummer: kvittens.uuidKvittens,
|
||||
submitted_at: submittedAt,
|
||||
submitted_by: resolved.tokenUserId,
|
||||
response_data: {
|
||||
signeradAv: kvittens.signeradAv ?? null,
|
||||
signeradTid: kvittens.signeradTid ?? null,
|
||||
uuidKvittens: kvittens.uuidKvittens,
|
||||
arbetsgivare: kvittens.arbetsgivare ?? null,
|
||||
period: kvittens.period ?? null,
|
||||
underlag: kvittens.underlag ?? null,
|
||||
reconciledBy: 'cron',
|
||||
},
|
||||
})
|
||||
.eq('id', declarationId)
|
||||
|
||||
if (decl.salary_run_id) {
|
||||
await supabase
|
||||
.from('salary_runs')
|
||||
.update({ agi_submitted_at: submittedAt })
|
||||
.eq('id', decl.salary_run_id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
|
||||
// Clear the locally-cached submission state so the panel doesn't
|
||||
// pop a stale "awaiting signature" view if the user revisits.
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', `agi_submission_${period}`)
|
||||
|
||||
// The declaration is already flipped to submitted above, and the next
|
||||
// run only revisits pending_signature rows: from here on everything is
|
||||
// best-effort. Each step gets its own try/catch so a failure is logged
|
||||
// as a warning without masking the successful filing or skipping the
|
||||
// remaining confirmation steps.
|
||||
|
||||
// The kvittens is the canonical filing receipt: confirm the period's
|
||||
// arbetsgivardeklaration deadline (terminal state).
|
||||
try {
|
||||
await completeTaxDeadline(
|
||||
supabase,
|
||||
companyId,
|
||||
['arbetsgivardeklaration'],
|
||||
`${decl.period_year}-${String(decl.period_month).padStart(2, '0')}`,
|
||||
'confirmed'
|
||||
)
|
||||
} catch (deadlineErr) {
|
||||
console.warn('[agi-kvittenser-cron] completeTaxDeadline failed after successful filing', {
|
||||
declarationId, companyId, period,
|
||||
message: deadlineErr instanceof Error ? deadlineErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
|
||||
// Tell the user: signing happened at Skatteverket, often long after
|
||||
// they closed our tab, so this is the only confirmation they get.
|
||||
if (resolved.tokenUserId) {
|
||||
try {
|
||||
await sendKvittensNotification(supabase, {
|
||||
companyId,
|
||||
userId: resolved.tokenUserId,
|
||||
kind: 'agi',
|
||||
period,
|
||||
kvittensnummer: kvittens.uuidKvittens,
|
||||
referenceId: declarationId,
|
||||
})
|
||||
} catch (notifyErr) {
|
||||
console.warn('[agi-kvittenser-cron] sendKvittensNotification failed after successful filing', {
|
||||
declarationId, companyId, period,
|
||||
message: notifyErr instanceof Error ? notifyErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ declarationId, companyId, period, status: 'signed' })
|
||||
const result: Result = { declarationId, period, status: outcome.status }
|
||||
if ('error' in outcome) result.error = outcome.error
|
||||
results.push(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
|
||||
@@ -267,7 +155,7 @@ export async function GET(request: Request) {
|
||||
// Downgrade the connection row so the next run falls back to the
|
||||
// user token (if any). Never touches skatteverket_tokens.
|
||||
await markGrantRevoked(companyId, currentSkvEnvironment(), 'lasombud', err.code)
|
||||
results.push({ declarationId, companyId, period, status: 'grant_revoked', error: err.code })
|
||||
results.push({ declarationId, period, status: 'grant_revoked', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -285,12 +173,12 @@ export async function GET(request: Request) {
|
||||
if (tokenRow?.user_id) {
|
||||
await markNeedsReconsent(supabase, tokenRow.user_id as string, err.code)
|
||||
}
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: err.code })
|
||||
results.push({ declarationId, period, status: 'expired_token', error: err.code })
|
||||
continue
|
||||
}
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') {
|
||||
// skvRequest already deleted the token row.
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: err.code })
|
||||
results.push({ declarationId, period, status: 'expired_token', error: err.code })
|
||||
continue
|
||||
}
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'ACCESS_DENIED') {
|
||||
@@ -311,34 +199,39 @@ export async function GET(request: Request) {
|
||||
{ declarationId, companyId, period, message },
|
||||
)
|
||||
}
|
||||
results.push({ declarationId, companyId, period, status: 'apigw_config', error: err.code })
|
||||
results.push({ declarationId, period, status: 'apigw_config', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
console.error('[agi-kvittenser-cron] Reconciliation failed', { declarationId, companyId, period, message })
|
||||
results.push({ declarationId, companyId, period, status: 'error', error: message })
|
||||
log.error('Reconciliation failed', { declarationId, companyId, period, message })
|
||||
results.push({ declarationId, period, status: 'error', error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const signed = results.filter(r => r.status === 'signed').length
|
||||
const stillPending = results.filter(r => r.status === 'still_pending').length
|
||||
const alreadyClaimed = results.filter(r => r.status === 'already_claimed').length
|
||||
const expired = results.filter(r => r.status === 'expired_token').length
|
||||
const grantRevoked = results.filter(r => r.status === 'grant_revoked').length
|
||||
const apigwConfig = results.filter(r => r.status === 'apigw_config').length
|
||||
const errors = results.filter(r => r.status === 'error').length
|
||||
|
||||
console.log(
|
||||
`[agi-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${expired} expired, ${grantRevoked} grants revoked, ${apigwConfig} apigw config gaps, ${errors} errors`,
|
||||
`[agi-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${alreadyClaimed} already claimed, ${expired} expired, ${grantRevoked} grants revoked, ${apigwConfig} apigw config gaps, ${errors} errors`,
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
processed: results.length,
|
||||
signed,
|
||||
stillPending,
|
||||
expired,
|
||||
grantRevoked,
|
||||
apigwConfig,
|
||||
errors,
|
||||
results,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
processed: results.length,
|
||||
signed,
|
||||
stillPending,
|
||||
alreadyClaimed,
|
||||
expired,
|
||||
grantRevoked,
|
||||
apigwConfig,
|
||||
errors,
|
||||
results,
|
||||
},
|
||||
{ headers: NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,16 +184,28 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
fetchSubmission()
|
||||
}, [fetchStatus, fetchSubmission])
|
||||
|
||||
// Handle of the OAuth popup opened by handleConnect: used to verify the
|
||||
// sender identity of incoming postMessages.
|
||||
const popupRef = useRef<Window | null>(null)
|
||||
|
||||
// Listen for OAuth completion from the BankID popup. When the popup posts
|
||||
// back a success/error message we re-fetch status so the panel flips from
|
||||
// "expired" / not-connected to "Ansluten" without a full page reload.
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (event.origin !== window.location.origin) return
|
||||
// Source-identity check: only the popup this component opened can
|
||||
// trigger the handler; a window reference cannot be forged by other
|
||||
// same-origin scripts.
|
||||
if (!popupRef.current || event.source !== popupRef.current) return
|
||||
if (event.data?.type === 'skatteverket-oauth-success') {
|
||||
setError(null)
|
||||
setSuccess(t('oauth_success'))
|
||||
fetchStatus()
|
||||
// Verified success: rebroadcast as an internal DOM event so passive
|
||||
// consumers (e.g. the salary page) can react without trusting raw
|
||||
// postMessage.
|
||||
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
|
||||
} else if (event.data?.type === 'skatteverket-oauth-error') {
|
||||
const reason =
|
||||
typeof event.data.reason === 'string' && event.data.reason
|
||||
@@ -366,6 +378,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
'skatteverket-oauth',
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
)
|
||||
popupRef.current = popup
|
||||
if (!popup) {
|
||||
// Popup blocked: fall back to a full-page navigation.
|
||||
window.location.href = url
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -38,11 +38,16 @@ export function SkatteverketConnectPanel() {
|
||||
|
||||
function SkatteverketPersonalConnectionCard() {
|
||||
const t = useTranslations('settings_skatteverket_connect')
|
||||
// Toast strings shared with TaxSettingsContent's query-param fallback path.
|
||||
const tOauth = useTranslations('settings_skatteverket')
|
||||
const { toast } = useToast()
|
||||
const hasSkatteverket = useCapability(CAPABILITY.skatteverket)
|
||||
const [status, setStatus] = useState<Status | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
// Handle of the OAuth popup opened by startConnect: used to verify the
|
||||
// sender identity of incoming postMessages.
|
||||
const popupRef = useRef<Window | null>(null)
|
||||
|
||||
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
@@ -53,7 +58,7 @@ function SkatteverketPersonalConnectionCard() {
|
||||
agd: t('scope_agd'),
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const loadStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/skatteverket/status')
|
||||
@@ -68,15 +73,71 @@ function SkatteverketPersonalConnectionCard() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus()
|
||||
}, [])
|
||||
}, [loadStatus])
|
||||
|
||||
// Listen for OAuth completion from the BankID popup (same pattern as
|
||||
// AGIPanel): the callback page posts success/error and closes itself, so
|
||||
// the settings page never navigates and we just re-fetch the status.
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (event.origin !== window.location.origin) return
|
||||
// Source-identity check: only the popup this component opened can
|
||||
// trigger the handler; a window reference cannot be forged by other
|
||||
// same-origin scripts.
|
||||
if (!popupRef.current || event.source !== popupRef.current) return
|
||||
if (event.data?.type === 'skatteverket-oauth-success') {
|
||||
toast({
|
||||
title: tOauth('connected_title'),
|
||||
description: tOauth('connected_description'),
|
||||
})
|
||||
loadStatus()
|
||||
// Verified success: rebroadcast as an internal DOM event so passive
|
||||
// consumers (e.g. the salary page) can react without trusting raw
|
||||
// postMessage.
|
||||
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
|
||||
} else if (event.data?.type === 'skatteverket-oauth-error') {
|
||||
toast({
|
||||
title: tOauth('connect_failed_title'),
|
||||
description:
|
||||
typeof event.data.reason === 'string' && event.data.reason
|
||||
? event.data.reason
|
||||
: undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [loadStatus, toast, tOauth])
|
||||
|
||||
function startConnect() {
|
||||
// Open the BankID OAuth flow in a centered popup. The callback page
|
||||
// detects `window.opener`, posts back a message and closes itself: the
|
||||
// settings page never navigates, so browser history stays clean and
|
||||
// closing the settings afterwards cannot walk Back into the consumed
|
||||
// OAuth chain (the "redirected to Skatteverket again" bug).
|
||||
const returnTo = encodeURIComponent('/settings/tax')
|
||||
window.location.href = `/api/extensions/ext/skatteverket/authorize?return_to=${returnTo}`
|
||||
const url = `/api/extensions/ext/skatteverket/authorize?return_to=${returnTo}`
|
||||
const w = 600
|
||||
const h = 750
|
||||
const left = window.screenX + (window.outerWidth - w) / 2
|
||||
const top = window.screenY + (window.outerHeight - h) / 2
|
||||
const popup = window.open(
|
||||
url,
|
||||
'skatteverket-oauth',
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
)
|
||||
popupRef.current = popup
|
||||
if (!popup) {
|
||||
// Popup blocked: fall back to the full-page flow. The callback then
|
||||
// lands on /settings/tax?skv_connected=true, handled by
|
||||
// TaxSettingsContent's query-param effect.
|
||||
window.location.href = url
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
@@ -88,6 +149,9 @@ function SkatteverketPersonalConnectionCard() {
|
||||
if (!res.ok) throw new Error(t('disconnect_failed'))
|
||||
toast({ title: t('toast_disconnected') })
|
||||
await loadStatus()
|
||||
// Connection state changed: notify passive consumers via the same
|
||||
// internal event as a verified OAuth success.
|
||||
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('toast_disconnect_failed'),
|
||||
@@ -235,7 +299,11 @@ function SkatteverketPersonalConnectionCard() {
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{(status.expired || status.needsReconsent || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (
|
||||
{/* The skattekonto read scope is named `skahmst` in the live grants;
|
||||
accept the older `skattekonto` name too (mirrors the missing-scope
|
||||
notice above). Checking only `skattekonto` kept this button
|
||||
permanently visible on healthy connections. */}
|
||||
{(status.expired || status.needsReconsent || !status.canRefresh || !(scopes.includes('skahmst') || scopes.includes('skattekonto')) || !scopes.includes('agd')) && (
|
||||
<Button
|
||||
onClick={startConnect}
|
||||
disabled={status.disabled || !hasSkatteverket}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { warnRecorder } = vi.hoisted(() => ({ warnRecorder: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: warnRecorder,
|
||||
error: vi.fn(),
|
||||
child() {
|
||||
return this
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/agi-client', () => ({
|
||||
agiGetKvittenser: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/resolve-auth', () => ({
|
||||
resolveReadAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/kvittens-notification', () => ({
|
||||
sendKvittensNotification: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/deadlines/complete-tax-deadline', () => ({
|
||||
completeTaxDeadline: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { reconcileAgiDeclaration } from '../lib/agi-kvittens-reconcile'
|
||||
import { agiGetKvittenser } from '../lib/agi-client'
|
||||
import { resolveReadAuth } from '../lib/resolve-auth'
|
||||
import { sendKvittensNotification } from '../lib/kvittens-notification'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
|
||||
const mockAgiGetKvittenser = vi.mocked(agiGetKvittenser)
|
||||
const mockResolveReadAuth = vi.mocked(resolveReadAuth)
|
||||
const mockSendKvittensNotification = vi.mocked(sendKvittensNotification)
|
||||
const mockCompleteTaxDeadline = vi.mocked(completeTaxDeadline)
|
||||
|
||||
const DECL = {
|
||||
id: 'decl-1',
|
||||
company_id: 'comp-1',
|
||||
salary_run_id: 'run-1',
|
||||
period_year: 2026,
|
||||
period_month: 5,
|
||||
}
|
||||
|
||||
/**
|
||||
* Table-aware stub covering the reconciler's four query shapes:
|
||||
* company_settings select (.single), the agi_declarations claim update
|
||||
* (awaited), the salary_runs update (awaited), and the extension_data
|
||||
* delete (awaited). Records which tables were mutated so the "no side
|
||||
* effects" assertions are structural, not inferred.
|
||||
*/
|
||||
function makeSupabase(opts: {
|
||||
claim?: { data?: unknown[] | null; error?: { message: string } | null }
|
||||
salaryRunError?: { message: string } | null
|
||||
} = {}) {
|
||||
const mutatedTables: string[] = []
|
||||
return {
|
||||
supabase: {
|
||||
from(table: string) {
|
||||
let op: 'read' | 'mutate' = 'read'
|
||||
const chain: any = {}
|
||||
for (const method of ['select', 'eq', 'order', 'limit', 'in']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
for (const method of ['update', 'delete']) {
|
||||
chain[method] = vi.fn(() => {
|
||||
op = 'mutate'
|
||||
return chain
|
||||
})
|
||||
}
|
||||
chain.single = vi.fn(async () => ({
|
||||
data:
|
||||
table === 'company_settings'
|
||||
? { org_number: '556123-4567', entity_type: 'aktiebolag' }
|
||||
: null,
|
||||
error: null,
|
||||
}))
|
||||
chain.then = (resolve: (v: unknown) => void) => {
|
||||
if (op === 'mutate') mutatedTables.push(table)
|
||||
if (table === 'agi_declarations' && op === 'mutate') {
|
||||
return resolve({
|
||||
data: opts.claim && 'data' in opts.claim ? opts.claim.data : [{ id: DECL.id }],
|
||||
error: opts.claim?.error ?? null,
|
||||
})
|
||||
}
|
||||
if (table === 'salary_runs') {
|
||||
return resolve({ error: opts.salaryRunError ?? null })
|
||||
}
|
||||
return resolve({ data: null, error: null })
|
||||
}
|
||||
return chain
|
||||
},
|
||||
} as any,
|
||||
mutatedTables,
|
||||
}
|
||||
}
|
||||
|
||||
function kvittensResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
kvittenser: [
|
||||
{
|
||||
uuidKvittens: 'uuid-1',
|
||||
signeradAv: '191212121212',
|
||||
signeradTid: '2026-06-01T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('reconcileAgiDeclaration: claim semantics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveReadAuth.mockResolvedValue({
|
||||
ok: true,
|
||||
auth: { mode: 'user' } as any,
|
||||
source: 'user',
|
||||
tokenUserId: 'user-1',
|
||||
})
|
||||
mockAgiGetKvittenser.mockResolvedValue(kvittensResponse())
|
||||
})
|
||||
|
||||
it('promotes the declaration and runs all side effects on a successful claim', async () => {
|
||||
const { supabase, mutatedTables } = makeSupabase()
|
||||
|
||||
const outcome = await reconcileAgiDeclaration(supabase, DECL, { reconciledBy: 'cron' })
|
||||
|
||||
expect(outcome).toEqual({ status: 'signed', kvittensnummer: 'uuid-1' })
|
||||
expect(mutatedTables).toEqual(['agi_declarations', 'salary_runs', 'extension_data'])
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns already_claimed and runs no side effects when another run won the claim', async () => {
|
||||
const { supabase, mutatedTables } = makeSupabase({ claim: { data: [] } })
|
||||
|
||||
const outcome = await reconcileAgiDeclaration(supabase, DECL, { reconciledBy: 'post-connect' })
|
||||
|
||||
expect(outcome).toEqual({ status: 'already_claimed' })
|
||||
// Only the claim attempt itself mutated anything.
|
||||
expect(mutatedTables).toEqual(['agi_declarations'])
|
||||
expect(mockCompleteTaxDeadline).not.toHaveBeenCalled()
|
||||
expect(mockSendKvittensNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error and runs no side effects when the claim update fails', async () => {
|
||||
const { supabase, mutatedTables } = makeSupabase({
|
||||
claim: { data: null, error: { message: 'connection reset' } },
|
||||
})
|
||||
|
||||
const outcome = await reconcileAgiDeclaration(supabase, DECL, { reconciledBy: 'cron' })
|
||||
|
||||
expect(outcome).toMatchObject({ status: 'error' })
|
||||
expect((outcome as { error: string }).error).toContain('connection reset')
|
||||
expect(mutatedTables).toEqual(['agi_declarations'])
|
||||
expect(mockCompleteTaxDeadline).not.toHaveBeenCalled()
|
||||
expect(mockSendKvittensNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports signed and continues when the salary_runs stamp fails', async () => {
|
||||
const { supabase } = makeSupabase({ salaryRunError: { message: 'row locked' } })
|
||||
|
||||
const outcome = await reconcileAgiDeclaration(supabase, DECL, { reconciledBy: 'cron' })
|
||||
|
||||
expect(outcome).toEqual({ status: 'signed', kvittensnummer: 'uuid-1' })
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledTimes(1)
|
||||
const warned = warnRecorder.mock.calls.map(c => String(c[0]))
|
||||
expect(warned.some(m => m.includes('agi_submitted_at stamp failed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns still_pending without attempting a claim when no kvittens exists', async () => {
|
||||
mockAgiGetKvittenser.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { kvittenser: [] },
|
||||
} as any)
|
||||
const { supabase, mutatedTables } = makeSupabase()
|
||||
|
||||
const outcome = await reconcileAgiDeclaration(supabase, DECL, { reconciledBy: 'cron' })
|
||||
|
||||
expect(outcome).toEqual({ status: 'still_pending' })
|
||||
expect(mutatedTables).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -275,3 +275,167 @@ describe('settleAgiTaxPayments', () => {
|
||||
expect(settled).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Production format: the draw books as two rows with Swedish month names
|
||||
// ("Avdragen skatt maj 2026" = total_tax, "Arbetsgivaravgift maj 2026" =
|
||||
// total_avgifter) instead of the test environment's single combined
|
||||
// "Arbetsgivardeklaration YYYYMM" row.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function taxDrawRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
transaktionsdatum: '2026-06-12',
|
||||
transaktionstext: 'Avdragen skatt maj 2026',
|
||||
belopp_skatteverket: -3391,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function avgiftDrawRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
transaktionsdatum: '2026-06-12',
|
||||
transaktionstext: 'Arbetsgivaravgift maj 2026',
|
||||
belopp_skatteverket: -6284,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('settleAgiTaxPayments: production two-row format', () => {
|
||||
it('settles when the tax + avgift pair matches the declared amounts exactly', async () => {
|
||||
const { supabase, updates } = createSettlementMock({
|
||||
declarations: [declaration()],
|
||||
})
|
||||
|
||||
const settled = await settleAgiTaxPayments(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
[taxDrawRow(), avgiftDrawRow()],
|
||||
2538,
|
||||
)
|
||||
|
||||
expect(settled).toBe(1)
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0].payload).toEqual({ tax_paid_at: '2026-06-12T00:00:00Z' })
|
||||
expect(updates[0].filters).toMatchObject({
|
||||
id: 'agi-may',
|
||||
company_id: COMPANY,
|
||||
tax_paid_at: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('settles on the later date when the pair books on different days', async () => {
|
||||
const { supabase, updates } = createSettlementMock({
|
||||
declarations: [declaration()],
|
||||
})
|
||||
|
||||
const settled = await settleAgiTaxPayments(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
[
|
||||
taxDrawRow({ transaktionsdatum: '2026-06-12' }),
|
||||
avgiftDrawRow({ transaktionsdatum: '2026-06-13' }),
|
||||
],
|
||||
0,
|
||||
)
|
||||
|
||||
expect(settled).toBe(1)
|
||||
expect(updates[0].payload).toEqual({ tax_paid_at: '2026-06-13T00:00:00Z' })
|
||||
})
|
||||
|
||||
it('does not settle from a lone tax row', async () => {
|
||||
const { supabase, updates } = createSettlementMock({
|
||||
declarations: [declaration()],
|
||||
})
|
||||
|
||||
const settled = await settleAgiTaxPayments(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
[taxDrawRow()],
|
||||
0,
|
||||
)
|
||||
|
||||
expect(settled).toBe(0)
|
||||
expect(updates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not settle when one side of the pair mismatches', async () => {
|
||||
const { supabase, updates } = createSettlementMock({
|
||||
declarations: [declaration()],
|
||||
})
|
||||
|
||||
const settled = await settleAgiTaxPayments(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
[taxDrawRow(), avgiftDrawRow({ belopp_skatteverket: -6283 })],
|
||||
0,
|
||||
)
|
||||
|
||||
expect(settled).toBe(0)
|
||||
expect(updates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips the period when duplicate rows of one kind make it ambiguous', async () => {
|
||||
const { supabase, updates } = createSettlementMock({
|
||||
declarations: [declaration()],
|
||||
})
|
||||
|
||||
const settled = await settleAgiTaxPayments(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
[taxDrawRow(), taxDrawRow(), avgiftDrawRow()],
|
||||
0,
|
||||
)
|
||||
|
||||
expect(settled).toBe(0)
|
||||
expect(updates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('never classifies beslut correction rows as draws', async () => {
|
||||
const { supabase, updates, selects } = createSettlementMock({
|
||||
declarations: [declaration({ period_month: 3 })],
|
||||
})
|
||||
|
||||
const settled = await settleAgiTaxPayments(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
[
|
||||
{
|
||||
transaktionsdatum: '2026-08-12',
|
||||
transaktionstext: 'Beslut 260703 arbetsgivaravgift mars 2026',
|
||||
belopp_skatteverket: -2524,
|
||||
},
|
||||
],
|
||||
0,
|
||||
)
|
||||
|
||||
expect(settled).toBe(0)
|
||||
expect(updates).toHaveLength(0)
|
||||
// The beslut row is not a draw candidate at all: no lookup happens.
|
||||
expect(selects).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a beslut row for the same period does not block the pair from settling', async () => {
|
||||
const { supabase, updates } = createSettlementMock({
|
||||
declarations: [declaration()],
|
||||
})
|
||||
|
||||
const settled = await settleAgiTaxPayments(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
[
|
||||
taxDrawRow(),
|
||||
avgiftDrawRow(),
|
||||
{
|
||||
transaktionsdatum: '2026-08-12',
|
||||
transaktionstext: 'Beslut 260703 arbetsgivaravgift maj 2026',
|
||||
belopp_skatteverket: -2524,
|
||||
},
|
||||
],
|
||||
0,
|
||||
)
|
||||
|
||||
expect(settled).toBe(1)
|
||||
expect(updates).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { warnRecorder } = vi.hoisted(() => ({ warnRecorder: vi.fn() }))
|
||||
|
||||
// log.warn is suppressed under NODE_ENV=test, so the failure-path tests
|
||||
// observe it through this mock instead of a console spy.
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: warnRecorder,
|
||||
error: vi.fn(),
|
||||
child() {
|
||||
return this
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/extensions/context-factory', () => ({
|
||||
createExtensionContext: vi.fn().mockReturnValue({ stub: 'ctx' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
hasCapability: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/skattekonto-sync', () => ({
|
||||
syncSkattekonto: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/agi-kvittens-reconcile', () => ({
|
||||
reconcileAgiDeclaration: vi.fn(),
|
||||
}))
|
||||
|
||||
import { runPostConnectRefresh } from '../lib/post-connect-refresh'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { syncSkattekonto } from '../lib/skattekonto-sync'
|
||||
import { reconcileAgiDeclaration } from '../lib/agi-kvittens-reconcile'
|
||||
|
||||
const mockCreateExtensionContext = vi.mocked(createExtensionContext)
|
||||
const mockHasCapability = vi.mocked(hasCapability)
|
||||
const mockSyncSkattekonto = vi.mocked(syncSkattekonto)
|
||||
const mockReconcile = vi.mocked(reconcileAgiDeclaration)
|
||||
|
||||
const USER = 'user-1'
|
||||
const COMPANY = 'company-1'
|
||||
|
||||
function pendingDecl(id: string) {
|
||||
return {
|
||||
id,
|
||||
company_id: COMPANY,
|
||||
salary_run_id: null,
|
||||
period_year: 2026,
|
||||
period_month: 5,
|
||||
}
|
||||
}
|
||||
|
||||
/** Thenable select chain for the agi_declarations pending lookup. */
|
||||
function makeSupabase(result: { data: unknown; error?: unknown }) {
|
||||
const chain: any = {}
|
||||
for (const method of ['select', 'eq', 'order', 'limit']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data, error: result.error ?? null })
|
||||
return { from: vi.fn(() => chain) } as any
|
||||
}
|
||||
|
||||
describe('runPostConnectRefresh', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateExtensionContext.mockReturnValue({ stub: 'ctx' } as any)
|
||||
mockHasCapability.mockResolvedValue(true)
|
||||
mockSyncSkattekonto.mockResolvedValue({
|
||||
booked: 1,
|
||||
upcoming: 0,
|
||||
saldoSkatteverket: 100,
|
||||
saldoKronofogden: 0,
|
||||
syncedAt: '2026-07-12T21:30:00Z',
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('syncs and reconciles pending declarations on the happy path', async () => {
|
||||
const supabase = makeSupabase({ data: [pendingDecl('d-1'), pendingDecl('d-2')] })
|
||||
mockReconcile
|
||||
.mockResolvedValueOnce({ status: 'signed', kvittensnummer: 'uuid-1' })
|
||||
.mockResolvedValueOnce({ status: 'still_pending' })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result).toEqual({ synced: true, reconciled: 1 })
|
||||
expect(mockCreateExtensionContext).toHaveBeenCalledWith(supabase, USER, COMPANY, 'skatteverket')
|
||||
expect(mockSyncSkattekonto).toHaveBeenCalledWith({ stub: 'ctx' })
|
||||
expect(mockReconcile).toHaveBeenCalledTimes(2)
|
||||
expect(mockReconcile).toHaveBeenCalledWith(supabase, pendingDecl('d-1'), {
|
||||
reconciledBy: 'post-connect',
|
||||
userId: USER,
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing when the skatteverket capability is not entitled', async () => {
|
||||
mockHasCapability.mockResolvedValueOnce(false)
|
||||
const supabase = makeSupabase({ data: [pendingDecl('d-1')] })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result).toEqual({ synced: false, reconciled: 0 })
|
||||
expect(mockSyncSkattekonto).not.toHaveBeenCalled()
|
||||
expect(mockReconcile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reconciles kvittenser when the sync fails', async () => {
|
||||
mockSyncSkattekonto.mockRejectedValueOnce(new Error('SKV timeout'))
|
||||
const supabase = makeSupabase({ data: [pendingDecl('d-1')] })
|
||||
mockReconcile.mockResolvedValueOnce({ status: 'signed', kvittensnummer: 'uuid-1' })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result).toEqual({ synced: false, reconciled: 1 })
|
||||
})
|
||||
|
||||
it('continues with remaining declarations when one reconcile throws', async () => {
|
||||
const supabase = makeSupabase({ data: [pendingDecl('d-1'), pendingDecl('d-2')] })
|
||||
mockReconcile
|
||||
.mockRejectedValueOnce(new Error('SESSION_EXPIRED'))
|
||||
.mockResolvedValueOnce({ status: 'signed', kvittensnummer: 'uuid-2' })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result).toEqual({ synced: true, reconciled: 1 })
|
||||
expect(mockReconcile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('never throws when the capability check itself fails', async () => {
|
||||
mockHasCapability.mockRejectedValueOnce(new Error('db down'))
|
||||
const supabase = makeSupabase({ data: [] })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result).toEqual({ synced: false, reconciled: 0 })
|
||||
expect(mockSyncSkattekonto).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles an empty pending lookup', async () => {
|
||||
const supabase = makeSupabase({ data: null })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result).toEqual({ synced: true, reconciled: 0 })
|
||||
expect(mockReconcile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs a failed pending lookup instead of treating it as no rows', async () => {
|
||||
const supabase = makeSupabase({ data: null, error: { message: 'connection reset' } })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result).toEqual({ synced: true, reconciled: 0 })
|
||||
expect(mockReconcile).not.toHaveBeenCalled()
|
||||
const warned = warnRecorder.mock.calls.map(c => String(c[0]))
|
||||
expect(warned.some(m => m.includes('kvittens lookup failed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('logs non-throw error outcomes from the reconciler', async () => {
|
||||
const supabase = makeSupabase({ data: [pendingDecl('d-1'), pendingDecl('d-2')] })
|
||||
mockReconcile
|
||||
.mockResolvedValueOnce({ status: 'error', error: 'SKV svarade 500' })
|
||||
.mockResolvedValueOnce({ status: 'signed', kvittensnummer: 'uuid-2' })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result).toEqual({ synced: true, reconciled: 1 })
|
||||
const errorWarn = warnRecorder.mock.calls.find(c =>
|
||||
String(c[0]).includes('reconcile returned error'),
|
||||
)
|
||||
expect(errorWarn).toBeDefined()
|
||||
expect(errorWarn?.[1]).toMatchObject({ declarationId: 'd-1', message: 'SKV svarade 500' })
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,35 @@ describe('parseAgiPeriod', () => {
|
||||
it('rejects implausible years', () => {
|
||||
expect(parseAgiPeriod('Arbetsgivardeklaration 199912')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses production month-name rows', () => {
|
||||
expect(parseAgiPeriod('Avdragen skatt maj 2026')).toEqual({
|
||||
year: 2026,
|
||||
month: 5,
|
||||
})
|
||||
expect(parseAgiPeriod('Arbetsgivaravgift maj 2026')).toEqual({
|
||||
year: 2026,
|
||||
month: 5,
|
||||
})
|
||||
expect(parseAgiPeriod('ARBETSGIVARAVGIFT December 2026')).toEqual({
|
||||
year: 2026,
|
||||
month: 12,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses the period from production beslut rows', () => {
|
||||
// The 6-digit beslut date must not be mistaken for a YYYYMM period; the
|
||||
// month-name token is the real period.
|
||||
expect(parseAgiPeriod('Beslut 260703 arbetsgivaravgift mars 2026')).toEqual({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat unrelated month-name rows as AGI periods', () => {
|
||||
expect(parseAgiPeriod('Intäktsränta maj 2026')).toBeNull()
|
||||
expect(parseAgiPeriod('Moms maj 2026')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
agiKontrolleraIU,
|
||||
} from './lib/agi-client'
|
||||
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
|
||||
import { runPostConnectRefresh } from './lib/post-connect-refresh'
|
||||
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
|
||||
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
|
||||
import {
|
||||
@@ -55,6 +56,9 @@ import {
|
||||
import { splitTransactions } from './lib/skattekonto-buckets'
|
||||
import type { SkattekontoBalanceSnapshot } from './types'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('skatteverket')
|
||||
|
||||
/**
|
||||
* Skatteverket integration extension.
|
||||
@@ -254,46 +258,64 @@ export const skatteverketExtension: Extension = {
|
||||
const state = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
|
||||
// JSON-encode for safe embedding inside <script>. Escapes quotes and `</`
|
||||
// so the value can't break out of the script tag.
|
||||
// Injection-safety invariants: appUrl comes from NEXT_PUBLIC_APP_URL
|
||||
// (deployment configuration, never user input), and jsLiteral
|
||||
// JSON-encodes and escapes `<` so embedded values cannot break out of
|
||||
// the script context. The per-response CSP nonce below is defense in
|
||||
// depth on top of that: even injected markup could never execute.
|
||||
const jsLiteral = (value: unknown) =>
|
||||
JSON.stringify(value ?? '').replace(/</g, '\\u003c')
|
||||
|
||||
// CSP allows only the nonce-carrying inline script; everything else
|
||||
// is blocked. Cache-Control: no-store because the callback URL
|
||||
// carries a one-shot authorization code and must never be cached.
|
||||
const responseHeaders = (nonce: string) => ({
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Security-Policy':
|
||||
`default-src 'none'; script-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'`,
|
||||
'Cache-Control': 'no-store',
|
||||
})
|
||||
|
||||
// Build an HTML response that detects whether we're running inside an
|
||||
// OAuth popup. If `window.opener` exists, post a message back to the
|
||||
// parent and close the popup. Otherwise fall back to a plain redirect
|
||||
// (preserves the legacy non-popup connect flow).
|
||||
// (preserves the legacy non-popup connect flow). The fallback uses
|
||||
// location.replace so this callback URL (whose code and state are
|
||||
// consumed) drops out of history: navigating Back from the landing
|
||||
// page must not re-run the callback into a guaranteed CSRF error.
|
||||
const respondWithSuccess = (fallbackPath: string) => {
|
||||
const html = `<!DOCTYPE html><html><body><script>
|
||||
const nonce = crypto.randomUUID()
|
||||
const html = `<!DOCTYPE html><html><body><script nonce="${nonce}">
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'skatteverket-oauth-success' }, ${jsLiteral(appUrl)});
|
||||
window.close();
|
||||
} else {
|
||||
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
|
||||
window.location.replace(${jsLiteral(`${appUrl}${fallbackPath}`)});
|
||||
}
|
||||
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
headers: responseHeaders(nonce),
|
||||
})
|
||||
}
|
||||
|
||||
const respondWithError = (reason: string, fallbackPath: string) => {
|
||||
const nonce = crypto.randomUUID()
|
||||
const escapedReason = reason
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
const html = `<!DOCTYPE html><html><body><script>
|
||||
const html = `<!DOCTYPE html><html><body><script nonce="${nonce}">
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'skatteverket-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
|
||||
window.close();
|
||||
} else {
|
||||
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
|
||||
window.location.replace(${jsLiteral(`${appUrl}${fallbackPath}`)});
|
||||
}
|
||||
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
headers: responseHeaders(nonce),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -411,6 +433,37 @@ export const skatteverketExtension: Extension = {
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.in('key', ['oauth_state', 'oauth_return_to', 'oauth_code_verifier'])
|
||||
|
||||
// Refresh Skatteverket-derived data NOW, while the fresh token is
|
||||
// guaranteed alive: SKV per-flow tokens live ~65 minutes, so the
|
||||
// nightly crons usually find them dead and right-after-consent is
|
||||
// the one reliable window for a personal-token fetch. Awaited on
|
||||
// purpose: when the popup closes, the salary/skattekonto pages can
|
||||
// refetch and see synced + auto-settled data instead of racing a
|
||||
// background job. Best-effort: a refresh failure must never fail
|
||||
// the connect that just succeeded.
|
||||
// 30-second deadline: a hung SKV call must not hold the OAuth
|
||||
// callback open. On timeout the refresh keeps running best-effort
|
||||
// (no cancellation); the user still gets the success response.
|
||||
const refreshTimeoutMs = 30000
|
||||
try {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timedOut = await Promise.race([
|
||||
runPostConnectRefresh(supabase, user.id, companyId).then(() => false),
|
||||
new Promise<true>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), refreshTimeoutMs)
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer))
|
||||
if (timedOut) {
|
||||
log.warn('post-connect refresh timed out', {
|
||||
companyId,
|
||||
userId: user.id,
|
||||
timeoutMs: refreshTimeoutMs,
|
||||
})
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
log.error('post-connect refresh failed', refreshErr, { companyId, userId: user.id })
|
||||
}
|
||||
|
||||
return respondWithSuccess(successPath)
|
||||
} catch (err) {
|
||||
console.error('[skatteverket] Token exchange failed:', err)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { agiGetKvittenser } from './agi-client'
|
||||
import { resolveReadAuth } from './resolve-auth'
|
||||
import { sendKvittensNotification } from './kvittens-notification'
|
||||
|
||||
/**
|
||||
* Shared per-declaration kvittens reconciliation.
|
||||
*
|
||||
* The user signs the AGI in Skatteverket's Mina Sidor; the resulting kvittens
|
||||
* (uuidKvittens + signeradTid) is the canonical filing receipt. This module
|
||||
* turns a `pending_signature` agi_declarations row into `submitted` once that
|
||||
* kvittens exists: it is called from the nightly kvittens cron and from the
|
||||
* post-connect refresh (right after a fresh BankID consent, the one moment a
|
||||
* personal token is guaranteed alive).
|
||||
*
|
||||
* Auth errors (SkatteverketAuthError) propagate to the caller on purpose: the
|
||||
* cron maps them to per-run statuses and side effects (needs_reconsent
|
||||
* flagging, grant revocation) that differ from the post-connect path.
|
||||
*
|
||||
* Logging goes through the structured logger (redaction + level filtering);
|
||||
* the cron tests observe it via a logger mock.
|
||||
*/
|
||||
|
||||
const log = createLogger('agi-kvittens-reconcile')
|
||||
|
||||
export interface PendingAgiDeclaration {
|
||||
id: string
|
||||
company_id: string
|
||||
salary_run_id: string | null
|
||||
period_year: number
|
||||
period_month: number
|
||||
}
|
||||
|
||||
export type AgiReconcileOutcome =
|
||||
| { status: 'signed'; kvittensnummer: string }
|
||||
| { status: 'still_pending' }
|
||||
/** Another run (cron vs post-connect) promoted the row first; no side effects ran. */
|
||||
| { status: 'already_claimed' }
|
||||
| { status: 'no_token' }
|
||||
| { status: 'expired_token'; error: string }
|
||||
| { status: 'no_company_settings' }
|
||||
| { status: 'error'; error: string }
|
||||
|
||||
export async function reconcileAgiDeclaration(
|
||||
supabase: SupabaseClient,
|
||||
decl: PendingAgiDeclaration,
|
||||
opts: { reconciledBy: 'cron' | 'post-connect'; userId?: string },
|
||||
): Promise<AgiReconcileOutcome> {
|
||||
const companyId = decl.company_id
|
||||
const declarationId = decl.id
|
||||
const period = formatRedovisningsperiod('monthly', decl.period_year, decl.period_month)
|
||||
|
||||
// Auth resolution prefers system credentials (verified lasombud grant)
|
||||
// and falls back to the company's user token: kvittens polling is the
|
||||
// canonical case for the hybrid model, since the user signed at SKV
|
||||
// and their 65-minute session is usually long dead by the time the
|
||||
// kvittens exists.
|
||||
const resolved = await resolveReadAuth(supabase, companyId, {
|
||||
requires: 'lasombud',
|
||||
userId: opts.userId,
|
||||
})
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === 'needs_reconsent') {
|
||||
// A connection flagged needs_reconsent cannot heal on its own
|
||||
// (SKV's per-flow refresh tokens live 65 minutes): skip quietly
|
||||
// instead of failing the same declaration every run.
|
||||
return { status: 'expired_token', error: 'needs_reconsent' }
|
||||
}
|
||||
return { status: 'no_token' }
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!settings?.org_number) {
|
||||
return { status: 'no_company_settings' }
|
||||
}
|
||||
|
||||
const arbetsgivare = formatRedovisare(
|
||||
settings.org_number as string,
|
||||
settings.entity_type as 'enskild_firma' | 'aktiebolag',
|
||||
)
|
||||
|
||||
const kvittRes = await agiGetKvittenser(resolved.auth, arbetsgivare, period)
|
||||
if (!kvittRes.ok) {
|
||||
return { status: 'error', error: kvittRes.error }
|
||||
}
|
||||
|
||||
const kvittens = kvittRes.data.kvittenser?.[0]
|
||||
if (!kvittens?.uuidKvittens) {
|
||||
return { status: 'still_pending' }
|
||||
}
|
||||
|
||||
// The presence of uuidKvittens confirms SKV signed and accepted
|
||||
// the AGI. signeradTid is the precise signing moment; if SKV omits
|
||||
// it we fall back to reconciliation time + warn so the discrepancy
|
||||
// is investigable. Leaving NULL would hide that the filing occurred
|
||||
// at all, which itself misstates behandlingshistorik (BFNAR 2013:2
|
||||
// kap 8 / BFL 5 kap 6§). The fallback only applies on this code
|
||||
// path because we're inside the kvittens-found branch above.
|
||||
// The fallback is an upper bound (reconciliation ran after signing)
|
||||
// recorded to keep behandlingshistorik complete; response_data.signeradTid
|
||||
// stays null and submittedAtEstimated=true marks the estimate so it is
|
||||
// never mistaken for the legal filing time.
|
||||
const submittedAt = kvittens.signeradTid || new Date().toISOString()
|
||||
if (!kvittens.signeradTid) {
|
||||
log.warn('kvittens missing signeradTid; using reconciliation time', {
|
||||
declarationId, companyId, period,
|
||||
})
|
||||
}
|
||||
|
||||
// submitted_by records the TECHNICAL submitter: the token-owning
|
||||
// auth.users row, i.e. the human who connected via BankID. The LEGAL
|
||||
// signatory is response_data.signeradAv from the kvittens (a
|
||||
// personnummer), which the token user_id does NOT necessarily match
|
||||
// (e.g. if the connected user is a bookkeeper but the
|
||||
// deklarationsombud signed). signeradAv is the authoritative
|
||||
// reference for the audit trail (BFL 5 kap 6§, BFNAR 2013:2 kap 8);
|
||||
// we preserve the full kvittens in response_data so it records the
|
||||
// actual BankID signer regardless of who triggered the reconciliation.
|
||||
// Compare-and-set claim: the 2-hourly cron and a post-connect refresh can
|
||||
// race on the same declaration, and the side effects below (salary_runs
|
||||
// stamp, cache cleanup, deadline confirmation, notification) must run
|
||||
// exactly once. Zero updated rows means another run won the claim; a
|
||||
// failed update must not fall through to those side effects either.
|
||||
const { data: claimed, error: claimError } = await supabase
|
||||
.from('agi_declarations')
|
||||
.update({
|
||||
status: 'submitted',
|
||||
kvittensnummer: kvittens.uuidKvittens,
|
||||
submitted_at: submittedAt,
|
||||
submitted_by: resolved.tokenUserId,
|
||||
response_data: {
|
||||
signeradAv: kvittens.signeradAv ?? null,
|
||||
signeradTid: kvittens.signeradTid ?? null,
|
||||
submittedAtEstimated: !kvittens.signeradTid,
|
||||
uuidKvittens: kvittens.uuidKvittens,
|
||||
arbetsgivare: kvittens.arbetsgivare ?? null,
|
||||
period: kvittens.period ?? null,
|
||||
underlag: kvittens.underlag ?? null,
|
||||
reconciledBy: opts.reconciledBy,
|
||||
},
|
||||
})
|
||||
.eq('id', declarationId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending_signature')
|
||||
.select('id')
|
||||
|
||||
if (claimError) {
|
||||
return { status: 'error', error: `Kunde inte uppdatera deklarationen: ${claimError.message}` }
|
||||
}
|
||||
if (!claimed || claimed.length === 0) {
|
||||
return { status: 'already_claimed' }
|
||||
}
|
||||
|
||||
if (decl.salary_run_id) {
|
||||
const { error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({ agi_submitted_at: submittedAt })
|
||||
.eq('id', decl.salary_run_id)
|
||||
.eq('company_id', companyId)
|
||||
if (runError) {
|
||||
// The declaration is already promoted and cannot be unwound here; a
|
||||
// missing agi_submitted_at stamp must stay investigable (BFNAR 2013:2
|
||||
// kap 8 behandlingshistorik) without aborting the remaining steps.
|
||||
log.warn('salary_runs agi_submitted_at stamp failed after claim', {
|
||||
declarationId, companyId, period,
|
||||
message: runError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the locally-cached submission state so the panel doesn't
|
||||
// pop a stale "awaiting signature" view if the user revisits.
|
||||
// No declaration-id guard is needed here: the cache key is
|
||||
// deliberately period-scoped and agi_declarations is UNIQUE per
|
||||
// company+period, so no two declarations share a key and there is
|
||||
// no cross-declaration race to guard.
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', `agi_submission_${period}`)
|
||||
|
||||
// The declaration is already flipped to submitted above, and reconciliation
|
||||
// only revisits pending_signature rows: from here on everything is
|
||||
// best-effort. Each step gets its own try/catch so a failure is logged
|
||||
// as a warning without masking the successful filing or skipping the
|
||||
// remaining confirmation steps.
|
||||
|
||||
// The kvittens is the canonical filing receipt: confirm the period's
|
||||
// arbetsgivardeklaration deadline (terminal state).
|
||||
try {
|
||||
await completeTaxDeadline(
|
||||
supabase,
|
||||
companyId,
|
||||
['arbetsgivardeklaration'],
|
||||
`${decl.period_year}-${String(decl.period_month).padStart(2, '0')}`,
|
||||
'confirmed'
|
||||
)
|
||||
} catch (deadlineErr) {
|
||||
log.warn('completeTaxDeadline failed after successful filing', {
|
||||
declarationId, companyId, period,
|
||||
message: deadlineErr instanceof Error ? deadlineErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
|
||||
// Tell the user: signing happened at Skatteverket, often long after
|
||||
// they closed our tab, so this is the only confirmation they get.
|
||||
if (resolved.tokenUserId) {
|
||||
try {
|
||||
await sendKvittensNotification(supabase, {
|
||||
companyId,
|
||||
userId: resolved.tokenUserId,
|
||||
kind: 'agi',
|
||||
period,
|
||||
kvittensnummer: kvittens.uuidKvittens,
|
||||
referenceId: declarationId,
|
||||
})
|
||||
} catch (notifyErr) {
|
||||
log.warn('sendKvittensNotification failed after successful filing', {
|
||||
declarationId, companyId, period,
|
||||
message: notifyErr instanceof Error ? notifyErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'signed', kvittensnummer: kvittens.uuidKvittens }
|
||||
}
|
||||
@@ -1,23 +1,30 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { parseAgiPeriod } from './skattekonto-match'
|
||||
import { parseNumericAgiPeriod, SWEDISH_MONTH_NUMBERS } from './skattekonto-match'
|
||||
|
||||
const log = createLogger('agi-tax-settlement')
|
||||
|
||||
/**
|
||||
* Auto-settle AGI tax payments from booked skattekonto rows.
|
||||
*
|
||||
* When Skatteverket books the "Arbetsgivardeklaration YYYYMM" debit on the
|
||||
* skattekonto, the declared amount has been drawn from the account. If the
|
||||
* account is not in deficit at that point, the period's tax obligation is
|
||||
* settled: flip agi_declarations.tax_paid_at so the salary UI stops showing
|
||||
* the period as unpaid.
|
||||
* When Skatteverket books the AGI debit on the skattekonto, the declared
|
||||
* amount has been drawn from the account. If the account is not in deficit
|
||||
* at that point, the period's tax obligation is settled: flip
|
||||
* agi_declarations.tax_paid_at so the salary UI stops showing the period
|
||||
* as unpaid.
|
||||
*
|
||||
* The draw appears in two formats:
|
||||
* - test environment: one combined row "Arbetsgivardeklaration YYYYMM"
|
||||
* equal to total_tax + total_avgifter;
|
||||
* - production: two rows, "Avdragen skatt <månad> <år>" (= total_tax) and
|
||||
* "Arbetsgivaravgift <månad> <år>" (= total_avgifter).
|
||||
*
|
||||
* The rule is deliberately strict (deterministic, no inference):
|
||||
* - saldo must be >= 0: a deficit means something is still unpaid, so
|
||||
* nothing gets marked paid;
|
||||
* - the row's amount must equal total_tax + total_avgifter to the ore:
|
||||
* corrections and partial draws fall back to the manual mark-paid button.
|
||||
* - amounts must match to the ore, and the production pair requires exactly
|
||||
* one row of each kind: corrections, partial draws and duplicate rows fall
|
||||
* back to the manual mark-paid button.
|
||||
*/
|
||||
|
||||
export interface SettleableSkattekontoRow {
|
||||
@@ -39,6 +46,88 @@ function periodKey(year: number, month: number): string {
|
||||
return `${year}-${String(month).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const MONTH_NAME_ALTERNATION = Object.keys(SWEDISH_MONTH_NUMBERS).join('|')
|
||||
|
||||
// Anchored to the whole text on purpose: production decision rows like
|
||||
// "Beslut 260703 arbetsgivaravgift mars 2026" carry the same keyword + period
|
||||
// but are corrections with arbitrary amounts and must never classify as the
|
||||
// period's regular draw.
|
||||
const TAX_DRAW_RE = new RegExp(
|
||||
`^avdragen skatt\\s+(${MONTH_NAME_ALTERNATION})\\s+(\\d{4})\\s*$`,
|
||||
'i',
|
||||
)
|
||||
const AVGIFT_DRAW_RE = new RegExp(
|
||||
`^arbetsgivaravgift\\s+(${MONTH_NAME_ALTERNATION})\\s+(\\d{4})\\s*$`,
|
||||
'i',
|
||||
)
|
||||
|
||||
type DrawKind = 'combined' | 'tax' | 'avgift'
|
||||
|
||||
interface PeriodDraws {
|
||||
tax: SettleableSkattekontoRow[]
|
||||
avgift: SettleableSkattekontoRow[]
|
||||
combined: SettleableSkattekontoRow[]
|
||||
}
|
||||
|
||||
function classifyDraw(
|
||||
transaktionstext: string,
|
||||
): { kind: DrawKind; year: number; month: number } | null {
|
||||
const text = transaktionstext.trim()
|
||||
|
||||
for (const [kind, re] of [
|
||||
['tax', TAX_DRAW_RE],
|
||||
['avgift', AVGIFT_DRAW_RE],
|
||||
] as const) {
|
||||
const m = re.exec(text)
|
||||
if (m) {
|
||||
const month = SWEDISH_MONTH_NUMBERS[m[1].toLowerCase()]
|
||||
const year = Number(m[2])
|
||||
if (month && year >= 2000 && year <= 2100) return { kind, year, month }
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const period = parseNumericAgiPeriod(text)
|
||||
if (period) return { kind: 'combined', ...period }
|
||||
return null
|
||||
}
|
||||
|
||||
function drawnOre(row: SettleableSkattekontoRow): number {
|
||||
return Math.round(Math.abs(row.belopp_skatteverket) * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* The date the period's obligation was settled, or null when the booked rows
|
||||
* don't deterministically prove full settlement: either one combined row
|
||||
* matching the whole declared amount, or exactly one tax row + exactly one
|
||||
* avgift row matching their respective totals (settled on the later of the
|
||||
* two dates).
|
||||
*/
|
||||
function resolveSettlementDate(
|
||||
draws: PeriodDraws,
|
||||
decl: AgiDeclarationRow,
|
||||
): string | null {
|
||||
const declaredTotal = Math.round((decl.total_tax + decl.total_avgifter) * 100)
|
||||
|
||||
const combinedMatch = draws.combined.find(r => drawnOre(r) === declaredTotal)
|
||||
if (combinedMatch) return combinedMatch.transaktionsdatum
|
||||
|
||||
if (draws.combined.length === 0 && draws.tax.length === 1 && draws.avgift.length === 1) {
|
||||
const declaredTax = Math.round(decl.total_tax * 100)
|
||||
const declaredAvgifter = Math.round(decl.total_avgifter * 100)
|
||||
if (
|
||||
drawnOre(draws.tax[0]) === declaredTax &&
|
||||
drawnOre(draws.avgift[0]) === declaredAvgifter
|
||||
) {
|
||||
return draws.tax[0].transaktionsdatum > draws.avgift[0].transaktionsdatum
|
||||
? draws.tax[0].transaktionsdatum
|
||||
: draws.avgift[0].transaktionsdatum
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip tax_paid_at for AGI declarations whose skattekonto debit row is booked.
|
||||
* Returns the number of declarations settled. Never throws: settlement is a
|
||||
@@ -53,20 +142,29 @@ export async function settleAgiTaxPayments(
|
||||
try {
|
||||
if (saldoSkatteverket < 0) return 0
|
||||
|
||||
// Debit rows (money drawn from the account) carrying an AGI period token.
|
||||
const candidates = bookedRows
|
||||
.map(row => {
|
||||
if (row.belopp_skatteverket >= 0) return null
|
||||
const period = parseAgiPeriod(row.transaktionstext)
|
||||
if (!period) return null
|
||||
return { row, period }
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null)
|
||||
// Debit rows (money drawn from the account) that classify as an AGI draw,
|
||||
// grouped per period so the production tax/avgift pair settles together.
|
||||
const drawsByPeriod = new Map<string, PeriodDraws>()
|
||||
const periods = new Map<string, { year: number; month: number }>()
|
||||
|
||||
if (candidates.length === 0) return 0
|
||||
for (const row of bookedRows) {
|
||||
if (row.belopp_skatteverket >= 0) continue
|
||||
const draw = classifyDraw(row.transaktionstext)
|
||||
if (!draw) continue
|
||||
const key = periodKey(draw.year, draw.month)
|
||||
let bucket = drawsByPeriod.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { tax: [], avgift: [], combined: [] }
|
||||
drawsByPeriod.set(key, bucket)
|
||||
periods.set(key, { year: draw.year, month: draw.month })
|
||||
}
|
||||
bucket[draw.kind].push(row)
|
||||
}
|
||||
|
||||
const years = Array.from(new Set(candidates.map(c => c.period.year)))
|
||||
const months = Array.from(new Set(candidates.map(c => c.period.month)))
|
||||
if (drawsByPeriod.size === 0) return 0
|
||||
|
||||
const years = Array.from(new Set(Array.from(periods.values()).map(p => p.year)))
|
||||
const months = Array.from(new Set(Array.from(periods.values()).map(p => p.month)))
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agi_declarations')
|
||||
@@ -90,19 +188,35 @@ export async function settleAgiTaxPayments(
|
||||
}
|
||||
|
||||
let settled = 0
|
||||
const settledIds = new Set<string>()
|
||||
|
||||
for (const { row, period } of candidates) {
|
||||
const decl = declarationsByPeriod.get(periodKey(period.year, period.month))
|
||||
if (!decl || settledIds.has(decl.id)) continue
|
||||
for (const [key, draws] of drawsByPeriod) {
|
||||
const decl = declarationsByPeriod.get(key)
|
||||
if (!decl) continue
|
||||
|
||||
const drawn = Math.round(Math.abs(row.belopp_skatteverket) * 100)
|
||||
const declared = Math.round((decl.total_tax + decl.total_avgifter) * 100)
|
||||
if (drawn !== declared) continue
|
||||
const paidDate = resolveSettlementDate(draws, decl)
|
||||
if (!paidDate) {
|
||||
// Refusing to settle must leave a trace: a rounding divergence between
|
||||
// the stored declaration totals and SKV's actual draw would otherwise
|
||||
// block auto-settlement for the period with nothing to diagnose.
|
||||
// Amounts only, in ore; transaction texts can carry personal data.
|
||||
log.info('agi settlement candidates did not match declared amounts', {
|
||||
companyId,
|
||||
period: key,
|
||||
declaredTaxOre: Math.round(decl.total_tax * 100),
|
||||
declaredAvgifterOre: Math.round(decl.total_avgifter * 100),
|
||||
taxDrawsOre: draws.tax.map(r => drawnOre(r)),
|
||||
avgiftDrawsOre: draws.avgift.map(r => drawnOre(r)),
|
||||
combinedDrawsOre: draws.combined.map(r => drawnOre(r)),
|
||||
taxDrawCount: draws.tax.length,
|
||||
avgiftDrawCount: draws.avgift.length,
|
||||
combinedDrawCount: draws.combined.length,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('agi_declarations')
|
||||
.update({ tax_paid_at: `${row.transaktionsdatum}T00:00:00Z` })
|
||||
.update({ tax_paid_at: `${paidDate}T00:00:00Z` })
|
||||
.eq('id', decl.id)
|
||||
.eq('company_id', companyId)
|
||||
.is('tax_paid_at', null) // guard against concurrent settles
|
||||
@@ -116,7 +230,6 @@ export async function settleAgiTaxPayments(
|
||||
continue
|
||||
}
|
||||
|
||||
settledIds.add(decl.id)
|
||||
settled++
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { syncSkattekonto } from './skattekonto-sync'
|
||||
import { reconcileAgiDeclaration, type PendingAgiDeclaration } from './agi-kvittens-reconcile'
|
||||
|
||||
const log = createLogger('skatteverket-post-connect')
|
||||
|
||||
/**
|
||||
* Refresh Skatteverket-derived data right after a successful BankID consent.
|
||||
*
|
||||
* SKV's `per`-flow tokens (and their refresh tokens) live ~65 minutes, so the
|
||||
* nightly skattekonto cron and the 2-hourly kvittens cron usually find them
|
||||
* dead: right after consent is the one reliable window where a personal-token
|
||||
* fetch is guaranteed to work. The OAuth callback awaits this before
|
||||
* responding, so when the popup closes the skattekonto rows are upserted, AGI
|
||||
* tax payments are auto-settled, and stuck pending-signature declarations have
|
||||
* had their kvittens re-checked; UI listeners can refetch without racing a
|
||||
* background job.
|
||||
*
|
||||
* Best-effort by contract: every step has its own try/catch and this function
|
||||
* never throws. A refresh failure must never fail the connect that just
|
||||
* succeeded.
|
||||
*/
|
||||
export interface PostConnectRefreshResult {
|
||||
synced: boolean
|
||||
reconciled: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound the kvittens re-checks so a pathological backlog cannot stall the
|
||||
* OAuth callback: 12 covers a full year of monthly AGI periods.
|
||||
*/
|
||||
const MAX_KVITTENS_CHECKS = 12
|
||||
|
||||
export async function runPostConnectRefresh(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
): Promise<PostConnectRefreshResult> {
|
||||
const result: PostConnectRefreshResult = { synced: false, reconciled: 0 }
|
||||
|
||||
try {
|
||||
if (!(await hasCapability(supabase, companyId, CAPABILITY.skatteverket))) {
|
||||
return result
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('post-connect capability check failed', {
|
||||
companyId,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
||||
|
||||
try {
|
||||
await syncSkattekonto(ctx)
|
||||
result.synced = true
|
||||
} catch (err) {
|
||||
log.warn('post-connect skattekonto sync failed', {
|
||||
companyId,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: pending, error: pendingError } = await supabase
|
||||
.from('agi_declarations')
|
||||
.select('id, company_id, salary_run_id, period_year, period_month')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending_signature')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(MAX_KVITTENS_CHECKS)
|
||||
|
||||
// Supabase returns query failures as a value, not a throw: without this
|
||||
// branch a transient lookup failure would masquerade as "no pending rows".
|
||||
if (pendingError) {
|
||||
log.warn('post-connect kvittens lookup failed', {
|
||||
companyId,
|
||||
message: pendingError.message,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
for (const decl of (pending ?? []) as PendingAgiDeclaration[]) {
|
||||
try {
|
||||
const outcome = await reconcileAgiDeclaration(supabase, decl, {
|
||||
reconciledBy: 'post-connect',
|
||||
userId,
|
||||
})
|
||||
if (outcome.status === 'signed') {
|
||||
result.reconciled++
|
||||
} else if (outcome.status === 'error') {
|
||||
// Non-throw failures (SKV HTTP errors, claim-update failures) are
|
||||
// returned as an outcome; surface them so they stay attributable.
|
||||
log.warn('post-connect kvittens reconcile returned error', {
|
||||
companyId,
|
||||
declarationId: decl.id,
|
||||
message: outcome.error,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('post-connect kvittens reconcile failed', {
|
||||
companyId,
|
||||
declarationId: decl.id,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('post-connect kvittens lookup failed', {
|
||||
companyId,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -59,21 +59,75 @@ export interface SkattekontoMatchCandidate {
|
||||
matched_via_agi_period?: boolean
|
||||
}
|
||||
|
||||
/** Swedish month names exactly as SKV writes them in prod transaktionstext. */
|
||||
export const SWEDISH_MONTH_NUMBERS: Record<string, number> = {
|
||||
januari: 1,
|
||||
februari: 2,
|
||||
mars: 3,
|
||||
april: 4,
|
||||
maj: 5,
|
||||
juni: 6,
|
||||
juli: 7,
|
||||
augusti: 8,
|
||||
september: 9,
|
||||
oktober: 10,
|
||||
november: 11,
|
||||
december: 12,
|
||||
}
|
||||
|
||||
const MONTH_NAME_ALTERNATION = Object.keys(SWEDISH_MONTH_NUMBERS).join('|')
|
||||
|
||||
// Production skattekonto rows write the period as "<keyword> <månad> <år>"
|
||||
// ("Avdragen skatt maj 2026", "Arbetsgivaravgift maj 2026"); the numeric
|
||||
// "Arbetsgivardeklaration 202605" form is what the SKV test environment uses.
|
||||
const MONTH_NAME_PERIOD_RE = new RegExp(
|
||||
`(?:arbetsgivardeklaration|arbetsgivaravgift|avdragen skatt|\\bagi\\b)\\s+(${MONTH_NAME_ALTERNATION})\\s+(\\d{4})\\b`,
|
||||
'i',
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse an AGI period from a Skatteverket transaktionstext.
|
||||
*
|
||||
* Examples that match:
|
||||
* "Arbetsgivardeklaration 202605"
|
||||
* "Arbetsgivardeklaration 202605" (test environment)
|
||||
* "arbetsgivardeklaration 2026-05"
|
||||
* "AGI 202605"
|
||||
* "Avdragen skatt maj 2026" (production)
|
||||
* "Arbetsgivaravgift maj 2026" (production)
|
||||
* "Beslut 260703 arbetsgivaravgift mars 2026" (production beslut rows)
|
||||
*
|
||||
* Beslut rows parsing to their period is intentional (audited): it lets match
|
||||
* suggestions period-boost correction rows too. This is safe because (a) the
|
||||
* settlement module never uses parseAgiPeriod; it classifies with its own
|
||||
* start-anchored regexes and parseNumericAgiPeriod only, so a beslut row can
|
||||
* never mark a period paid, and (b) the only production callers are
|
||||
* findMatchSuggestionsBulk and findMatchCandidates in this file, both of which
|
||||
* require an exact amount+side match on a 1630 line before suggesting anything,
|
||||
* so a beslut row can only ever be suggested against an entry carrying exactly
|
||||
* the beslut's amount.
|
||||
*
|
||||
* Returns null when no period token is present or the value is out of range.
|
||||
*/
|
||||
export function parseAgiPeriod(
|
||||
transaktionstext: string,
|
||||
): { year: number; month: number } | null {
|
||||
return (
|
||||
parseNumericAgiPeriod(transaktionstext) ??
|
||||
parseMonthNameAgiPeriod(transaktionstext)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The numeric-token subset of parseAgiPeriod ("Arbetsgivardeklaration 202605",
|
||||
* "AGI 2026-05"). Exported separately because the settlement's combined-row
|
||||
* classifier must stay pinned to this form: the month-name form always means
|
||||
* the split tax/avgift rows, which settle pairwise.
|
||||
*
|
||||
* The fallback (numeric YYYYMM after any AGI keyword) covers older SKV variants
|
||||
* that omit the leading word but still place the period adjacent to "AGI" or
|
||||
* "arbetsgivaravgift" elsewhere in the row.
|
||||
*/
|
||||
export function parseAgiPeriod(
|
||||
export function parseNumericAgiPeriod(
|
||||
transaktionstext: string,
|
||||
): { year: number; month: number } | null {
|
||||
const text = transaktionstext.toLowerCase()
|
||||
@@ -98,6 +152,17 @@ export function parseAgiPeriod(
|
||||
return null
|
||||
}
|
||||
|
||||
function parseMonthNameAgiPeriod(
|
||||
transaktionstext: string,
|
||||
): { year: number; month: number } | null {
|
||||
const m = MONTH_NAME_PERIOD_RE.exec(transaktionstext)
|
||||
if (!m) return null
|
||||
const month = SWEDISH_MONTH_NUMBERS[m[1].toLowerCase()]
|
||||
const year = Number(m[2])
|
||||
if (month && isValidPeriod(year, month)) return { year, month }
|
||||
return null
|
||||
}
|
||||
|
||||
function isValidPeriod(year: number, month: number): boolean {
|
||||
return Number.isFinite(year) && Number.isFinite(month)
|
||||
&& year >= 2000 && year <= 2100
|
||||
|
||||
@@ -216,8 +216,10 @@ export async function syncSkattekonto(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-settle AGI tax payments: when the "Arbetsgivardeklaration YYYYMM"
|
||||
// debit is booked and the account is not in deficit, the period is paid.
|
||||
// Auto-settle AGI tax payments: when the period's AGI debit is booked
|
||||
// (one combined "Arbetsgivardeklaration YYYYMM" row in the test
|
||||
// environment, the "Avdragen skatt" + "Arbetsgivaravgift" pair in prod)
|
||||
// and the account is not in deficit, the period is paid.
|
||||
// Best-effort inside (never throws).
|
||||
await settleAgiTaxPayments(
|
||||
ctx.supabase,
|
||||
|
||||
+1
-1
@@ -1921,7 +1921,7 @@
|
||||
"toast_disconnect_failed": "Could not disconnect",
|
||||
"loading_status": "Loading status…",
|
||||
"disabled_message": "The Skatteverket integration is temporarily disabled. Contact support.",
|
||||
"connect_intro": "Connect to Skatteverket with BankID to submit VAT declarations, employer declarations and fetch the tax account balance.",
|
||||
"connect_intro": "Connect to Skatteverket with BankID to submit VAT declarations, employer declarations and fetch the tax account balance. When you connect, we immediately fetch the company's tax account balance and transactions and check for pending employer declaration receipts (kvittenser) at Skatteverket.",
|
||||
"skahmst_note": "On Skatteverket's consent page one of the permissions appears as <code>skahmst (Rubrik saknas)</code>: that's the scope name for tax account balance and transactions (Skattekonto HuvudMan STatus). Skatteverket has not yet published a Swedish description. It's safe to approve.",
|
||||
"connect_with_bankid": "Connect with BankID",
|
||||
"expired": "Expired",
|
||||
|
||||
+1
-1
@@ -1921,7 +1921,7 @@
|
||||
"toast_disconnect_failed": "Kunde inte koppla från",
|
||||
"loading_status": "Hämtar status…",
|
||||
"disabled_message": "Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.",
|
||||
"connect_intro": "Anslut till Skatteverket med BankID för att skicka momsdeklaration, arbetsgivardeklaration och hämta saldot på skattekontot.",
|
||||
"connect_intro": "Anslut till Skatteverket med BankID för att skicka momsdeklaration, arbetsgivardeklaration och hämta saldot på skattekontot. När du ansluter hämtar vi direkt företagets skattekontosaldo och transaktioner och kontrollerar om det finns väntande kvittenser för arbetsgivardeklarationer hos Skatteverket.",
|
||||
"skahmst_note": "På Skatteverkets samtyckessida visas en av behörigheterna som <code>skahmst (Rubrik saknas)</code>: det är scope-namnet för skattekontots saldo och transaktioner (Skattekonto HuvudMan STatus). Skatteverket har inte publicerat en svensk beskrivning för den ännu. Det är ofarligt att godkänna.",
|
||||
"connect_with_bankid": "Anslut med BankID",
|
||||
"expired": "Utgången",
|
||||
|
||||
Reference in New Issue
Block a user