fix(scoping): Skatteverket per företag + nåbara startkort + företags-scopade val (#1610)

* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals

Skatteverket connections become per (user, company): the token table
carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half
migrations), so one connection leaked "connected" onto every company the
user belongs to, sync ran the token against the wrong orgnr (behorighet
403), and reconnecting from another company silently moved the row and
went dark on the first company's crons. Token reads/writes are now scoped
by company through the whole chain (token-store, api-client refresh
coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP),
/skattekonto/saldo answers 401 NOT_CONNECTED for companies without their
own row (which is what the page's startkort keys on), and the dashboard
connect-nudge counts only the active company's row.

Bookkeeping's pristine start card now keys on all-years emptiness via a
count probe instead of "no active filters": the default fiscal-year
selection counted as a filter, which made the card unreachable on
brand-new companies (it showed "inga traffar" instead).

Two browser-global localStorage keys become company-scoped with legacy
fallbacks: the inbox onboarding dismissal (dismissing on one company hid
the card everywhere) and the periodisering auto-detect toggle.

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

* fix(scoping): dedupe cron work per (user, company) + guard the ledger probe

CodeRabbit findings on #1610: the skattekonto sync cron still deduped
token rows by user_id alone, which would drop every company but one for
multi-company operators (the exact scenario the PR fixes); and the
all-years ledger probe could leave a stale false behind on a failed
refetch, letting the pristine card render unconfirmed. The probe now
resets to unknown in flight and carries the fetch generation guard.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-14 08:21:57 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 4e14182a00
commit 18c20e68e6
28 changed files with 317 additions and 145 deletions
@@ -125,7 +125,7 @@ describe('gnubok_vat_declaration_validate', () => {
expect(result.redovisningsperiod).toBe('202503')
// Only /kontrollera was called: nothing was saved at SKV.
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/kontrollera\//)
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/kontrollera\//)
})
})
@@ -148,7 +148,7 @@ describe('gnubok_vat_declaration_submit', () => {
expect(result.preview.commit_action).toMatch(/signering/i)
// Exactly one SKV call (the stage-time /kontrollera); no /utkast.
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/kontrollera\//)
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/kontrollera\//)
})
})
@@ -169,7 +169,7 @@ describe('gnubok_vat_declaration_validate', () => {
expect(result.completeness_ok).toBe(true)
expect(result.completeness_checks).toEqual([])
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/kontrollera\//)
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/kontrollera\//)
})
// The masking case: rutor 30-32 are compared against 2645/2647, so ordinary
+5 -5
View File
@@ -11228,7 +11228,7 @@ export const tools: McpTool[] = [
const { redovisare, redovisningsperiod, momsuppgift } =
await buildMomsuppgift(supabase, companyId, { periodType, year, period })
const res = await skvRequest(
supabase, userId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
supabase, userId, companyId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'kontrollera', agRegistreradId: redovisare, redovisningsperiod,
@@ -11298,7 +11298,7 @@ export const tools: McpTool[] = [
try {
const prep = await buildMomsuppgift(supabase, companyId, { periodType, year, period })
const res = await skvRequest(
supabase, userId, 'POST', `/kontrollera/${prep.redovisare}/${prep.redovisningsperiod}`, prep.momsuppgift,
supabase, userId, companyId, 'POST', `/kontrollera/${prep.redovisare}/${prep.redovisningsperiod}`, prep.momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'kontrollera', agRegistreradId: prep.redovisare, redovisningsperiod: prep.redovisningsperiod,
@@ -11365,7 +11365,7 @@ export const tools: McpTool[] = [
let submitted: unknown = null
let decided: unknown = null
if (state === 'submitted' || state === 'both') {
const res = await skvRequest(supabase, userId, 'GET', `/inlamnat/${redovisare}/${redovisningsperiod}`)
const res = await skvRequest(supabase, userId, companyId, 'GET', `/inlamnat/${redovisare}/${redovisningsperiod}`)
await writeSkatteverketAudit(ctx, {
endpoint: 'inlamnat', agRegistreradId: redovisare, redovisningsperiod,
outcome: res.ok || res.status === 404 ? 'ok' : 'skv_error', responseStatus: res.status,
@@ -11379,7 +11379,7 @@ export const tools: McpTool[] = [
}
}
if (state === 'decided' || state === 'both') {
const res = await skvRequest(supabase, userId, 'GET', `/beslutat/${redovisare}/${redovisningsperiod}`)
const res = await skvRequest(supabase, userId, companyId, 'GET', `/beslutat/${redovisare}/${redovisningsperiod}`)
await writeSkatteverketAudit(ctx, {
endpoint: 'beslutat', agRegistreradId: redovisare, redovisningsperiod,
outcome: res.ok || res.status === 404 ? 'ok' : 'skv_error', responseStatus: res.status,
@@ -11524,7 +11524,7 @@ export const tools: McpTool[] = [
// leaves kvittenser null rather than hard-failing the status check;
// auth errors throw and map to SKATTEVERKET_NOT_CONNECTED.
let kvittenser: unknown = null
const res = await agiGetKvittenser({ mode: 'user', supabase, userId }, arbetsgivare, period)
const res = await agiGetKvittenser({ mode: 'user', supabase, userId, companyId }, arbetsgivare, period)
await writeSkatteverketAudit(ctx, {
endpoint: 'kvittenser', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: res.ok ? 'ok' : 'skv_error', responseStatus: res.status,
@@ -47,7 +47,7 @@ describe('skvRequest: error mapping', () => {
it('maps empty 401 → ACCESS_DENIED (likely missing APIGW subscription)', async () => {
mockFetchStatus(401)
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
@@ -59,7 +59,7 @@ describe('skvRequest: error mapping', () => {
it('maps 401 with body text → SESSION_EXPIRED with a clean Swedish message (no body leak)', async () => {
mockFetchStatus(401, 'token expired')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
@@ -75,13 +75,13 @@ describe('skvRequest: error mapping', () => {
deleteTokensMock.mockClear()
mockFetchStatus(401, '{"error":"Token has been revoked."}')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('TOKEN_REVOKED')
expect((e as SkatteverketAuthError).message).toMatch(/återkallat/i)
expect(deleteTokensMock).toHaveBeenCalledWith(fakeSupabase, 'user-1')
expect(deleteTokensMock).toHaveBeenCalledWith(fakeSupabase, 'user-1', 'comp-1')
}
})
@@ -90,7 +90,7 @@ describe('skvRequest: error mapping', () => {
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
})
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
@@ -101,7 +101,7 @@ describe('skvRequest: error mapping', () => {
it('maps 403 with Behörighet body → BEHORIGHET_SAKNAS', async () => {
mockFetchStatus(403, 'Behörighet saknas för aktören')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
@@ -118,7 +118,7 @@ describe('skvRequest: error mapping', () => {
it('maps the APIGW "required scopes are not authorized" 403 → ACCESS_DENIED, not MISSING_SCOPE', async () => {
mockFetchStatus(403, '{"error": "The required scopes are not authorized"}')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
@@ -133,7 +133,7 @@ describe('skvRequest: error mapping', () => {
'{"error":"invalid_scope","description":"The required scope agd has been requested for that access token."}',
)
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
@@ -143,7 +143,7 @@ describe('skvRequest: error mapping', () => {
it('maps the SKV scope sentence alone (no invalid_scope code) → MISSING_SCOPE', async () => {
mockFetchStatus(403, 'The required scope agd has been requested for that access token.')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
@@ -158,7 +158,7 @@ describe('skvRequest: error mapping', () => {
'WWW-Authenticate': 'Bearer error="invalid_scope"',
})
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
@@ -168,7 +168,7 @@ describe('skvRequest: error mapping', () => {
it('maps generic 403 → ACCESS_DENIED', async () => {
mockFetchStatus(403, 'Forbidden')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
@@ -179,7 +179,7 @@ describe('skvRequest: error mapping', () => {
it('maps 429 → RATE_LIMITED (new behavior)', async () => {
mockFetchStatus(429)
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
@@ -192,13 +192,13 @@ describe('skvRequest: error mapping', () => {
it('returns the response for 5xx (caller decides retry)', async () => {
mockFetchStatus(503, 'Service Unavailable')
const res = await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
const res = await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect(res.status).toBe(503)
})
it('returns the response for success', async () => {
mockFetchStatus(200, '{"ok":true}')
const res = await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
const res = await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ ok: true })
@@ -73,10 +73,10 @@ describe('commitSubmitVatDeclaration', () => {
expect(result).toMatchObject({ ok: true, signing_url: 'https://skv.test/sign/abc' })
expect(mockSkvRequest).toHaveBeenCalledTimes(2)
// call order: utkast (POST) before las (PUT)
expect(mockSkvRequest.mock.calls[0][2]).toBe('POST')
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/utkast\/165560000000\/202503$/)
expect(mockSkvRequest.mock.calls[1][2]).toBe('PUT')
expect(mockSkvRequest.mock.calls[1][3]).toMatch(/^\/las\/165560000000\/202503$/)
expect(mockSkvRequest.mock.calls[0][3]).toBe('POST')
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/utkast\/165560000000\/202503$/)
expect(mockSkvRequest.mock.calls[1][3]).toBe('PUT')
expect(mockSkvRequest.mock.calls[1][4]).toMatch(/^\/las\/165560000000\/202503$/)
})
it('utkast rejected by SKV → non-recoverable, no /las call', async () => {
@@ -152,7 +152,7 @@ describe('runPostConnectRefresh', () => {
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
expect(result.synced).toBe(false)
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(supabase, USER, 'MISSING_SCOPE')
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(supabase, USER, COMPANY, 'MISSING_SCOPE')
})
it('does not persist needs_reconsent for non-terminal auth error codes', async () => {
@@ -20,13 +20,15 @@ beforeEach(() => {
async function mockSelectReturning(row: unknown) {
const { createClient } = await import('@supabase/supabase-js')
// getTokens chains .eq('user_id', …).eq('company_id', …).maybeSingle():
// a self-referencing eq keeps the chain length out of the mock's contract.
const eqChain: { eq: ReturnType<typeof vi.fn>; maybeSingle: ReturnType<typeof vi.fn> } = {
eq: vi.fn(() => eqChain),
maybeSingle: vi.fn(async () => row),
}
;(createClient as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue({
from: vi.fn(() => ({
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(async () => row),
})),
})),
select: vi.fn(() => eqChain),
})),
})
}
@@ -38,7 +40,7 @@ describe('getTokens', () => {
// mock changes. Use vitest's resetModules to force re-import.
vi.resetModules()
const { getTokens: fresh } = await import('../lib/token-store')
const result = await fresh(fakeSupabase, 'user-1')
const result = await fresh(fakeSupabase, 'user-1', 'comp-1')
expect(result).toBeNull()
})
@@ -61,7 +63,7 @@ describe('getTokens', () => {
const { getTokens: fresh } = await import('../lib/token-store')
try {
await fresh(fakeSupabase, 'user-1')
await fresh(fakeSupabase, 'user-1', 'comp-1')
expect.fail('expected throw')
} catch (e) {
const err = e as { name: string; code: string; message: string }
@@ -69,7 +69,7 @@ describe('submitVatDeclarationChain', () => {
})
// Only the kontrollera call: no utkast, no lås, no persisted state.
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][3]).toBe('/kontrollera/165560000000/202606')
expect(mockSkvRequest.mock.calls[0][4]).toBe('/kontrollera/165560000000/202606')
expect((ctx.settings.set as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
})
@@ -122,7 +122,7 @@ describe('submitVatDeclarationChain', () => {
expect(result).toMatchObject({ ok: true, signingUrl: 'https://skv.test/sign/xyz' })
expect(mockSkvRequest).toHaveBeenCalledTimes(2)
expect(mockSkvRequest.mock.calls[0][3]).toBe('/utkast/165560000000/202606')
expect(mockSkvRequest.mock.calls[0][4]).toBe('/utkast/165560000000/202606')
})
it('utkast failure -> stage draft, nothing saved, retry-safe', async () => {
+37 -18
View File
@@ -550,7 +550,7 @@ export const skatteverketExtension: Extension = {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const tokens = await getTokens(ctx.supabase, ctx.userId)
const tokens = await getTokens(ctx.supabase, ctx.userId, ctx.companyId)
const environment = getSkatteverketEnvironment()
const disabled = (process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true'
@@ -564,7 +564,7 @@ export const skatteverketExtension: Extension = {
// Persisted health, written by the crons when they hit a terminal
// auth state. Lets the settings panel prompt for re-consent
// proactively instead of only after a live failure.
const health = await getTokenHealth(ctx.supabase, ctx.userId)
const health = await getTokenHealth(ctx.supabase, ctx.userId, ctx.companyId)
return NextResponse.json({
connected: true,
@@ -589,7 +589,7 @@ export const skatteverketExtension: Extension = {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
await deleteTokens(ctx.supabase, ctx.userId)
await deleteTokens(ctx.supabase, ctx.userId, ctx.companyId)
return NextResponse.json({ success: true })
},
},
@@ -752,6 +752,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.userId,
ctx.companyId,
'POST',
`/kontrollera/${redovisare}/${redovisningsperiod}`,
momsuppgift
@@ -800,6 +801,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.userId,
ctx.companyId,
'POST',
`/utkast/${redovisare}/${redovisningsperiod}`,
momsuppgift
@@ -850,6 +852,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.userId,
ctx.companyId,
'GET',
`/utkast/${redovisare}/${redovisningsperiod}`
)
@@ -889,6 +892,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.userId,
ctx.companyId,
'DELETE',
`/utkast/${redovisare}/${redovisningsperiod}`
)
@@ -928,6 +932,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.userId,
ctx.companyId,
'PUT',
`/las/${redovisare}/${redovisningsperiod}`
)
@@ -975,6 +980,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.userId,
ctx.companyId,
'DELETE',
`/las/${redovisare}/${redovisningsperiod}`
)
@@ -1211,7 +1217,7 @@ export const skatteverketExtension: Extension = {
console.log('[skatteverket] AGI submitting underlag:', { arbetsgivare, period })
const result = await agiPostUnderlag(ctx.supabase, ctx.userId, xml)
const result = await agiPostUnderlag(ctx.supabase, ctx.userId, ctx.companyId, xml)
if (!result.ok) {
console.error('[skatteverket] AGI underlag error:', result.status, result.error)
return NextResponse.json(
@@ -1268,7 +1274,7 @@ export const skatteverketExtension: Extension = {
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
}
const result = await agiGetKontrollresultat(ctx.supabase, ctx.userId, inlamningId)
const result = await agiGetKontrollresultat(ctx.supabase, ctx.userId, ctx.companyId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
@@ -1338,7 +1344,7 @@ export const skatteverketExtension: Extension = {
return NextResponse.json({ error: 'Saknar inlamningId' }, { status: 400 })
}
const result = await agiSparaUnderlag(ctx.supabase, ctx.userId, inlamningId)
const result = await agiSparaUnderlag(ctx.supabase, ctx.userId, ctx.companyId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
@@ -1426,7 +1432,7 @@ export const skatteverketExtension: Extension = {
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
}
const result = await agiAvbrytUnderlag(ctx.supabase, ctx.userId, inlamningId)
const result = await agiAvbrytUnderlag(ctx.supabase, ctx.userId, ctx.companyId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
@@ -1493,7 +1499,7 @@ export const skatteverketExtension: Extension = {
)
}
const result = await agiTaBortSparadInlamning(
ctx.supabase, ctx.userId, arbetsgivare, period, inlamningId,
ctx.supabase, ctx.userId, ctx.companyId, arbetsgivare, period, inlamningId,
)
if (!result.ok) {
return NextResponse.json(
@@ -1534,7 +1540,7 @@ export const skatteverketExtension: Extension = {
}
const result = await agiSkapaGranskningsunderlag(
ctx.supabase, ctx.userId, arbetsgivare, period, { lasPeriod },
ctx.supabase, ctx.userId, ctx.companyId, arbetsgivare, period, { lasPeriod },
)
if (!result.ok) {
return NextResponse.json(
@@ -1618,7 +1624,7 @@ export const skatteverketExtension: Extension = {
}
const result = await agiGetKvittenser(
{ mode: 'user', supabase: ctx.supabase, userId: ctx.userId },
{ mode: 'user', supabase: ctx.supabase, userId: ctx.userId, companyId: ctx.companyId },
arbetsgivare,
period
)
@@ -1800,7 +1806,7 @@ export const skatteverketExtension: Extension = {
}
try {
const result = await agiKontrolleraHU(ctx.supabase, ctx.userId, parsed.data)
const result = await agiKontrolleraHU(ctx.supabase, ctx.userId, ctx.companyId, parsed.data)
if (!result.ok) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
@@ -1908,7 +1914,7 @@ export const skatteverketExtension: Extension = {
}
try {
const result = await agiKontrolleraIU(ctx.supabase, ctx.userId, parsed.data)
const result = await agiKontrolleraIU(ctx.supabase, ctx.userId, ctx.companyId, parsed.data)
if (!result.ok) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
@@ -1968,7 +1974,7 @@ export const skatteverketExtension: Extension = {
{ status: 400 },
)
}
const result = await agiLasPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
const result = await agiLasPeriod(ctx.supabase, ctx.userId, ctx.companyId, arbetsgivare, period)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
@@ -1998,7 +2004,7 @@ export const skatteverketExtension: Extension = {
{ status: 400 },
)
}
const result = await agiLasUppPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
const result = await agiLasUppPeriod(ctx.supabase, ctx.userId, ctx.companyId, arbetsgivare, period)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
@@ -2056,6 +2062,19 @@ export const skatteverketExtension: Extension = {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
// The Skattekonto page keys its "inte anslutet" empty state off a 401
// NOT_CONNECTED from this route. Connections are per (user, company):
// without this check, a company that never connected rendered the
// connected-but-unsynced view because the snapshot read below always
// answered 200 (with null data), and "Synkronisera nu" then died on a
// behorighet error at SKV.
const tokens = await getTokens(ctx.supabase, ctx.userId, ctx.companyId)
if (!tokens) {
return NextResponse.json(
{ error: 'Inte ansluten till Skatteverket.', code: 'NOT_CONNECTED' },
{ status: 401 },
)
}
const snapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(SKATTEKONTO_BALANCE_SNAPSHOT_KEY)
const lastSyncedAt = await ctx.settings.get<string>(SKATTEKONTO_LAST_SYNCED_AT_KEY)
return NextResponse.json({
@@ -2630,7 +2649,7 @@ async function commitSubmitAgi(
await buildAgiUnderlag(supabase, companyId, salaryRunId)
// 1. POST /underlag (XML) → inlamningId.
const submit = await agiPostUnderlag(supabase, userId, xml)
const submit = await agiPostUnderlag(supabase, userId, companyId, xml)
await writeSkatteverketAudit(ctx, {
endpoint: 'agi/submit', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: submit.ok ? 'ok' : 'skv_error', responseStatus: submit.status,
@@ -2647,10 +2666,10 @@ async function commitSubmitAgi(
}))
// 2. Poll kontrollresultat (bounded; SKV is typically sub-second).
let kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
let kontroll = await agiGetKontrollresultat(supabase, userId, companyId, inlamningId)
for (let i = 0; i < 2 && kontroll.ok && kontroll.data.status === 'PROCESSING'; i++) {
await sleep(750)
kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
kontroll = await agiGetKontrollresultat(supabase, userId, companyId, inlamningId)
}
await writeSkatteverketAudit(ctx, {
endpoint: 'agi/kontrollresultat', agRegistreradId: arbetsgivare, redovisningsperiod: period,
@@ -2678,7 +2697,7 @@ async function commitSubmitAgi(
}
// 3. skapaGranskningsunderlag (lasPeriod=true) → Mina Sidor signing link.
const gransk = await agiSkapaGranskningsunderlag(supabase, userId, arbetsgivare, period, { lasPeriod: true })
const gransk = await agiSkapaGranskningsunderlag(supabase, userId, companyId, arbetsgivare, period, { lasPeriod: true })
await writeSkatteverketAudit(ctx, {
endpoint: 'agi/granskningsunderlag', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: gransk.ok ? 'ok' : 'skv_error', responseStatus: gransk.status,
@@ -85,11 +85,13 @@ async function readErrorBody(response: Response): Promise<{ error: string; body?
export async function agiPostUnderlag(
supabase: SupabaseClient,
userId: string,
companyId: string,
xml: string,
): Promise<Result<SkatteverketAGIUnderlagResponse>> {
const response = await skvRequest(
supabase,
userId,
companyId,
'POST',
'/underlag',
xml,
@@ -118,11 +120,13 @@ export async function agiPostUnderlag(
export async function agiGetKontrollresultat(
supabase: SupabaseClient,
userId: string,
companyId: string,
inlamningId: number,
): Promise<Result<SkatteverketAGIKontrollresultat>> {
const response = await skvRequest(
supabase,
userId,
companyId,
'GET',
`/underlag/${inlamningId}/kontrollresultat`,
undefined,
@@ -155,11 +159,13 @@ export async function agiGetKontrollresultat(
export async function agiSparaUnderlag(
supabase: SupabaseClient,
userId: string,
companyId: string,
inlamningId: number,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
companyId,
'POST',
`/underlag/${inlamningId}/spara`,
undefined,
@@ -185,11 +191,13 @@ export async function agiSparaUnderlag(
export async function agiAvbrytUnderlag(
supabase: SupabaseClient,
userId: string,
companyId: string,
inlamningId: number,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
companyId,
'DELETE',
`/underlag/${inlamningId}`,
undefined,
@@ -213,6 +221,7 @@ export async function agiAvbrytUnderlag(
export async function agiTaBortSparadInlamning(
supabase: SupabaseClient,
userId: string,
companyId: string,
arbetsgivare: string,
period: string,
inlamningId: number,
@@ -220,6 +229,7 @@ export async function agiTaBortSparadInlamning(
const response = await skvRequest(
supabase,
userId,
companyId,
'DELETE',
`${periodPath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
undefined,
@@ -245,6 +255,7 @@ export async function agiTaBortSparadInlamning(
export async function agiSkapaGranskningsunderlag(
supabase: SupabaseClient,
userId: string,
companyId: string,
arbetsgivare: string,
period: string,
options: { lasPeriod?: boolean } = {},
@@ -253,6 +264,7 @@ export async function agiSkapaGranskningsunderlag(
const response = await skvRequest(
supabase,
userId,
companyId,
'POST',
`${periodPath(arbetsgivare, period)}/skapaGranskningsunderlag${qs}`,
undefined,
@@ -317,12 +329,14 @@ export async function agiGetKvittenser(
export async function agiLasPeriod(
supabase: SupabaseClient,
userId: string,
companyId: string,
arbetsgivare: string,
period: string,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
companyId,
'POST',
`${periodPath(arbetsgivare, period)}/las`,
undefined,
@@ -340,12 +354,14 @@ export async function agiLasPeriod(
export async function agiLasUppPeriod(
supabase: SupabaseClient,
userId: string,
companyId: string,
arbetsgivare: string,
period: string,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
companyId,
'POST',
`${periodPath(arbetsgivare, period)}/lasUpp`,
undefined,
@@ -372,11 +388,13 @@ export async function agiLasUppPeriod(
export async function agiKontrolleraHU(
supabase: SupabaseClient,
userId: string,
companyId: string,
hu: Record<string, unknown>,
): Promise<Result<SkatteverketAGIKontrollsvar>> {
const response = await skvRequest(
supabase,
userId,
companyId,
'POST',
'/underlag/huvuduppgift/kontrollera',
hu,
@@ -401,11 +419,13 @@ export async function agiKontrolleraHU(
export async function agiKontrolleraIU(
supabase: SupabaseClient,
userId: string,
companyId: string,
iu: Record<string, unknown>,
): Promise<Result<SkatteverketAGIKontrollsvar>> {
const response = await skvRequest(
supabase,
userId,
companyId,
'POST',
'/underlag/individuppgift/kontrollera',
iu,
@@ -16,7 +16,7 @@ import type { SkatteverketTokens } from '../types'
* Used by background reads; carries no user session at all.
*/
export type SkvAuth =
| { mode: 'user'; supabase: SupabaseClient; userId: string }
| { mode: 'user'; supabase: SupabaseClient; userId: string; companyId: string }
| { mode: 'system' }
const log = createLogger('skatteverket-api-client')
@@ -123,9 +123,10 @@ const refreshInFlight = new Map<string, Promise<string>>()
*/
async function getValidToken(
supabase: SupabaseClient,
userId: string
userId: string,
companyId: string
): Promise<string> {
const tokens = await getTokens(supabase, userId)
const tokens = await getTokens(supabase, userId, companyId)
if (!tokens) {
throw new SkatteverketAuthError(
'Inte ansluten till Skatteverket. Anslut med BankID först.',
@@ -138,24 +139,26 @@ async function getValidToken(
return tokens.access_token
}
// Need refresh: coalesce concurrent attempts.
const inFlight = refreshInFlight.get(userId)
// Need refresh: coalesce concurrent attempts per (user, company) row.
const flightKey = `${userId}:${companyId}`
const inFlight = refreshInFlight.get(flightKey)
if (inFlight) return inFlight
const promise = refreshTokenForUser(supabase, userId)
.finally(() => refreshInFlight.delete(userId))
refreshInFlight.set(userId, promise)
const promise = refreshTokenForUser(supabase, userId, companyId)
.finally(() => refreshInFlight.delete(flightKey))
refreshInFlight.set(flightKey, promise)
return promise
}
async function refreshTokenForUser(
supabase: SupabaseClient,
userId: string,
companyId: string,
): Promise<string> {
// Re-read after entering the critical section. Another process may have
// refreshed while we were waiting; if so, the row now has a new
// refresh_token and a future expiry: just hand it back.
const tokens = await getTokens(supabase, userId)
const tokens = await getTokens(supabase, userId, companyId)
if (!tokens) {
throw new SkatteverketAuthError(
'Inte ansluten till Skatteverket. Anslut med BankID först.',
@@ -212,7 +215,7 @@ async function refreshTokenForUser(
...refreshed,
scope: tokens.scope,
}
await storeTokens(supabase, userId, updatedTokens)
await storeTokens(supabase, userId, updatedTokens, companyId)
return updatedTokens.access_token
}
@@ -260,12 +263,13 @@ function isTokenScopeRejection(body: string): boolean {
export async function skvRequest(
supabase: SupabaseClient,
userId: string,
companyId: string,
method: string,
path: string,
body?: unknown,
options?: { baseUrl?: string; contentType?: string }
): Promise<Response> {
return skvRequestWithAuth({ mode: 'user', supabase, userId }, method, path, body, options)
return skvRequestWithAuth({ mode: 'user', supabase, userId, companyId }, method, path, body, options)
}
/**
@@ -299,7 +303,7 @@ export async function skvRequestWithAuth(
let accessToken: string
if (auth.mode === 'user') {
accessToken = await getValidToken(auth.supabase, auth.userId)
accessToken = await getValidToken(auth.supabase, auth.userId, auth.companyId)
} else {
try {
accessToken = await getSystemAccessToken()
@@ -437,7 +441,7 @@ export async function skvRequestWithAuth(
// primary auth error to the user.
if (lower.includes('revoked') || lower.includes('token has been revoked')) {
try {
await deleteTokens(auth.supabase, auth.userId)
await deleteTokens(auth.supabase, auth.userId, auth.companyId)
} catch (cleanupErr) {
log.error('failed to clear revoked token row', cleanupErr as Error, { userId: auth.userId })
}
@@ -76,7 +76,7 @@ export async function runPostConnectRefresh(
err instanceof SkatteverketAuthError &&
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
) {
await markNeedsReconsent(supabase, userId, err.code)
await markNeedsReconsent(supabase, userId, companyId, err.code)
}
}
@@ -71,7 +71,7 @@ export async function resolveReadAuth(
if (opts.userId) {
return {
ok: true,
auth: { mode: 'user', supabase, userId: opts.userId },
auth: { mode: 'user', supabase, userId: opts.userId, companyId },
source: 'user',
tokenUserId: opts.userId,
}
@@ -83,7 +83,7 @@ export async function resolveReadAuth(
return {
ok: true,
auth: { mode: 'user', supabase, userId: token.userId },
auth: { mode: 'user', supabase, userId: token.userId, companyId },
source: 'user',
tokenUserId: token.userId,
}
@@ -136,7 +136,7 @@ export async function syncSkattekonto(
// Defaults to the ctx user's personal token: the interactive manual-sync
// route keeps its exact pre-hybrid behavior. The cron passes system auth
// for companies with a verified lasombud grant.
auth: SkvAuth = { mode: 'user', supabase: ctx.supabase, userId: ctx.userId },
auth: SkvAuth = { mode: 'user', supabase: ctx.supabase, userId: ctx.userId, companyId: ctx.companyId },
): Promise<SkattekontoSyncResult> {
const omfragad = await resolveOmfragad(ctx.supabase, ctx.companyId)
@@ -65,63 +65,41 @@ function decrypt(ciphertext: string): string {
}
/**
* Store (replace) Skatteverket tokens for a user.
* Store (replace) Skatteverket tokens for a user + company pair.
* Both access_token and refresh_token are encrypted at rest.
*
* Implemented as DELETE + INSERT instead of UPSERT because some environments
* are missing the UNIQUE(user_id) constraint that ON CONFLICT requires. The
* delete-then-insert pattern is safe because OAuth callbacks for a given user
* are not concurrent (the user can only sign in with BankID once at a time).
* Connections are per (user_id, company_id): the OAuth token is the person's
* BankID session, but which company it is wired to is an explicit product
* choice, and a multi-company operator holds one row per company. The
* DELETE + INSERT (instead of UPSERT) predates the UNIQUE constraint and is
* safe because OAuth callbacks and refreshes for a given pair are coalesced.
*/
export async function storeTokens(
_supabase: SupabaseClient,
userId: string,
tokens: SkatteverketTokens,
companyId?: string,
companyId: string,
): Promise<void> {
const encryptedAccess = encrypt(tokens.access_token)
const encryptedRefresh = tokens.refresh_token ? encrypt(tokens.refresh_token) : null
const db = getServiceClient()
// The multi-tenant refactor (migration 20260330130000) put a NOT NULL
// company_id on every table. Tokens are conceptually user-scoped (one
// BankID identity), but the schema requires a company_id. The OAuth
// callback passes one explicitly. Token-refresh flows (called from
// skvRequest) don't pass one, so before we DELETE the existing row we
// remember its company_id and reuse it on INSERT.
let resolvedCompanyId = companyId
if (!resolvedCompanyId) {
const { data: existing, error: selectError } = await db
.from('skatteverket_tokens')
.select('company_id')
.eq('user_id', userId)
.maybeSingle()
// Throw before the destructive DELETE: a transient read failure here
// would otherwise wipe the existing row and then fail the INSERT on the
// NOT NULL company_id, leaving the user with no token at all.
if (selectError) {
throw new Error(`Failed to read existing token row: ${selectError.message}`)
}
if (existing?.company_id) resolvedCompanyId = existing.company_id
}
const { error: deleteError } = await db
.from('skatteverket_tokens')
.delete()
.eq('user_id', userId)
.eq('company_id', companyId)
if (deleteError) throw new Error(`Failed to clear existing tokens: ${deleteError.message}`)
const row: Record<string, unknown> = {
const { error: insertError } = await db.from('skatteverket_tokens').insert({
user_id: userId,
company_id: companyId,
access_token: encryptedAccess,
refresh_token: encryptedRefresh,
expires_at: new Date(tokens.expires_at).toISOString(),
refresh_count: tokens.refresh_count,
scope: tokens.scope,
}
if (resolvedCompanyId) row.company_id = resolvedCompanyId
const { error: insertError } = await db.from('skatteverket_tokens').insert(row)
})
if (insertError) throw new Error(`Failed to store tokens: ${insertError.message}`)
}
@@ -131,14 +109,16 @@ export async function storeTokens(
*/
export async function getTokens(
_supabase: SupabaseClient,
userId: string
userId: string,
companyId: string
): Promise<SkatteverketTokens | null> {
const db = getServiceClient()
const { data, error } = await db
.from('skatteverket_tokens')
.select('access_token, refresh_token, expires_at, refresh_count, scope')
.eq('user_id', userId)
.single()
.eq('company_id', companyId)
.maybeSingle()
if (error || !data) return null
@@ -159,6 +139,7 @@ export async function getTokens(
} catch (err) {
log.error('decryption failed for stored tokens', {
userId,
companyId,
error: err instanceof Error ? err.message : String(err),
})
throw new SkatteverketAuthError(
@@ -173,13 +154,15 @@ export async function getTokens(
*/
export async function deleteTokens(
_supabase: SupabaseClient,
userId: string
userId: string,
companyId: string
): Promise<void> {
const db = getServiceClient()
await db
.from('skatteverket_tokens')
.delete()
.eq('user_id', userId)
.eq('company_id', companyId)
}
/**
@@ -204,6 +187,7 @@ export const RECONSENT_ERROR_CODES = [
export async function markNeedsReconsent(
_supabase: SupabaseClient,
userId: string,
companyId: string,
errorCode: string,
): Promise<void> {
const db = getServiceClient()
@@ -215,6 +199,7 @@ export async function markNeedsReconsent(
last_error_at: new Date().toISOString(),
})
.eq('user_id', userId)
.eq('company_id', companyId)
if (error) {
log.warn('failed to mark token row needs_reconsent', {
userId,
@@ -231,12 +216,14 @@ export async function markNeedsReconsent(
export async function getTokenHealth(
_supabase: SupabaseClient,
userId: string,
companyId: string,
): Promise<{ status: string; last_error_code: string | null; last_error_at: string | null } | null> {
const db = getServiceClient()
const { data, error } = await db
.from('skatteverket_tokens')
.select('status, last_error_code, last_error_at')
.eq('user_id', userId)
.eq('company_id', companyId)
.maybeSingle()
if (error || !data) return null
return {
@@ -61,7 +61,7 @@ export async function submitVatDeclarationChain(
params: VatSubmitChainParams,
options: { validate?: boolean } = {}
): Promise<VatSubmitChainResult> {
const { supabase, userId } = ctx
const { supabase, userId, companyId } = ctx
const { redovisare, redovisningsperiod, momsuppgift } =
await buildMomsuppgift(supabase, ctx.companyId, params)
@@ -70,7 +70,7 @@ export async function submitVatDeclarationChain(
// any state exists at Skatteverket.
if (options.validate) {
const kontrollera = await skvRequest(
supabase, userId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
supabase, userId, companyId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/validate', agRegistreradId: redovisare, redovisningsperiod,
@@ -99,7 +99,7 @@ export async function submitVatDeclarationChain(
// 1. POST /utkast: save the draft to Eget utrymme. Overwrites any prior
// draft for the period, so retry after a mid-chain failure is safe.
const utkast = await skvRequest(
supabase, userId, 'POST', `/utkast/${redovisare}/${redovisningsperiod}`, momsuppgift,
supabase, userId, companyId, 'POST', `/utkast/${redovisare}/${redovisningsperiod}`, momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/draft', agRegistreradId: redovisare, redovisningsperiod,
@@ -128,7 +128,7 @@ export async function submitVatDeclarationChain(
// 2. PUT /las: lock for signing; returns the BankID signeringslänk.
const las = await skvRequest(
supabase, userId, 'PUT', `/las/${redovisare}/${redovisningsperiod}`,
supabase, userId, companyId, 'PUT', `/las/${redovisare}/${redovisningsperiod}`,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/lock', agRegistreradId: redovisare, redovisningsperiod,