feat(skatteverket): ombudsregister grant verification, honest session expiry, daily ombud sync (#2130)
* feat(skatteverket): ombudsregister grant verification, honest session expiry, daily ombud sync
Users reported the Skatteverket connection "just disappearing" with no
banner, needing BankID again every time. Two causes, both fixed here:
1. SKV's per-flow refresh token lives 65 minutes. /status and the
skv_disconnected notice called any stored refresh token "refreshable",
so a days-dead session reported healthy and the reconnect banner never
fired until a submission failed live. lib/skatteverket/session-lifetime
now decides refreshability (expires_at + 5 min, refresh cap) for both
surfaces; the settings panel states the one-hour session lifetime.
2. The durable fix is the ombud (system certificate) path, dormant since
July behind SKATTEVERKET_SYSTEM_AUTH_MODE. Skatteverket added scope
`obr` (Ombudshantering v2) to our application id on 2026-09-01, so grant
verification can now ask the ombudsregister instead of classifying 403s
from the read services:
- lib/ombud-client.ts: GET /ombud/autentisieratOmbud, GET /roller,
POST .../djuplank/utseombud, on the system identity, per the public
tjanstebeskrivning v2.0 (mirrored in dev_docs/skatteverket/ombudshantering).
Role codes are env-pinned (SKATTEVERKET_OMBUD_ROLL_LASOMBUD/_MOMS) or
matched on rollbeskrivning text; a deep link never mints with a
guessed code.
- grant-probe.ts: register first, read-service probes only as fallback.
- New daily cron /api/extensions/skatteverket/ombud/sync/cron (30 3 * * *):
one register call discovers every company that granted us, creates or
downgrades connection rows by org number, runs from shadow mode on,
and never mass-revokes on an empty register.
- POST /system-connection/deeplink + "Utse {app} som ombud" button:
the company lands in SKV's e-service with roles pre-selected.
- Default system scopes include `obr`; skvRequestWithAuth gains an
`accept` option (Ombudshantering requires the Accept header).
Still inert in prod until the org certificate and avtal land; the cron and
verify routes no-op while system auth is off or unconfigured.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
* fix(skatteverket): skeptic round on ombud sync, register 404 fallback, opt-in-only rows, mass-downgrade guard
Cron touches only existing connection rows (a tenant's own Verifiera or
deep-link opt-in; the deeplink route now records a pending row), so an
org-number twin never gets auto-verified, and rows the tenant revoked
locally stay revoked. A register 404 throws by default (spec: wrong URI)
and is empty only for the cron, which guards it. Decisions are planned
before any upsert; a run that would fully deny >= 3 rows and > 50% of the
granted ones applies no downgrade. Grants that classify as neither
behörighet are 'error', not 'denied'. Literal select in listConnections for
the phantom-column scanner. window.open without 'noopener' so the
pre-opened tab exists; opener nulled by hand. Deeplink route test added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
* fix(skatteverket): CodeRabbit round: exact role labels, paginate connections, deny never-listed rows, fail deeplink without opt-in row
Role descriptions match the whole label so 'Momsdeklaration,
deklarationsombud' is never read as the narrow moms role. listConnections
pages through fetchAllRows on (created_at, id). A pending row the register
never lists is written once as denied instead of staying 'Inte verifierad'.
The deeplink route returns 500 when the opt-in row cannot be stored, and the
panel navigates in-tab when the pre-opened tab was blocked.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
* fix(skatteverket): fence ombud grants on contested org numbers; cron honours unrecognised role codes
An org number claimed by more than one live company is contested: verify
and deep link answer 409 ORG_NUMBER_CONTESTED and the nightly sync changes
nothing on it, so a tenant that typed a victim's public org number cannot
inherit the victim's grant. The sync also skips huvudmän whose register
roles classify as neither behörighet (pinning problem, never a denial),
mirroring probeViaOmbudsregister.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
* fix(skatteverket): validate the ombud deep link host; withdraw grants on contested org numbers
The register's djuplank must be an https skatteverket.se URL before it is
returned or navigated to (the settings page follows it). The nightly sync
now withdraws a grant already recorded on an org number that more than one
live company claims, instead of only refusing new ones.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d8cf78330e
commit
8b09b06e14
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* POST /system-connection/deeplink: gating (capability, role, system-auth
|
||||
* mode), org-number derivation server-side, the opt-in row it records, and
|
||||
* the OmbudApiError -> 502 mapping.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockRequireCapability = vi.fn()
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return { ...actual, requireCapability: (...a: unknown[]) => mockRequireCapability(...a) }
|
||||
})
|
||||
|
||||
const mockCreateDeepLink = vi.fn()
|
||||
vi.mock('../lib/ombud-client', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return { ...actual, createUtseOmbudDeepLink: (...a: unknown[]) => mockCreateDeepLink(...a) }
|
||||
})
|
||||
|
||||
const mockRecordProbeResult = vi.fn()
|
||||
const mockContested = vi.fn(async (_orgNumber: string) => false)
|
||||
vi.mock('../lib/connection-store', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
recordProbeResult: (...a: unknown[]) => mockRecordProbeResult(...a),
|
||||
isOrgNumberContested: (orgNumber: string) => mockContested(orgNumber),
|
||||
}
|
||||
})
|
||||
|
||||
const mockWriteAudit = vi.fn()
|
||||
vi.mock('../lib/audit', () => ({ writeSkatteverketAudit: (...a: unknown[]) => mockWriteAudit(...a) }))
|
||||
|
||||
import { skatteverketExtension } from '../index'
|
||||
import { OmbudApiError } from '../lib/ombud-client'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
const ENV_KEYS = ['SKATTEVERKET_SYSTEM_AUTH_MODE', 'SKATTEVERKET_SYSTEM_AUTH_MECHANISM']
|
||||
let savedEnv: Record<string, string | undefined>
|
||||
|
||||
function findRoute() {
|
||||
const route = skatteverketExtension.apiRoutes?.find(
|
||||
(r) => r.method === 'POST' && r.path === '/system-connection/deeplink'
|
||||
)
|
||||
if (!route) throw new Error('deeplink route not registered')
|
||||
return route
|
||||
}
|
||||
|
||||
/** Supabase stub: company_members role lookup, then company_settings org number. */
|
||||
function makeContext(opts: { role?: string; orgNumber?: string | null } = {}): ExtensionContext {
|
||||
const role = opts.role ?? 'owner'
|
||||
const orgNumber = opts.orgNumber === undefined ? '556000-0000' : opts.orgNumber
|
||||
const from = vi.fn((table: string) => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
const self = () => chain
|
||||
for (const m of ['select', 'eq', 'order', 'limit']) chain[m] = vi.fn(self)
|
||||
if (table === 'company_members') {
|
||||
chain.single = vi.fn(async () => ({ data: { role }, error: null }))
|
||||
chain.maybeSingle = vi.fn(async () => ({ data: { role }, error: null }))
|
||||
} else if (table === 'company_settings') {
|
||||
chain.single = vi.fn(async () => ({
|
||||
data: orgNumber ? { org_number: orgNumber, entity_type: 'aktiebolag' } : { org_number: null, entity_type: 'aktiebolag' },
|
||||
error: null,
|
||||
}))
|
||||
chain.maybeSingle = chain.single
|
||||
} else {
|
||||
chain.single = vi.fn(async () => ({ data: null, error: null }))
|
||||
chain.maybeSingle = chain.single
|
||||
}
|
||||
return chain
|
||||
})
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'skatteverket',
|
||||
requestId: 'req_test',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: { from } as any,
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
const request = () =>
|
||||
new Request('http://localhost/api/extensions/ext/skatteverket/system-connection/deeplink', { method: 'POST' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]))
|
||||
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'on'
|
||||
process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM = 'stub'
|
||||
mockRequireCapability.mockResolvedValue(null)
|
||||
mockRecordProbeResult.mockResolvedValue({ id: 'conn-1', status: 'pending' })
|
||||
mockContested.mockResolvedValue(false)
|
||||
mockWriteAudit.mockResolvedValue(undefined)
|
||||
mockCreateDeepLink.mockResolvedValue({
|
||||
djuplank: 'https://sso.skatteverket.se/ombud?x=1',
|
||||
roller: { lasombud: 'JLO', moms_ombud: 'MOMS' },
|
||||
expiresOn: '2026-09-22',
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = savedEnv[k]
|
||||
}
|
||||
})
|
||||
|
||||
describe('POST /system-connection/deeplink', () => {
|
||||
it('500 without an extension context', async () => {
|
||||
const res = await findRoute().handler(request())
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
|
||||
it('refuses when the capability gate blocks', async () => {
|
||||
const { NextResponse } = await import('next/server')
|
||||
mockRequireCapability.mockResolvedValue(NextResponse.json({ error: 'capability_blocked' }, { status: 403 }))
|
||||
const res = await findRoute().handler(request(), makeContext())
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockCreateDeepLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a viewer (AGI write role required)', async () => {
|
||||
const res = await findRoute().handler(request(), makeContext({ role: 'viewer' }))
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockCreateDeepLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('503 while system auth is off', async () => {
|
||||
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'off'
|
||||
const res = await findRoute().handler(request(), makeContext())
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
|
||||
it('400 when the company has no org number', async () => {
|
||||
const res = await findRoute().handler(request(), makeContext({ orgNumber: null }))
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockCreateDeepLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mints the link for the company\'s own 12-digit org number, records the opt-in row, audits', async () => {
|
||||
const res = await findRoute().handler(request(), makeContext())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
data: {
|
||||
djuplank: 'https://sso.skatteverket.se/ombud?x=1',
|
||||
roller: { lasombud: 'JLO', moms_ombud: 'MOMS' },
|
||||
expires_on: '2026-09-22',
|
||||
},
|
||||
})
|
||||
expect(mockCreateDeepLink).toHaveBeenCalledWith('165560000000', ['lasombud', 'moms_ombud'])
|
||||
expect(mockRecordProbeResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ companyId: 'company-1', orgNumber: '165560000000', createdBy: 'user-1', error: null })
|
||||
)
|
||||
// No grant state is asserted by minting a link.
|
||||
expect(mockRecordProbeResult.mock.calls[0][0]).not.toHaveProperty('lasombud')
|
||||
expect(mockWriteAudit).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ endpoint: 'system-connection/deeplink', agRegistreradId: '165560000000', outcome: 'ok' })
|
||||
)
|
||||
})
|
||||
|
||||
it('409 ORG_NUMBER_CONTESTED when another live company claims the same org number: no link, no row', async () => {
|
||||
mockContested.mockResolvedValue(true)
|
||||
const res = await findRoute().handler(request(), makeContext())
|
||||
expect(res.status).toBe(409)
|
||||
expect(await res.json()).toMatchObject({ code: 'ORG_NUMBER_CONTESTED' })
|
||||
expect(mockContested).toHaveBeenCalledWith('165560000000')
|
||||
expect(mockCreateDeepLink).not.toHaveBeenCalled()
|
||||
expect(mockRecordProbeResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('500 when the opt-in row cannot be stored: the link is not handed out', async () => {
|
||||
mockRecordProbeResult.mockResolvedValue(null)
|
||||
const res = await findRoute().handler(request(), makeContext())
|
||||
expect(res.status).toBe(500)
|
||||
expect(mockWriteAudit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('502 with the error code when the register refuses or the role codes are unresolved', async () => {
|
||||
mockCreateDeepLink.mockRejectedValue(new OmbudApiError('Rollkod saknas', 'OBR_ROLE_UNRESOLVED'))
|
||||
const res = await findRoute().handler(request(), makeContext())
|
||||
expect(res.status).toBe(502)
|
||||
expect(await res.json()).toMatchObject({ code: 'OBR_ROLE_UNRESOLVED' })
|
||||
expect(mockRecordProbeResult).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Grant-probe classification: 200 / felkod 3 / OMBUD_GRANT_MISSING / 404 /
|
||||
* transient failures, and the transient-error-never-downgrades rule (which
|
||||
* lives in connection-store's recordProbeResult and is asserted through the
|
||||
* recorded input here + directly below).
|
||||
* Grant verification: the ombudsregister (Ombudshantering v2) decides when it
|
||||
* answers; the read-service probes (200 / felkod 3 / OMBUD_GRANT_MISSING /
|
||||
* 404 / transient) decide only when the register cannot be consulted. The
|
||||
* transient-error-never-downgrades rule lives in connection-store's
|
||||
* recordProbeResult and is asserted through the recorded input here.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
@@ -24,70 +25,212 @@ vi.mock('../lib/connection-store', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
import { probeCompanyGrants } from '../lib/grant-probe'
|
||||
import { probeCompanyGrants, probeViaOmbudsregister } from '../lib/grant-probe'
|
||||
import { SkatteverketAuthError } from '../lib/api-client'
|
||||
|
||||
const ORG = '165560000000'
|
||||
const TODAY = '2026-09-01'
|
||||
|
||||
/** The register call is always first; make it fail so the service probes decide. */
|
||||
function registryUnavailable() {
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 503, text: async () => 'down' })
|
||||
}
|
||||
|
||||
function registryAnswers(posts: unknown[]) {
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => posts })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRecordProbeResult.mockResolvedValue({ id: 'conn-1', status: 'verified' })
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('probeCompanyGrants', () => {
|
||||
describe('probeCompanyGrants via the ombudsregister', () => {
|
||||
it('both roles active today -> both granted, source registry, no service probes', async () => {
|
||||
registryAnswers([
|
||||
{ huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', ombud: '165590000000', giltigFrom: '2026-07-19' },
|
||||
{ huvudman: ORG, roll: 'MOMS', rollbeskrivning: 'Momsdeklaration, ombud', ombud: '165590000000', giltigFrom: '2026-07-19', giltigTom: '' },
|
||||
])
|
||||
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.source).toBe('registry')
|
||||
expect(result.lasombud.status).toBe('granted')
|
||||
expect(result.momsOmbud.status).toBe('granted')
|
||||
expect(mockSkvRequestWithAuth).toHaveBeenCalledTimes(1)
|
||||
// System identity, ombud base URL, Accept header, huvudman filter.
|
||||
const [auth, method, path, , options] = mockSkvRequestWithAuth.mock.calls[0]
|
||||
expect(auth).toEqual({ mode: 'system' })
|
||||
expect(method).toBe('GET')
|
||||
expect(path).toBe(`/ombud/autentisieratOmbud?huvudman=${ORG}`)
|
||||
expect(options).toMatchObject({
|
||||
baseUrl: 'https://api.test.skatteverket.se/behorighet/ombudshantering/v2',
|
||||
accept: 'application/json',
|
||||
})
|
||||
})
|
||||
|
||||
it('only läsombud granted -> moms denied with the roles listed in the detail', async () => {
|
||||
registryAnswers([
|
||||
{ huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-07-19' },
|
||||
{ huvudman: ORG, roll: 'DEKL', rollbeskrivning: 'Deklarationsombud', giltigFrom: '2026-07-19' },
|
||||
])
|
||||
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.lasombud.status).toBe('granted')
|
||||
expect(result.momsOmbud.status).toBe('denied')
|
||||
expect(result.momsOmbud.detail).toContain('JLO, DEKL')
|
||||
expect(mockRecordProbeResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lasombud: expect.objectContaining({ status: 'granted' }),
|
||||
momsOmbud: expect.objectContaining({ status: 'denied' }),
|
||||
error: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('a 200 with no posts for the huvudman -> both denied (the company never granted)', async () => {
|
||||
registryAnswers([])
|
||||
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.source).toBe('registry')
|
||||
expect(result.lasombud.status).toBe('denied')
|
||||
expect(result.momsOmbud.status).toBe('denied')
|
||||
})
|
||||
|
||||
it('a future-dated or expired grant does not count', async () => {
|
||||
const future = await probeViaOmbudsregisterWith([
|
||||
{ huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-12-01' },
|
||||
])
|
||||
expect(future.result?.lasombud.status).toBe('denied')
|
||||
|
||||
const expired = await probeViaOmbudsregisterWith([
|
||||
{ huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2025-01-01', giltigTom: '2026-08-31' },
|
||||
])
|
||||
expect(expired.result?.lasombud.status).toBe('denied')
|
||||
|
||||
const active = await probeViaOmbudsregisterWith([
|
||||
{ huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2025-01-01', giltigTom: '2026-09-01' },
|
||||
])
|
||||
expect(active.result?.lasombud.status).toBe('granted')
|
||||
})
|
||||
|
||||
it('a register 404 is a transport fault: falls back to the service probes instead of denying', async () => {
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '{"message":"Not found"}' })
|
||||
mockSkvRequestWithAuth
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.source).toBe('service')
|
||||
expect(result.lasombud.status).toBe('granted')
|
||||
expect(result.lasombud.detail).toContain('OBR_HTTP_ERROR')
|
||||
})
|
||||
|
||||
it('grants that classify as neither behörighet are an error (unrecognised role codes), never a denial', async () => {
|
||||
registryAnswers([
|
||||
{ huvudman: ORG, roll: 'ZZ1', rollbeskrivning: 'Läsombud, juridisk person', giltigFrom: '2026-07-19' },
|
||||
{ huvudman: ORG, roll: 'ZZ2', rollbeskrivning: 'Ombud för momsdeklaration', giltigFrom: '2026-07-19' },
|
||||
])
|
||||
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.source).toBe('registry')
|
||||
expect(result.lasombud.status).toBe('error')
|
||||
expect(result.momsOmbud.status).toBe('error')
|
||||
expect(result.lasombud.detail).toContain('ZZ1, ZZ2')
|
||||
expect(mockRecordProbeResult.mock.calls[0][0].error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a register 403 (scope/avtal missing) is an error, never a company denial', async () => {
|
||||
// api-client maps a system-mode 403 to OMBUD_GRANT_MISSING; on the register that is run-level.
|
||||
mockSkvRequestWithAuth.mockRejectedValueOnce(new SkatteverketAuthError('nope', 'OMBUD_GRANT_MISSING'))
|
||||
// Fallback service probes both succeed.
|
||||
mockSkvRequestWithAuth
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.source).toBe('service')
|
||||
expect(result.lasombud.status).toBe('granted')
|
||||
expect(result.lasombud.detail).toContain('OBR_FORBIDDEN')
|
||||
expect(mockSkvRequestWithAuth).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
async function probeViaOmbudsregisterWith(posts: unknown[]) {
|
||||
registryAnswers(posts)
|
||||
return probeViaOmbudsregister(ORG, TODAY)
|
||||
}
|
||||
|
||||
describe('probeCompanyGrants service-probe fallback', () => {
|
||||
it('both probes 200 -> both granted', async () => {
|
||||
registryUnavailable()
|
||||
mockSkvRequestWithAuth
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 }) // saldo
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 }) // utkast
|
||||
|
||||
const result = await probeCompanyGrants('company-1', '165560000000')
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.source).toBe('service')
|
||||
expect(result.lasombud.status).toBe('granted')
|
||||
expect(result.momsOmbud.status).toBe('granted')
|
||||
// Both calls ran on SYSTEM credentials.
|
||||
expect(mockSkvRequestWithAuth.mock.calls[0][0]).toEqual({ mode: 'system' })
|
||||
expect(mockSkvRequestWithAuth.mock.calls[1][0]).toEqual({ mode: 'system' })
|
||||
// All calls ran on SYSTEM credentials.
|
||||
for (const call of mockSkvRequestWithAuth.mock.calls) expect(call[0]).toEqual({ mode: 'system' })
|
||||
// The fallback reason travels in the detail for the settings panel/probe row.
|
||||
expect(result.lasombud.detail).toContain('ombudsregister otillgängligt')
|
||||
})
|
||||
|
||||
it('records the actual 2xx status as detail, not a hardcoded 200', async () => {
|
||||
registryUnavailable()
|
||||
mockSkvRequestWithAuth
|
||||
.mockResolvedValueOnce({ ok: true, status: 204 }) // saldo
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 }) // utkast
|
||||
|
||||
const result = await probeCompanyGrants('company-1', '165560000000')
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.lasombud).toEqual({ status: 'granted', detail: '204' })
|
||||
expect(result.momsOmbud).toEqual({ status: 'granted', detail: '200' })
|
||||
expect(result.lasombud.status).toBe('granted')
|
||||
expect(result.lasombud.detail.startsWith('204')).toBe(true)
|
||||
expect(result.momsOmbud.detail.startsWith('200')).toBe(true)
|
||||
})
|
||||
|
||||
it('felkod 3 (no skattekonto) still proves the lasombud authorization', async () => {
|
||||
registryUnavailable()
|
||||
mockSkvRequestWithAuth
|
||||
.mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({ felkod: 3 }) })
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 })
|
||||
|
||||
const result = await probeCompanyGrants('company-1', '165560000000')
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.lasombud.status).toBe('granted')
|
||||
// 404 on /utkast = no draft, but the gateway authorized us.
|
||||
expect(result.momsOmbud.status).toBe('granted')
|
||||
})
|
||||
|
||||
it('OMBUD_GRANT_MISSING classifies as denied', async () => {
|
||||
it('OMBUD_GRANT_MISSING on the read services classifies as denied', async () => {
|
||||
registryUnavailable()
|
||||
mockSkvRequestWithAuth
|
||||
.mockRejectedValueOnce(new SkatteverketAuthError('saknas', 'OMBUD_GRANT_MISSING'))
|
||||
.mockRejectedValueOnce(new SkatteverketAuthError('saknas', 'OMBUD_GRANT_MISSING'))
|
||||
|
||||
const result = await probeCompanyGrants('company-1', '165560000000')
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.lasombud.status).toBe('denied')
|
||||
expect(result.momsOmbud.status).toBe('denied')
|
||||
})
|
||||
|
||||
it('transient failures classify as error, never denied', async () => {
|
||||
registryUnavailable()
|
||||
mockSkvRequestWithAuth
|
||||
.mockRejectedValueOnce(new SkatteverketAuthError('overload', 'RATE_LIMITED'))
|
||||
.mockRejectedValueOnce(new Error('fetch failed'))
|
||||
|
||||
const result = await probeCompanyGrants('company-1', '165560000000')
|
||||
const result = await probeCompanyGrants('company-1', ORG)
|
||||
|
||||
expect(result.lasombud.status).toBe('error')
|
||||
expect(result.momsOmbud.status).toBe('error')
|
||||
@@ -97,16 +240,17 @@ describe('probeCompanyGrants', () => {
|
||||
})
|
||||
|
||||
it('persists the probe outcome via recordProbeResult', async () => {
|
||||
registryUnavailable()
|
||||
mockSkvRequestWithAuth
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
|
||||
await probeCompanyGrants('company-1', '165560000000', 'user-1')
|
||||
await probeCompanyGrants('company-1', ORG, 'user-1')
|
||||
|
||||
expect(mockRecordProbeResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: 'company-1',
|
||||
orgNumber: '165560000000',
|
||||
orgNumber: ORG,
|
||||
createdBy: 'user-1',
|
||||
lasombud: expect.objectContaining({ status: 'granted' }),
|
||||
momsOmbud: expect.objectContaining({ status: 'granted' }),
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Ombudshantering v2 client: base URL selection, role classification (pinned
|
||||
* code vs. description text), list envelope tolerance, 404-as-empty on the
|
||||
* list, the 403 remap, deep-link role resolution, and the pure grant helpers.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockSkvRequestWithAuth = vi.fn()
|
||||
const mockEnvironment = vi.fn(() => 'test')
|
||||
vi.mock('../lib/api-client', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
skvRequestWithAuth: (...a: unknown[]) => mockSkvRequestWithAuth(...a),
|
||||
getSkatteverketEnvironment: () => mockEnvironment(),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
classifyOmbudRole,
|
||||
createUtseOmbudDeepLink,
|
||||
getOmbudApiBaseUrl,
|
||||
getOmbudRoleDescriptions,
|
||||
isAllowedDeepLinkUrl,
|
||||
isGrantActive,
|
||||
listOmbudGrants,
|
||||
normalizeHuvudman,
|
||||
OmbudApiError,
|
||||
resolveOmbudRoleCodes,
|
||||
summarizeGrants,
|
||||
} from '../lib/ombud-client'
|
||||
import { SkatteverketAuthError } from '../lib/api-client'
|
||||
|
||||
const ENV_KEYS = ['SKATTEVERKET_OMBUD_API_BASE_URL', 'SKATTEVERKET_OMBUD_ROLL_LASOMBUD', 'SKATTEVERKET_OMBUD_ROLL_MOMS']
|
||||
let savedEnv: Record<string, string | undefined>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnvironment.mockReturnValue('test')
|
||||
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]))
|
||||
for (const k of ENV_KEYS) delete process.env[k]
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = savedEnv[k]
|
||||
}
|
||||
})
|
||||
|
||||
describe('getOmbudApiBaseUrl', () => {
|
||||
it('follows the SKV environment and honours the override', () => {
|
||||
expect(getOmbudApiBaseUrl()).toBe('https://api.test.skatteverket.se/behorighet/ombudshantering/v2')
|
||||
mockEnvironment.mockReturnValue('prod')
|
||||
expect(getOmbudApiBaseUrl()).toBe('https://api.skatteverket.se/behorighet/ombudshantering/v2')
|
||||
process.env.SKATTEVERKET_OMBUD_API_BASE_URL = 'https://example.test/obr/'
|
||||
expect(getOmbudApiBaseUrl()).toBe('https://example.test/obr')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyOmbudRole', () => {
|
||||
it('matches by description text when no code is pinned', () => {
|
||||
expect(classifyOmbudRole({ roll: 'X1', rollbeskrivning: 'Juridiskt läsombud' })).toBe('lasombud')
|
||||
expect(classifyOmbudRole({ roll: 'X2', rollbeskrivning: 'Momsdeklaration, ombud' })).toBe('moms_ombud')
|
||||
expect(classifyOmbudRole({ roll: 'X3', rollbeskrivning: 'Deklarationsombud' })).toBeNull()
|
||||
expect(classifyOmbudRole({ roll: 'X4' })).toBeNull()
|
||||
// Broader roles that merely contain the words are not the narrow ones.
|
||||
expect(classifyOmbudRole({ roll: 'X5', rollbeskrivning: 'Momsdeklaration, deklarationsombud' })).toBeNull()
|
||||
expect(classifyOmbudRole({ roll: 'X6', rollbeskrivning: 'Juridiskt läsombud och skattekonto' })).toBeNull()
|
||||
expect(classifyOmbudRole({ roll: 'X7', rollbeskrivning: ' momsdeklaration,ombud ' .trim() })).toBe('moms_ombud')
|
||||
})
|
||||
|
||||
it('a pinned code wins and disables the text fallback for that key', () => {
|
||||
process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO'
|
||||
expect(classifyOmbudRole({ roll: 'JLO', rollbeskrivning: 'whatever' })).toBe('lasombud')
|
||||
// Same wording, different code: not the pinned role.
|
||||
expect(classifyOmbudRole({ roll: 'OTHER', rollbeskrivning: 'Juridiskt läsombud' })).toBeNull()
|
||||
// The un-pinned key still falls back to text.
|
||||
expect(classifyOmbudRole({ roll: 'M', rollbeskrivning: 'Momsdeklaration, ombud' })).toBe('moms_ombud')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listOmbudGrants', () => {
|
||||
it('accepts a bare array and an enveloped list', async () => {
|
||||
const post = { huvudman: '165560000000', roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-01-01' }
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => [post] })
|
||||
expect(await listOmbudGrants()).toEqual([post])
|
||||
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ behorighetsposter: [post] }),
|
||||
})
|
||||
expect(await listOmbudGrants({ huvudman: '165560000000' })).toEqual([post])
|
||||
expect(mockSkvRequestWithAuth.mock.calls[1][2]).toBe('/ombud/autentisieratOmbud?huvudman=165560000000')
|
||||
})
|
||||
|
||||
it('404 throws by default (wrong URI per the spec) and is empty only with emptyOn404', async () => {
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '{"message":"Not found"}' })
|
||||
await expect(listOmbudGrants({ huvudman: '165560000000' })).rejects.toMatchObject({
|
||||
code: 'OBR_HTTP_ERROR',
|
||||
status: 404,
|
||||
})
|
||||
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '{"message":"Not found"}' })
|
||||
expect(await listOmbudGrants({}, { emptyOn404: true })).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an unparsable body and other HTTP errors as OmbudApiError', async () => {
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ nope: 1 }) })
|
||||
await expect(listOmbudGrants()).rejects.toMatchObject({ name: 'OmbudApiError', code: 'OBR_BAD_RESPONSE' })
|
||||
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'boom' })
|
||||
await expect(listOmbudGrants()).rejects.toMatchObject({ code: 'OBR_HTTP_ERROR', status: 500 })
|
||||
})
|
||||
|
||||
it('remaps the system-mode 403 (OMBUD_GRANT_MISSING) to OBR_FORBIDDEN', async () => {
|
||||
mockSkvRequestWithAuth.mockRejectedValueOnce(new SkatteverketAuthError('nope', 'OMBUD_GRANT_MISSING'))
|
||||
await expect(listOmbudGrants()).rejects.toMatchObject({ code: 'OBR_FORBIDDEN' })
|
||||
// Other auth errors pass through untouched (run-level, classified upstream).
|
||||
mockSkvRequestWithAuth.mockRejectedValueOnce(new SkatteverketAuthError('token', 'SYSTEM_AUTH_FAILED'))
|
||||
await expect(listOmbudGrants()).rejects.toBeInstanceOf(SkatteverketAuthError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roles and deep links', () => {
|
||||
it('getOmbudRoleDescriptions parses the roller list', async () => {
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ roller: [{ roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud' }] }),
|
||||
})
|
||||
expect(await getOmbudRoleDescriptions()).toEqual([{ roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud' }])
|
||||
expect(mockSkvRequestWithAuth.mock.calls[0][2]).toBe('/roller')
|
||||
})
|
||||
|
||||
it('resolveOmbudRoleCodes uses pinned codes without a network call', async () => {
|
||||
process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO'
|
||||
process.env.SKATTEVERKET_OMBUD_ROLL_MOMS = 'MOMS'
|
||||
expect(await resolveOmbudRoleCodes(['lasombud', 'moms_ombud'])).toEqual({ lasombud: 'JLO', moms_ombud: 'MOMS' })
|
||||
expect(mockSkvRequestWithAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolveOmbudRoleCodes looks unpinned codes up in /roller and refuses to guess', async () => {
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [
|
||||
{ roll: 'A1', rollbeskrivning: 'Juridiskt läsombud' },
|
||||
{ roll: 'B2', rollbeskrivning: 'Deklarationsombud' },
|
||||
],
|
||||
})
|
||||
await expect(resolveOmbudRoleCodes(['lasombud', 'moms_ombud'])).rejects.toMatchObject({
|
||||
code: 'OBR_ROLE_UNRESOLVED',
|
||||
})
|
||||
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [
|
||||
{ roll: 'A1', rollbeskrivning: 'Juridiskt läsombud' },
|
||||
{ roll: 'C3', rollbeskrivning: 'Momsdeklaration, ombud' },
|
||||
],
|
||||
})
|
||||
expect(await resolveOmbudRoleCodes(['lasombud', 'moms_ombud'])).toEqual({ lasombud: 'A1', moms_ombud: 'C3' })
|
||||
})
|
||||
|
||||
it('createUtseOmbudDeepLink posts the resolved codes for the huvudman and returns the link', async () => {
|
||||
process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO'
|
||||
process.env.SKATTEVERKET_OMBUD_ROLL_MOMS = 'MOMS'
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ djuplank: 'https://sso.skatteverket.se/ombud?x=1' }),
|
||||
})
|
||||
|
||||
const link = await createUtseOmbudDeepLink('165560000000', ['lasombud', 'moms_ombud'], undefined, new Date('2026-09-01T10:00:00Z'))
|
||||
|
||||
expect(link).toEqual({
|
||||
djuplank: 'https://sso.skatteverket.se/ombud?x=1',
|
||||
roller: { lasombud: 'JLO', moms_ombud: 'MOMS' },
|
||||
expiresOn: '2026-09-22',
|
||||
})
|
||||
const [auth, method, path, body, options] = mockSkvRequestWithAuth.mock.calls[0]
|
||||
expect(auth).toEqual({ mode: 'system' })
|
||||
expect(method).toBe('POST')
|
||||
expect(path).toBe('/ombud/autentisieratOmbud/huvudman/165560000000/djuplank/utseombud')
|
||||
expect(body).toEqual({ ombudsroller: ['JLO', 'MOMS'] })
|
||||
expect(options).toMatchObject({ accept: 'application/json' })
|
||||
})
|
||||
|
||||
it('createUtseOmbudDeepLink rejects a response without djuplank', async () => {
|
||||
process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO'
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) })
|
||||
await expect(createUtseOmbudDeepLink('165560000000', ['lasombud'])).rejects.toBeInstanceOf(OmbudApiError)
|
||||
})
|
||||
|
||||
it('createUtseOmbudDeepLink refuses a deep link that is not an https skatteverket.se URL (open redirect)', async () => {
|
||||
process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO'
|
||||
for (const bad of [
|
||||
'http://sso.skatteverket.se/ombud',
|
||||
'https://evil.example/skatteverket.se',
|
||||
'https://skatteverket.se.evil.example/',
|
||||
'javascript:alert(1)',
|
||||
'not a url',
|
||||
]) {
|
||||
mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ djuplank: bad }) })
|
||||
await expect(createUtseOmbudDeepLink('165560000000', ['lasombud'])).rejects.toMatchObject({ code: 'OBR_BAD_RESPONSE' })
|
||||
}
|
||||
expect(isAllowedDeepLinkUrl('https://sso.skatteverket.se/ombud?x=1')).toBe(true)
|
||||
expect(isAllowedDeepLinkUrl('https://skatteverket.se/ombud')).toBe(true)
|
||||
expect(isAllowedDeepLinkUrl('https://www.test.skatteverket.se/x')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pure helpers', () => {
|
||||
it('isGrantActive honours giltigFrom/giltigTom as date-only strings', () => {
|
||||
expect(isGrantActive({ giltigFrom: '2026-09-01' }, '2026-09-01')).toBe(true)
|
||||
expect(isGrantActive({ giltigFrom: '2026-09-02' }, '2026-09-01')).toBe(false)
|
||||
expect(isGrantActive({ giltigFrom: '2026-01-01', giltigTom: '2026-08-31' }, '2026-09-01')).toBe(false)
|
||||
expect(isGrantActive({ giltigFrom: '2026-01-01', giltigTom: '2026-09-01' }, '2026-09-01')).toBe(true)
|
||||
expect(isGrantActive({ giltigFrom: '2026-01-01', giltigTom: '' }, '2026-09-01')).toBe(true)
|
||||
expect(isGrantActive({ giltigFrom: '2026-01-01', giltigTom: null }, '2026-09-01')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizeHuvudman reduces 12 or 13-character identities to 12 digits', () => {
|
||||
expect(normalizeHuvudman('165560000000')).toBe('165560000000')
|
||||
expect(normalizeHuvudman('16556000-0000')).toBe('165560000000')
|
||||
expect(normalizeHuvudman('5560000000')).toBeNull()
|
||||
expect(normalizeHuvudman('someone@example.com')).toBeNull()
|
||||
})
|
||||
|
||||
it('summarizeGrants collapses posts per huvudman with only active, known roles deciding', () => {
|
||||
const summary = summarizeGrants(
|
||||
[
|
||||
{ huvudman: '165560000000', roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-01-01' },
|
||||
{ huvudman: '16556000-0000', roll: 'MOMS', rollbeskrivning: 'Momsdeklaration, ombud', giltigFrom: '2026-12-01' },
|
||||
{ huvudman: '165560000000', roll: 'DEKL', rollbeskrivning: 'Deklarationsombud', giltigFrom: '2026-01-01' },
|
||||
{ huvudman: '195001011234', roll: 'MOMS', rollbeskrivning: 'Momsdeklaration, ombud', giltigFrom: '2026-01-01' },
|
||||
{ huvudman: 'bad', roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-01-01' },
|
||||
],
|
||||
'2026-09-01',
|
||||
)
|
||||
expect(summary.get('165560000000')).toEqual({
|
||||
huvudman: '165560000000',
|
||||
lasombud: true,
|
||||
moms_ombud: false,
|
||||
roles: ['JLO', 'MOMS', 'DEKL'],
|
||||
recognized: true,
|
||||
})
|
||||
expect(summary.get('195001011234')).toMatchObject({ lasombud: false, moms_ombud: true, recognized: true })
|
||||
expect(summary.size).toBe(2)
|
||||
|
||||
// Only unknown roles: recognized stays false even though roles were listed.
|
||||
const unknown = summarizeGrants(
|
||||
[{ huvudman: '165560000000', roll: 'ZZ', rollbeskrivning: 'Något annat', giltigFrom: '2026-01-01' }],
|
||||
'2026-09-01',
|
||||
)
|
||||
expect(unknown.get('165560000000')).toMatchObject({ roles: ['ZZ'], recognized: false, lasombud: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* GET /status: canRefresh follows the 65-minute refresh-token life, not
|
||||
* merely "a refresh token is stored". The silent-drop case: a token that
|
||||
* expired hours ago with its (dead) refresh token still in the row.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockGetTokens = vi.fn()
|
||||
const mockGetTokenHealth = vi.fn()
|
||||
vi.mock('../lib/token-store', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
getTokens: (...a: unknown[]) => mockGetTokens(...a),
|
||||
getTokenHealth: (...a: unknown[]) => mockGetTokenHealth(...a),
|
||||
}
|
||||
})
|
||||
|
||||
import { skatteverketExtension } from '../index'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
function findRoute() {
|
||||
const route = skatteverketExtension.apiRoutes?.find((r) => r.method === 'GET' && r.path === '/status')
|
||||
if (!route) throw new Error('status route not registered')
|
||||
return route
|
||||
}
|
||||
|
||||
function makeContext(): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'skatteverket',
|
||||
requestId: 'req_test',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: { from: vi.fn() } as any,
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
const NOW = Date.parse('2026-09-01T12:00:00Z')
|
||||
const request = () => new Request('http://localhost/api/extensions/ext/skatteverket/status')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
mockGetTokenHealth.mockResolvedValue({ status: 'active', last_error_code: null, last_error_at: null })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('GET /status canRefresh', () => {
|
||||
it('not connected without a token row', async () => {
|
||||
mockGetTokens.mockResolvedValue(null)
|
||||
const res = await findRoute().handler(request(), makeContext())
|
||||
expect(await res.json()).toMatchObject({ connected: false })
|
||||
})
|
||||
|
||||
it('a token expired hours ago with a stored refresh token is NOT refreshable', async () => {
|
||||
mockGetTokens.mockResolvedValue({
|
||||
access_token: 'a',
|
||||
refresh_token: 'r',
|
||||
expires_at: NOW - 3 * 60 * 60 * 1000,
|
||||
refresh_count: 0,
|
||||
scope: 'momsdeklaration ska agd',
|
||||
})
|
||||
const body = await (await findRoute().handler(request(), makeContext())).json()
|
||||
expect(body).toMatchObject({ connected: true, expired: true, canRefresh: false, needsReconsent: false })
|
||||
})
|
||||
|
||||
it('a token three minutes past expiry is still refreshable', async () => {
|
||||
mockGetTokens.mockResolvedValue({
|
||||
access_token: 'a',
|
||||
refresh_token: 'r',
|
||||
expires_at: NOW - 3 * 60 * 1000,
|
||||
refresh_count: 2,
|
||||
scope: 'momsdeklaration',
|
||||
})
|
||||
const body = await (await findRoute().handler(request(), makeContext())).json()
|
||||
expect(body).toMatchObject({ connected: true, expired: true, canRefresh: true })
|
||||
})
|
||||
|
||||
it('a live token at the refresh cap is not refreshable', async () => {
|
||||
mockGetTokens.mockResolvedValue({
|
||||
access_token: 'a',
|
||||
refresh_token: 'r',
|
||||
expires_at: NOW + 30 * 60 * 1000,
|
||||
refresh_count: 10,
|
||||
scope: 'momsdeklaration',
|
||||
})
|
||||
const body = await (await findRoute().handler(request(), makeContext())).json()
|
||||
expect(body).toMatchObject({ connected: true, expired: false, canRefresh: false })
|
||||
})
|
||||
})
|
||||
@@ -85,7 +85,7 @@ describe('system-auth config', () => {
|
||||
})
|
||||
|
||||
it('parses scopes from env with a sensible default', () => {
|
||||
expect(getSystemScopes()).toEqual(['skattekonto', 'agd:lasa', 'momsdeklaration'])
|
||||
expect(getSystemScopes()).toEqual(['skattekonto', 'agd:lasa', 'momsdeklaration', 'obr'])
|
||||
process.env.SKATTEVERKET_SYSTEM_SCOPES = 'a b'
|
||||
expect(getSystemScopes()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
@@ -27,10 +27,17 @@ import {
|
||||
import { submitVatDeclarationChain } from './lib/vat-submit'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { getSystemAuthMode, isSystemAuthConfigured, getOmbudOrgNumber, getSystemCertInfo } from './lib/system-auth/config'
|
||||
import { getConnection, markConnectionRevoked } from './lib/connection-store'
|
||||
import {
|
||||
getConnection,
|
||||
isOrgNumberContested,
|
||||
markConnectionRevoked,
|
||||
recordProbeResult,
|
||||
} from './lib/connection-store'
|
||||
import { currentSkvEnvironment, resolveReadAuth } from './lib/resolve-auth'
|
||||
import { probeCompanyGrants } from './lib/grant-probe'
|
||||
import { formatRedovisare } from '@/lib/skatteverket/format'
|
||||
import { isSkvSessionRefreshable } from '@/lib/skatteverket/session-lifetime'
|
||||
import { createUtseOmbudDeepLink, OMBUD_ROLE_KEYS, OmbudApiError } from './lib/ombud-client'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import type {
|
||||
SkvSubmitResult,
|
||||
@@ -154,10 +161,18 @@ const SkattekontoBokforBatchSchema = z.object({
|
||||
* 4. Set SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL, _SCOPES, _CLIENT_ID,
|
||||
* _AUTH_MECHANISM per the docs; SKATTEVERKET_OMBUD_ORG_NUMBER =
|
||||
* Accounted's org number (shown to users in the grant instructions).
|
||||
* 5. Validate grant-probe.ts classification against real sandbox 403
|
||||
* bodies (shadow mode in the test environment first).
|
||||
* 5. First live call against Ombudshantering v2 in the test service
|
||||
* (scope `obr` was added to application id arcimtechnologyab_gnubok_1
|
||||
* on 2026-09-01): confirm the list envelope, read the rollbeteckning
|
||||
* codes from GET /roller and pin them in SKATTEVERKET_OMBUD_ROLL_LASOMBUD
|
||||
* / SKATTEVERKET_OMBUD_ROLL_MOMS (lib/ombud-client.ts). Grant
|
||||
* verification then runs on the register; the read-service probes in
|
||||
* grant-probe.ts stay as the fallback and still need their 403 bodies
|
||||
* validated (shadow mode in the test environment first).
|
||||
* 6. Godkännandetest per API, then SKATTEVERKET_SYSTEM_AUTH_MODE=on in
|
||||
* prod. User tokens remain the fallback indefinitely.
|
||||
* prod. User tokens remain the fallback indefinitely. The daily ombud
|
||||
* sync cron (/api/extensions/skatteverket/ombud/sync/cron) runs from
|
||||
* shadow mode on: it only records grant state.
|
||||
*
|
||||
* The /status endpoint reports which environment is active so the UI can
|
||||
* surface a Testmiljö / Produktion badge.
|
||||
@@ -177,6 +192,26 @@ async function requireSkvCapability(ctx: ExtensionContext): Promise<NextResponse
|
||||
return requireCapability(ctx.supabase, ctx.companyId, CAPABILITY.skatteverket)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ombud path binds Skatteverket system-credential access to an org
|
||||
* number, and org numbers are public and tenant-editable. While more than
|
||||
* one live company claims the same one, no company may verify a grant or
|
||||
* mint a deep link on it: a tenant that typed a victim's number must never
|
||||
* inherit the victim's grant. Returns the 409 to send, or null when clear.
|
||||
*/
|
||||
async function contestedOrgNumberResponse(orgNumber: string): Promise<NextResponse | null> {
|
||||
if (!(await isOrgNumberContested(orgNumber))) return null
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Organisationsnumret används av fler än ett företag i Accounted. ' +
|
||||
'Kontakta support för att aktivera ombudsanslutningen.',
|
||||
code: 'ORG_NUMBER_CONTESTED',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve auth for a company-scoped READ triggered from the UI: the caller's
|
||||
* own token if they connected, otherwise any active token another member of
|
||||
@@ -667,7 +702,15 @@ export const skatteverketExtension: Extension = {
|
||||
}
|
||||
|
||||
const expired = tokens.expires_at < Date.now()
|
||||
const canRefresh = tokens.refresh_token !== null && tokens.refresh_count < 10
|
||||
// Honest refreshability: SKV's per-flow refresh token dies 65 minutes
|
||||
// after issue, five minutes past access-token expiry. A stored
|
||||
// refresh token past that window is not "can refresh"; reporting it
|
||||
// as such kept the reconnect banner silent for days.
|
||||
const canRefresh = isSkvSessionRefreshable({
|
||||
expiresAt: tokens.expires_at,
|
||||
hasRefreshToken: tokens.refresh_token !== null,
|
||||
refreshCount: tokens.refresh_count,
|
||||
})
|
||||
|
||||
// Persisted health, written by the crons when they hit a terminal
|
||||
// auth state. Lets the settings panel prompt for re-consent
|
||||
@@ -793,6 +836,9 @@ export const skatteverketExtension: Extension = {
|
||||
settings.entity_type as 'enskild_firma' | 'aktiebolag'
|
||||
)
|
||||
|
||||
const contestedForVerify = await contestedOrgNumberResponse(orgNumber)
|
||||
if (contestedForVerify) return contestedForVerify
|
||||
|
||||
try {
|
||||
const result = await probeCompanyGrants(ctx.companyId, orgNumber, ctx.userId)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
@@ -815,6 +861,93 @@ export const skatteverketExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── System connection: deep link to appoint Accounted as ombud ──
|
||||
// Ombudshantering v2 mints a link into Skatteverket's e-service with
|
||||
// Accounted pre-filled as ombud and both roles pre-selected; the company
|
||||
// only signs with BankID there. Runs on the system identity (the link's
|
||||
// ombud is whoever holds the token), so it needs the same configuration
|
||||
// as verify.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/system-connection/deeplink',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
const blocked = await requireSkvCapability(ctx)
|
||||
if (blocked) return blocked
|
||||
const roleBlocked = await requireAgiWriteRole(ctx)
|
||||
if (roleBlocked) return roleBlocked
|
||||
|
||||
if (getSystemAuthMode() === 'off' || !isSystemAuthConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Systemanslutningen är inte aktiverad i denna miljö.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, entity_type')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
if (!settings?.org_number) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organisationsnummer saknas. Ange det under Inställningar först.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const orgNumber = formatRedovisare(
|
||||
settings.org_number as string,
|
||||
settings.entity_type as 'enskild_firma' | 'aktiebolag'
|
||||
)
|
||||
|
||||
const contestedForLink = await contestedOrgNumberResponse(orgNumber)
|
||||
if (contestedForLink) return contestedForLink
|
||||
|
||||
try {
|
||||
const link = await createUtseOmbudDeepLink(orgNumber, OMBUD_ROLE_KEYS)
|
||||
// Minting the link is the tenant's opt-in: record a pending row
|
||||
// (no grant state yet) so the nightly ombud sync, which only ever
|
||||
// touches existing rows, picks the signed grant up on its own.
|
||||
const optIn = await recordProbeResult({
|
||||
companyId: ctx.companyId,
|
||||
environment: currentSkvEnvironment(),
|
||||
orgNumber,
|
||||
createdBy: ctx.userId,
|
||||
error: null,
|
||||
})
|
||||
if (!optIn) {
|
||||
// Handing out the link without the row would let the company
|
||||
// sign at Skatteverket and never be picked up by the sync.
|
||||
log.error('deep link minted but opt-in row could not be stored', { companyId: ctx.companyId })
|
||||
return NextResponse.json(
|
||||
{ error: 'Anslutningen kunde inte sparas. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'system-connection/deeplink',
|
||||
agRegistreradId: orgNumber,
|
||||
outcome: 'ok',
|
||||
})
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
djuplank: link.djuplank,
|
||||
roller: link.roller,
|
||||
expires_on: link.expiresOn,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof OmbudApiError) {
|
||||
log.warn('ombud deep link failed', { companyId: ctx.companyId, code: err.code, message: err.message })
|
||||
return NextResponse.json({ error: err.message, code: err.code }, { status: 502 })
|
||||
}
|
||||
return handleSkvError(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── System connection: revoke locally ───────────────────────────
|
||||
{
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -384,7 +384,7 @@ export async function skvRequestWithAuth(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: { baseUrl?: string; contentType?: string }
|
||||
options?: { baseUrl?: string; contentType?: string; accept?: string }
|
||||
): Promise<Response> {
|
||||
if (isDisabled()) {
|
||||
throw new SkatteverketAuthError(
|
||||
@@ -434,6 +434,9 @@ export async function skvRequestWithAuth(
|
||||
headers['Client_Secret'] = getApiGwClientSecret()
|
||||
headers['skv_client_correlation_id'] = crypto.randomUUID()
|
||||
}
|
||||
// Ombudshantering lists Accept as a required header (406 otherwise); the
|
||||
// moms/skattekonto/AGI services never needed it, so it stays opt-in.
|
||||
if (options?.accept) headers['Accept'] = options.accept
|
||||
|
||||
// contentType defaults to application/json, which is right for moms +
|
||||
// skattekonto. AGI's POST /underlag takes application/xml: callers pass
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { toRedovisare12 } from '@/lib/invariants/org-number'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('skatteverket-connection-store')
|
||||
@@ -230,6 +232,88 @@ export async function listVerifiedCompanies(
|
||||
return (data ?? []) as Array<{ company_id: string; org_number: string; created_by: string | null }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Every connection row for an environment, for the ombudsregister sync. The
|
||||
* caller decides what a row's status means ('revoked' rows are the tenant's
|
||||
* own disconnect and are skipped there). Paginated through fetchAllRows on a
|
||||
* stable (created_at, id) order so nothing past PostgREST's 1000-row page is
|
||||
* silently left stale. The select is spelled out rather than reusing
|
||||
* CONNECTION_COLUMNS so the phantom-column scanner can check it.
|
||||
*/
|
||||
export async function listConnections(environment: SkvEnvironment): Promise<SkvCompanyConnection[]> {
|
||||
try {
|
||||
const client = getServiceClient()
|
||||
return await fetchAllRows<SkvCompanyConnection>(({ from, to }) =>
|
||||
client
|
||||
.from('skatteverket_company_connections')
|
||||
.select('id, company_id, environment, org_number, status, lasombud_status, lasombud_checked_at, moms_ombud_status, moms_ombud_checked_at, verified_at, last_probe_at, last_probe_detail, last_error')
|
||||
.eq('environment', environment)
|
||||
.order('created_at', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
} catch (error) {
|
||||
log.warn('listConnections failed', {
|
||||
environment,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Org numbers (12-digit redovisare form) claimed by MORE than one live
|
||||
* (non-archived) company. Org-number reuse is allowed in the product and
|
||||
* tenant isolation is the boundary, but the ombud path binds Skatteverket's
|
||||
* system-credential access to an org number: while two tenants claim the
|
||||
* same one, neither may be marked granted, or a tenant that typed a victim's
|
||||
* public org number would inherit the victim's grant. Verify, deep link and
|
||||
* the nightly sync all consult this.
|
||||
*/
|
||||
export async function findContestedOrgNumbers(): Promise<Set<string>> {
|
||||
const client = getServiceClient()
|
||||
type SettingsRow = { company_id: string; org_number: string | null; entity_type: string | null }
|
||||
type ArchivedRow = { id: string }
|
||||
const [settings, archived] = await Promise.all([
|
||||
fetchAllRows<SettingsRow>(({ from, to }) =>
|
||||
client
|
||||
.from('company_settings')
|
||||
.select('company_id, org_number, entity_type')
|
||||
.not('org_number', 'is', null)
|
||||
.order('company_id', { ascending: true })
|
||||
.range(from, to)
|
||||
),
|
||||
fetchAllRows<ArchivedRow>(({ from, to }) =>
|
||||
client
|
||||
.from('companies')
|
||||
.select('id')
|
||||
.not('archived_at', 'is', null)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
),
|
||||
])
|
||||
const archivedIds = new Set(archived.map((row) => row.id))
|
||||
const claimants = new Map<string, number>()
|
||||
for (const row of settings) {
|
||||
if (!row.org_number || archivedIds.has(row.company_id)) continue
|
||||
let redovisare: string
|
||||
try {
|
||||
redovisare = toRedovisare12(row.org_number, row.entity_type === 'enskild_firma' ? 'enskild_firma' : 'aktiebolag')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
claimants.set(redovisare, (claimants.get(redovisare) ?? 0) + 1)
|
||||
}
|
||||
const contested = new Set<string>()
|
||||
for (const [orgNumber, count] of claimants) if (count > 1) contested.add(orgNumber)
|
||||
return contested
|
||||
}
|
||||
|
||||
/** True when more than one live company claims this 12-digit org number. */
|
||||
export async function isOrgNumberContested(orgNumber: string): Promise<boolean> {
|
||||
return (await findContestedOrgNumbers()).has(orgNumber)
|
||||
}
|
||||
|
||||
/** Test hook: reset the memoized service client. */
|
||||
export function __resetConnectionStoreForTests(): void {
|
||||
_serviceClient = null
|
||||
|
||||
@@ -2,31 +2,38 @@ import { skvRequestWithAuth, SkatteverketAuthError } from './api-client'
|
||||
import { getSkattekontoBaseUrl } from './skattekonto-client'
|
||||
import { recordProbeResult, type GrantStatus, type SkvCompanyConnection } from './connection-store'
|
||||
import { currentSkvEnvironment } from './resolve-auth'
|
||||
import { isoDate, listOmbudGrants, OmbudApiError, summarizeGrants } from './ombud-client'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('skatteverket-grant-probe')
|
||||
|
||||
/**
|
||||
* Behorighet verification probes.
|
||||
* Behorighet verification.
|
||||
*
|
||||
* After the user grants Accounted's org number a behorighet in Skatteverket's
|
||||
* Ombud och behorigheter e-service, nothing tells us: there is no callback.
|
||||
* The probe makes one cheap read per behorighet with SYSTEM credentials and
|
||||
* classifies the outcome:
|
||||
* Two ways to find out, tried in order:
|
||||
*
|
||||
* lasombud : GET skattekonto saldo for the company's org number.
|
||||
* 200 -> granted; felkod 3 (no skattekonto registered) also
|
||||
* proves authorization passed -> granted-with-note;
|
||||
* OMBUD_GRANT_MISSING (403) -> denied; anything transient
|
||||
* (5xx, timeout, rate limit) -> error, which never downgrades
|
||||
* a previously granted state (connection-store rule).
|
||||
* moms_ombud : GET moms /utkast for the current period. 200 or 404 (no
|
||||
* draft exists, but the gateway authorized us) -> granted;
|
||||
* OMBUD_GRANT_MISSING -> denied.
|
||||
* 1. The ombudsregister itself (Ombudshantering via API v2, scope `obr`):
|
||||
* GET /ombud/autentisieratOmbud?huvudman={orgNumber} on SYSTEM
|
||||
* credentials lists exactly which roles this company gave Accounted and
|
||||
* for how long. Authoritative: a role present and active today is
|
||||
* granted, a 200 without it is denied. Only a failure to ASK the
|
||||
* register (scope missing, 5xx, timeout, unparsable body) is an
|
||||
* 'error', and then the service probes below decide instead.
|
||||
*
|
||||
* The classification heuristics live here, in one file, because they are
|
||||
* assumptions until validated against real sandbox 403 bodies (Phase 2 of
|
||||
* the rollout): expected churn stays contained.
|
||||
* 2. Service probes (the pre-`obr` heuristic), one cheap read per
|
||||
* behorighet on SYSTEM credentials:
|
||||
* lasombud : GET skattekonto saldo. 200 -> granted; felkod 3 (no
|
||||
* skattekonto registered) also proves authorization ->
|
||||
* granted-with-note; OMBUD_GRANT_MISSING (403) -> denied;
|
||||
* transient (5xx, timeout, rate limit) -> error.
|
||||
* moms_ombud : GET moms /utkast for the current period. 200 or 404 (no
|
||||
* draft, but the gateway authorized us) -> granted;
|
||||
* OMBUD_GRANT_MISSING -> denied.
|
||||
*
|
||||
* 'error' never downgrades a previously granted state (connection-store
|
||||
* rule); only an explicit 'denied' does.
|
||||
*/
|
||||
|
||||
export interface ProbeClassification {
|
||||
@@ -96,14 +103,72 @@ async function probeMomsOmbud(orgNumber: string): Promise<ProbeClassification> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface GrantProbeResult {
|
||||
connection: SkvCompanyConnection | null
|
||||
export interface RegistryProbe {
|
||||
lasombud: ProbeClassification
|
||||
momsOmbud: ProbeClassification
|
||||
}
|
||||
|
||||
/**
|
||||
* Run both behorighet probes for a company and persist the outcome.
|
||||
* Ask the ombudsregister about one huvudman. Returns null when the register
|
||||
* could not be consulted (so the caller falls back to the service probes);
|
||||
* the reason is logged, and surfaces in the probe detail of the fallback.
|
||||
*/
|
||||
export async function probeViaOmbudsregister(
|
||||
orgNumber: string,
|
||||
today: string = isoDate(new Date())
|
||||
): Promise<{ result: RegistryProbe; roles: string[] } | { result: null; reason: string }> {
|
||||
try {
|
||||
const posts = await listOmbudGrants({ huvudman: orgNumber })
|
||||
const summary = summarizeGrants(posts, today).get(orgNumber)
|
||||
const roles = summary?.roles ?? []
|
||||
// The company granted Accounted something, but none of it classifies as
|
||||
// either behörighet: far more likely an unrecognised rollbeteckning (codes
|
||||
// not pinned yet, or a renamed rollbeskrivning) than a company that chose
|
||||
// only unrelated roles. 'error' never downgrades a granted row; 'denied'
|
||||
// would. Pin the codes and the ambiguity disappears.
|
||||
if (roles.length > 0 && !summary?.recognized) {
|
||||
const detail = `ombudsregister: roller okända (${roles.join(', ')}); pinna rollkoderna`
|
||||
return {
|
||||
result: {
|
||||
lasombud: { status: 'error', detail },
|
||||
momsOmbud: { status: 'error', detail },
|
||||
},
|
||||
roles,
|
||||
}
|
||||
}
|
||||
const describe = (granted: boolean, key: 'lasombud' | 'moms_ombud'): ProbeClassification =>
|
||||
granted
|
||||
? { status: 'granted', detail: `ombudsregister: ${key} aktiv ${today}` }
|
||||
: { status: 'denied', detail: `ombudsregister: ${key} saknas (roller: ${roles.join(', ') || 'inga'})` }
|
||||
return {
|
||||
result: {
|
||||
lasombud: describe(summary?.lasombud ?? false, 'lasombud'),
|
||||
momsOmbud: describe(summary?.moms_ombud ?? false, 'moms_ombud'),
|
||||
},
|
||||
roles,
|
||||
}
|
||||
} catch (err) {
|
||||
const reason =
|
||||
err instanceof OmbudApiError || err instanceof SkatteverketAuthError
|
||||
? `${err.code}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
log.warn('ombudsregister lookup unavailable, falling back to service probes', { orgNumber, reason })
|
||||
return { result: null, reason }
|
||||
}
|
||||
}
|
||||
|
||||
export interface GrantProbeResult {
|
||||
connection: SkvCompanyConnection | null
|
||||
lasombud: ProbeClassification
|
||||
momsOmbud: ProbeClassification
|
||||
/** 'registry' when the ombudsregister answered, 'service' when the read probes decided. */
|
||||
source: 'registry' | 'service'
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify both behorigheter for a company and persist the outcome.
|
||||
* The caller has already verified role + capability and resolved the
|
||||
* company's normalized 12-digit org number.
|
||||
*/
|
||||
@@ -112,10 +177,26 @@ export async function probeCompanyGrants(
|
||||
orgNumber: string,
|
||||
createdBy?: string
|
||||
): Promise<GrantProbeResult> {
|
||||
const [lasombud, momsOmbud] = [await probeLasombud(orgNumber), await probeMomsOmbud(orgNumber)]
|
||||
const registry = await probeViaOmbudsregister(orgNumber)
|
||||
|
||||
let lasombud: ProbeClassification
|
||||
let momsOmbud: ProbeClassification
|
||||
let source: GrantProbeResult['source']
|
||||
if (registry.result) {
|
||||
;({ lasombud, momsOmbud } = registry.result)
|
||||
source = 'registry'
|
||||
} else {
|
||||
lasombud = await probeLasombud(orgNumber)
|
||||
momsOmbud = await probeMomsOmbud(orgNumber)
|
||||
source = 'service'
|
||||
const note = ` (ombudsregister otillgängligt: ${registry.reason})`
|
||||
lasombud = { ...lasombud, detail: lasombud.detail + note }
|
||||
momsOmbud = { ...momsOmbud, detail: momsOmbud.detail + note }
|
||||
}
|
||||
|
||||
log.info('grant probe completed', {
|
||||
companyId,
|
||||
source,
|
||||
lasombud: lasombud.status,
|
||||
momsOmbud: momsOmbud.status,
|
||||
})
|
||||
@@ -136,5 +217,5 @@ export async function probeCompanyGrants(
|
||||
: null,
|
||||
})
|
||||
|
||||
return { connection, lasombud, momsOmbud }
|
||||
return { connection, lasombud, momsOmbud, source }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { getSkatteverketEnvironment, skvRequestWithAuth, SkatteverketAuthError } from './api-client'
|
||||
import type { SkvBehorighet } from './connection-store'
|
||||
|
||||
const log = createLogger('skatteverket-ombud-client')
|
||||
|
||||
/**
|
||||
* Client for Skatteverket's "Ombudshantering via API" v2 (scope `obr`).
|
||||
*
|
||||
* Source: Tjänstebeskrivning Ombudshantering via API v2.0, dokumentversion
|
||||
* 1.0 (2022-06-16), mirrored in dev_docs/skatteverket/ombudshantering/.
|
||||
* Base URI `{host}/behorighet/ombudshantering/v2`, JSON over HTTPS, headers
|
||||
* Accept + content-type application/json, Authorization Bearer, client_id,
|
||||
* client_secret, skv_client_correlation_id. Operations:
|
||||
*
|
||||
* GET /ombud/autentisieratOmbud?huvudman&roll&giltigFrom&giltigTom
|
||||
* The huvudmän (companies/persons) the AUTHENTICATED ombud may
|
||||
* represent, with role + validity. The ombud identity comes from the
|
||||
* token: with a personal BankID token that is the person, with the
|
||||
* system (CCG, organisationscertifikat) token it is Accounted's org
|
||||
* number. Only the system identity answers "who granted Accounted".
|
||||
* GET /huvudman/autentisieradHuvudman?ombud&roll&giltigFrom&giltigTom
|
||||
* The reverse view for a huvudman. Not used here.
|
||||
* POST /ombud/autentisieratOmbud/huvudman/{huvudman}/djuplank/utseombud
|
||||
* body { ombudsroller: [rollbeteckning...], giltigTom? }. Returns a
|
||||
* deep link into the e-service "Ombud och behörigheter" with the roles
|
||||
* pre-selected for the huvudman to sign with BankID. Valid three
|
||||
* weeks from creation.
|
||||
* GET /roller?roll
|
||||
* Role descriptions: rollbeteckning + rollbeskrivning.
|
||||
*
|
||||
* Every call here runs on the SYSTEM identity: the whole point is to ask
|
||||
* the register what companies granted Accounted, and to mint deep links
|
||||
* that name Accounted as the ombud.
|
||||
*
|
||||
* Two things the service description does not pin down, deliberately
|
||||
* handled tolerantly and logged so the first live call against the test
|
||||
* service settles them (see dev_docs/skatteverket/ombudshantering/README.md):
|
||||
* - the JSON envelope of list responses (bare array vs. an object holding
|
||||
* the list): both are accepted,
|
||||
* - the rollbeteckning codes for "Juridiskt läsombud" and "Momsdeklaration,
|
||||
* ombud": pin them via env once known; until then roles are classified
|
||||
* by their rollbeskrivning text, which the register returns alongside.
|
||||
*/
|
||||
|
||||
export const OMBUD_API_TEST_BASE_URL = 'https://api.test.skatteverket.se/behorighet/ombudshantering/v2'
|
||||
export const OMBUD_API_PROD_BASE_URL = 'https://api.skatteverket.se/behorighet/ombudshantering/v2'
|
||||
|
||||
/** Deep links from /djuplank/utseombud are valid this long (service description 4.1.1). */
|
||||
export const OMBUD_DEEP_LINK_VALIDITY_WEEKS = 3
|
||||
|
||||
export function getOmbudApiBaseUrl(): string {
|
||||
const override = process.env.SKATTEVERKET_OMBUD_API_BASE_URL
|
||||
if (override) return override.replace(/\/+$/, '')
|
||||
return getSkatteverketEnvironment() === 'prod' ? OMBUD_API_PROD_BASE_URL : OMBUD_API_TEST_BASE_URL
|
||||
}
|
||||
|
||||
/** The behörigheter Accounted asks companies for, keyed as the connection row stores them. */
|
||||
export const OMBUD_ROLE_KEYS: readonly SkvBehorighet[] = ['lasombud', 'moms_ombud'] as const
|
||||
|
||||
/**
|
||||
* Pinned rollbeteckning codes (exact match on `roll`). Unknown until read
|
||||
* from GET /roller in the test service; env-configurable so no deploy is
|
||||
* needed when Skatteverket confirms them.
|
||||
*/
|
||||
const ROLE_CODE_ENV: Record<SkvBehorighet, string> = {
|
||||
lasombud: 'SKATTEVERKET_OMBUD_ROLL_LASOMBUD',
|
||||
moms_ombud: 'SKATTEVERKET_OMBUD_ROLL_MOMS',
|
||||
}
|
||||
|
||||
/**
|
||||
* Description-text fallback. Skatteverket's own labels in the e-service are
|
||||
* "Juridiskt läsombud" and "Momsdeklaration, ombud"; the register returns a
|
||||
* rollbeskrivning with every behörighetspost, so the text is available even
|
||||
* before the codes are pinned. Matching is exact on the whole label (case and
|
||||
* whitespace aside): "Momsdeklaration, deklarationsombud" is a broader role
|
||||
* and must never be read as "Momsdeklaration, ombud".
|
||||
*/
|
||||
const ROLE_DESCRIPTION_PATTERNS: Record<SkvBehorighet, RegExp> = {
|
||||
lasombud: /^juridiskt\s+l[äa]sombud$/i,
|
||||
moms_ombud: /^momsdeklaration,\s*ombud$/i,
|
||||
}
|
||||
|
||||
export function getPinnedRoleCode(key: SkvBehorighet): string | null {
|
||||
const raw = process.env[ROLE_CODE_ENV[key]]?.trim()
|
||||
return raw ? raw : null
|
||||
}
|
||||
|
||||
export interface OmbudRoleLike {
|
||||
roll: string
|
||||
rollbeskrivning?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of Accounted's behörigheter a register role represents, or null
|
||||
* when it is some other role (companies hand out many; only two matter).
|
||||
* A pinned code wins over the text match, and a pinned code that does NOT
|
||||
* match disqualifies the text fallback for that key: once Emil has told us
|
||||
* the code, a differently-coded role with similar wording is a different role.
|
||||
*/
|
||||
export function classifyOmbudRole(role: OmbudRoleLike): SkvBehorighet | null {
|
||||
for (const key of OMBUD_ROLE_KEYS) {
|
||||
const pinned = getPinnedRoleCode(key)
|
||||
if (pinned) {
|
||||
if (role.roll === pinned) return key
|
||||
continue
|
||||
}
|
||||
if (role.rollbeskrivning && ROLE_DESCRIPTION_PATTERNS[key].test(role.rollbeskrivning)) return key
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Wire schemas ─────────────────────────────────────────────────────
|
||||
|
||||
const BehorighetspostSchema = z.object({
|
||||
huvudman: z.string(),
|
||||
roll: z.string(),
|
||||
rollbeskrivning: z.string().nullish(),
|
||||
ombud: z.string().nullish(),
|
||||
giltigFrom: z.string(),
|
||||
giltigTom: z.string().nullish(),
|
||||
})
|
||||
export type Behorighetspost = z.infer<typeof BehorighetspostSchema>
|
||||
|
||||
const RollbeskrivningspostSchema = z.object({
|
||||
roll: z.string(),
|
||||
rollbeskrivning: z.string().nullish(),
|
||||
})
|
||||
export type Rollbeskrivningspost = z.infer<typeof RollbeskrivningspostSchema>
|
||||
|
||||
/**
|
||||
* Hosts a deep link may point at. The settings page navigates the browser
|
||||
* to this URL, so the register's answer is not trusted blindly: only HTTPS
|
||||
* on skatteverket.se (or a subdomain) passes. Test service links come from
|
||||
* the same domain family.
|
||||
*/
|
||||
export function isAllowedDeepLinkUrl(raw: string): boolean {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (url.protocol !== 'https:') return false
|
||||
const host = url.hostname.toLowerCase()
|
||||
return host === 'skatteverket.se' || host.endsWith('.skatteverket.se')
|
||||
}
|
||||
|
||||
const DjuplankSchema = z.object({
|
||||
djuplank: z.string().min(1).refine(isAllowedDeepLinkUrl, 'djuplank must be an https skatteverket.se URL'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Accept the list either bare or wrapped in an object under any of the names
|
||||
* the service description uses for it. Logged once per shape so the first
|
||||
* live call documents which one Skatteverket actually sends.
|
||||
*/
|
||||
function unwrapList(json: unknown, candidateKeys: string[], operation: string): unknown[] {
|
||||
if (Array.isArray(json)) return json
|
||||
if (json && typeof json === 'object') {
|
||||
for (const key of candidateKeys) {
|
||||
const value = (json as Record<string, unknown>)[key]
|
||||
if (Array.isArray(value)) {
|
||||
log.info('ombud list envelope observed', { operation, key })
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new OmbudApiError(`Oväntat svarsformat från Ombudshantering (${operation}).`, 'OBR_BAD_RESPONSE')
|
||||
}
|
||||
|
||||
export type OmbudApiErrorCode =
|
||||
| 'OBR_FORBIDDEN' // 401/403 for the system identity: scope/avtal missing, not a company-level fact
|
||||
| 'OBR_BAD_RESPONSE'
|
||||
| 'OBR_HTTP_ERROR'
|
||||
| 'OBR_ROLE_UNRESOLVED'
|
||||
|
||||
export class OmbudApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: OmbudApiErrorCode,
|
||||
public readonly status?: number
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'OmbudApiError'
|
||||
}
|
||||
}
|
||||
|
||||
async function ombudRequest(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Response> {
|
||||
try {
|
||||
return await skvRequestWithAuth({ mode: 'system' }, method, path, body, {
|
||||
baseUrl: getOmbudApiBaseUrl(),
|
||||
accept: 'application/json',
|
||||
})
|
||||
} catch (err) {
|
||||
// api-client maps a system-mode 403 to OMBUD_GRANT_MISSING because on the
|
||||
// read services a 403 is "this company did not grant us". On the register
|
||||
// itself a 403 means Accounted may not call the service at all (scope or
|
||||
// avtal): a run-level condition that must never be recorded as a
|
||||
// company's grant state.
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'OMBUD_GRANT_MISSING') {
|
||||
throw new OmbudApiError(
|
||||
'Ombudshantering-tjänsten nekade anropet: kontrollera att scopet obr och avtalet är på plats.',
|
||||
'OBR_FORBIDDEN',
|
||||
403
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonOrThrow(response: Response, operation: string): Promise<unknown> {
|
||||
if (response.ok) {
|
||||
return response.json().catch(() => {
|
||||
throw new OmbudApiError(`Ombudshantering svarade utan JSON (${operation}).`, 'OBR_BAD_RESPONSE', response.status)
|
||||
})
|
||||
}
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new OmbudApiError(
|
||||
`Ombudshantering svarade ${response.status} (${operation})${text ? `: ${text.slice(0, 200)}` : ''}`,
|
||||
'OBR_HTTP_ERROR',
|
||||
response.status
|
||||
)
|
||||
}
|
||||
|
||||
function buildQuery(params: Record<string, string | undefined>): string {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) search.set(key, value)
|
||||
}
|
||||
const qs = search.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
// ── Operations ───────────────────────────────────────────────────────
|
||||
|
||||
export type ListOmbudGrantsFilter = {
|
||||
/** 12-digit org/person number of one huvudman; omit for all of them. */
|
||||
huvudman?: string
|
||||
roll?: string
|
||||
giltigFrom?: string
|
||||
giltigTom?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /ombud/autentisieratOmbud: every behörighetspost where the system
|
||||
* identity (Accounted's org number) is the ombud. Skatteverket returns
|
||||
* current and FUTURE grants; use {@link isGrantActive} before trusting one.
|
||||
*
|
||||
* A 404 is "wrong URI" per the service description (4.7), yet its 1..*
|
||||
* multiplicity for the list leaves an empty register indistinguishable from
|
||||
* it. Default: a 404 throws like any other HTTP error, so a single-company
|
||||
* probe falls back to the read-service probes instead of recording a denial
|
||||
* on a transport fault. Only the sync cron opts into `emptyOn404`, and it
|
||||
* pairs that with its empty-register guard (a zero result downgrades nothing).
|
||||
*/
|
||||
export async function listOmbudGrants(
|
||||
filter: ListOmbudGrantsFilter = {},
|
||||
options: { emptyOn404?: boolean } = {}
|
||||
): Promise<Behorighetspost[]> {
|
||||
const response = await ombudRequest('GET', `/ombud/autentisieratOmbud${buildQuery(filter)}`)
|
||||
if (response.status === 404 && options.emptyOn404) return []
|
||||
const json = await readJsonOrThrow(response, 'ombud/autentisieratOmbud')
|
||||
const rows = unwrapList(json, ['behorighetsposter', 'Behorighetsposter', 'behorigheter'], 'ombud/autentisieratOmbud')
|
||||
const parsed = z.array(BehorighetspostSchema).safeParse(rows)
|
||||
if (!parsed.success) {
|
||||
throw new OmbudApiError('Behörighetsposter från Ombudshantering kunde inte tolkas.', 'OBR_BAD_RESPONSE')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/** GET /roller: all rollbeteckningar with descriptions (or one, when filtered). */
|
||||
export async function getOmbudRoleDescriptions(roll?: string): Promise<Rollbeskrivningspost[]> {
|
||||
const response = await ombudRequest('GET', `/roller${buildQuery({ roll })}`)
|
||||
const json = await readJsonOrThrow(response, 'roller')
|
||||
const rows = unwrapList(json, ['rollbeskrivningsposter', 'Rollbeskrivningsposter', 'roller'], 'roller')
|
||||
const parsed = z.array(RollbeskrivningspostSchema).safeParse(rows)
|
||||
if (!parsed.success) {
|
||||
throw new OmbudApiError('Rollbeskrivningar från Ombudshantering kunde inte tolkas.', 'OBR_BAD_RESPONSE')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* The rollbeteckning codes to put in a deep link: pinned env values first,
|
||||
* otherwise resolved from GET /roller by description. Throws when a key
|
||||
* cannot be resolved rather than minting a link with a guessed role.
|
||||
*/
|
||||
export async function resolveOmbudRoleCodes(keys: readonly SkvBehorighet[]): Promise<Record<SkvBehorighet, string>> {
|
||||
const resolved: Partial<Record<SkvBehorighet, string>> = {}
|
||||
const missing: SkvBehorighet[] = []
|
||||
for (const key of keys) {
|
||||
const pinned = getPinnedRoleCode(key)
|
||||
if (pinned) resolved[key] = pinned
|
||||
else missing.push(key)
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
const roles = await getOmbudRoleDescriptions()
|
||||
for (const key of missing) {
|
||||
const hit = roles.find((role) => classifyOmbudRole(role) === key)
|
||||
if (hit) resolved[key] = hit.roll
|
||||
}
|
||||
const unresolved = missing.filter((key) => !resolved[key])
|
||||
if (unresolved.length > 0) {
|
||||
log.warn('ombud role codes unresolved from /roller', {
|
||||
unresolved,
|
||||
available: roles.map((r) => `${r.roll}=${r.rollbeskrivning ?? ''}`),
|
||||
})
|
||||
throw new OmbudApiError(
|
||||
`Rollkod saknas för ${unresolved.join(', ')}: sätt ${unresolved
|
||||
.map((key) => ROLE_CODE_ENV[key])
|
||||
.join(' och ')} efter uppslag i GET /roller.`,
|
||||
'OBR_ROLE_UNRESOLVED'
|
||||
)
|
||||
}
|
||||
}
|
||||
return resolved as Record<SkvBehorighet, string>
|
||||
}
|
||||
|
||||
export interface UtseOmbudDeepLink {
|
||||
djuplank: string
|
||||
/** rollbeteckning codes the link pre-selects, keyed by behörighet. */
|
||||
roller: Record<SkvBehorighet, string>
|
||||
/** yyyy-mm-dd when the link stops working (three weeks from creation). */
|
||||
expiresOn: string
|
||||
}
|
||||
|
||||
/**
|
||||
* POST .../huvudman/{huvudman}/djuplank/utseombud on the system identity:
|
||||
* a link the company opens to appoint Accounted as ombud with the given
|
||||
* roles pre-selected, then signs in the e-service with BankID.
|
||||
*/
|
||||
export async function createUtseOmbudDeepLink(
|
||||
huvudman: string,
|
||||
keys: readonly SkvBehorighet[] = OMBUD_ROLE_KEYS,
|
||||
giltigTom?: string,
|
||||
now: Date = new Date()
|
||||
): Promise<UtseOmbudDeepLink> {
|
||||
const roller = await resolveOmbudRoleCodes(keys)
|
||||
const body: { ombudsroller: string[]; giltigTom?: string } = {
|
||||
ombudsroller: keys.map((key) => roller[key]),
|
||||
}
|
||||
if (giltigTom) body.giltigTom = giltigTom
|
||||
const response = await ombudRequest(
|
||||
'POST',
|
||||
`/ombud/autentisieratOmbud/huvudman/${encodeURIComponent(huvudman)}/djuplank/utseombud`,
|
||||
body
|
||||
)
|
||||
const json = await readJsonOrThrow(response, 'djuplank/utseombud')
|
||||
const parsed = DjuplankSchema.safeParse(json)
|
||||
if (!parsed.success) {
|
||||
log.warn('deep link rejected', { issues: parsed.error.issues.map((i) => i.message) })
|
||||
throw new OmbudApiError('Djuplänken från Ombudshantering kunde inte tolkas.', 'OBR_BAD_RESPONSE')
|
||||
}
|
||||
const expires = new Date(now.getTime() + OMBUD_DEEP_LINK_VALIDITY_WEEKS * 7 * 24 * 60 * 60 * 1000)
|
||||
return { djuplank: parsed.data.djuplank, roller, expiresOn: expires.toISOString().slice(0, 10) }
|
||||
}
|
||||
|
||||
// ── Pure helpers over behörighetsposter ──────────────────────────────
|
||||
|
||||
/** yyyy-mm-dd for the given instant, in UTC (Skatteverket dates are date-only). */
|
||||
export function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* A grant counts today when giltigFrom is today or earlier and giltigTom is
|
||||
* empty (tillsvidare) or today or later. Date-only strings compare
|
||||
* lexically, which is exactly the yyyy-mm-dd contract.
|
||||
*/
|
||||
export function isGrantActive(post: Pick<Behorighetspost, 'giltigFrom' | 'giltigTom'>, today: string): boolean {
|
||||
if (!post.giltigFrom || post.giltigFrom > today) return false
|
||||
if (post.giltigTom && post.giltigTom < today) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Register identities arrive as 12 digits, sometimes with a separator
|
||||
* (string 12..13 per the service description). Reduce to 12 digits or null.
|
||||
*/
|
||||
export function normalizeHuvudman(raw: string): string | null {
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
return /^\d{12}$/.test(digits) ? digits : null
|
||||
}
|
||||
|
||||
export interface HuvudmanGrantSummary {
|
||||
huvudman: string
|
||||
lasombud: boolean
|
||||
moms_ombud: boolean
|
||||
/** Every rollbeteckning seen for this huvudman, active or not: diagnostics. */
|
||||
roles: string[]
|
||||
/**
|
||||
* True when at least one post (active or not) classified as one of
|
||||
* Accounted's behörigheter. False with a non-empty `roles` means the
|
||||
* register lists roles we cannot name: a code-pinning problem, not a
|
||||
* company decision.
|
||||
*/
|
||||
recognized: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse behörighetsposter into one row per huvudman saying which of
|
||||
* Accounted's two behörigheter are active today. Roles outside the two are
|
||||
* kept in `roles` for the probe detail but decide nothing.
|
||||
*/
|
||||
export function summarizeGrants(posts: Behorighetspost[], today: string): Map<string, HuvudmanGrantSummary> {
|
||||
const out = new Map<string, HuvudmanGrantSummary>()
|
||||
for (const post of posts) {
|
||||
const huvudman = normalizeHuvudman(post.huvudman)
|
||||
if (!huvudman) {
|
||||
log.warn('behorighetspost with unparsable huvudman skipped', { huvudman: post.huvudman, roll: post.roll })
|
||||
continue
|
||||
}
|
||||
let row = out.get(huvudman)
|
||||
if (!row) {
|
||||
row = { huvudman, lasombud: false, moms_ombud: false, roles: [], recognized: false }
|
||||
out.set(huvudman, row)
|
||||
}
|
||||
if (!row.roles.includes(post.roll)) row.roles.push(post.roll)
|
||||
const key = classifyOmbudRole(post)
|
||||
if (!key) continue
|
||||
row.recognized = true
|
||||
if (isGrantActive(post, today)) row[key] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -46,11 +46,13 @@ export function getSystemClientId(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default scopes for the system token. The real scope names for the org
|
||||
* flow are pending SKV docs; override via env when they land.
|
||||
* Default scopes for the system token. `obr` is Ombudshantering (confirmed
|
||||
* by Skatteverket 2026-09-01 when they added it to application id
|
||||
* arcimtechnologyab_gnubok_1); the other names are pending SKV's org-flow
|
||||
* docs. Override via env when they land.
|
||||
*/
|
||||
export function getSystemScopes(): string[] {
|
||||
const raw = process.env.SKATTEVERKET_SYSTEM_SCOPES ?? 'skattekonto agd:lasa momsdeklaration'
|
||||
const raw = process.env.SKATTEVERKET_SYSTEM_SCOPES ?? 'skattekonto agd:lasa momsdeklaration obr'
|
||||
return raw.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user