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:
Mattsson
2026-07-13 01:40:03 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f04dc4c4e0
commit aab7e47c35
19 changed files with 1433 additions and 239 deletions
@@ -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()
})
})
// ──────────────────────────────────────────────────────────────────────
+62 -9
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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,