Files
accounted/extensions/general/mcp-server/__tests__/vat-declaration-validate-completeness.test.ts
T
18c20e68e6 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>
2026-08-14 08:21:57 +02:00

258 lines
9.8 KiB
TypeScript

/**
* gnubok_vat_declaration_validate: arithmetic vs completeness.
*
* The tool used to be a pure proxy for Skatteverket's POST /kontrollera, which
* lib/reports/vat-declaration-checks.ts documents as arithmetic-only:
*
* "Skatteverket's 'validering' only confirms that the payload is internally
* arithmetically consistent: it does NOT confirm that the declaration
* reflects reality. A declaration of all zeros validates fine; one with
* output VAT but no underlying purchases validates fine too, until the
* gateway-level FK004 rule fires."
*
* So a green kontrollresultat was being handed to the agent as if it meant
* "safe to file". The tool now also runs the local completeness checks and
* reports the two verdicts under separate keys.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { VatDeclarationRutor } from '@/types'
const mockSkvRequest = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, skvRequest: (...a: unknown[]) => mockSkvRequest(...a) }
})
vi.mock('@/extensions/general/skatteverket/lib/declaration-prep', () => ({
buildMomsuppgift: vi.fn(async () => ({
redovisare: '165560000000',
redovisningsperiod: '202601',
momsuppgift: { summaMoms: 2500 },
})),
resolveRedovisare: vi.fn(async () => '165560000000'),
}))
vi.mock('@/extensions/general/skatteverket/lib/audit', () => ({
writeSkatteverketAudit: vi.fn(),
}))
// The declaration figures are the unit under test here, so they are supplied
// directly instead of being reconstructed from a ledger fixture.
const mockCalculateVatDeclaration = vi.fn()
vi.mock('@/lib/reports/vat-declaration', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
calculateVatDeclaration: (...a: unknown[]) => mockCalculateVatDeclaration(...a),
}
})
// The per-verifikat FK004 scan has its own coverage in lib/reports/__tests__;
// here it must simply not add findings of its own.
vi.mock('@/lib/reports/rc-basis-gaps', () => ({
findRcBasisGaps: vi.fn(async () => []),
}))
import { tools } from '../server'
const validate = tools.find((t) => t.name === 'gnubok_vat_declaration_validate')!
/** Full SKV 4700 projection with ruta 49 derived, so SUMMA_MOMS_DRIFT stays quiet. */
function makeRutor(partial: Partial<VatDeclarationRutor> = {}): VatDeclarationRutor {
const base: VatDeclarationRutor = {
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
ruta30: 0, ruta31: 0, ruta32: 0,
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0,
ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
ruta48: 0, ruta49: 0,
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
...partial,
}
base.ruta49 =
base.ruta10 + base.ruta11 + base.ruta12 +
base.ruta30 + base.ruta31 + base.ruta32 +
base.ruta60 + base.ruta61 + base.ruta62 -
base.ruta48
return base
}
/**
* `rcInputAccountTotals` is the 2645/2647 pair the real calculateVatDeclaration
* carries alongside the rutor. Omitting it here is not neutral: the tool then
* runs RC_INPUT_VAT_MISMATCH against the ruta 48 aggregate instead of the
* reverse-charge input accounts, which is exactly the weaker form.
*/
function setDeclaration(
rutor: VatDeclarationRutor,
rcInput?: Record<string, { debit: number; credit: number }>,
) {
mockCalculateVatDeclaration.mockResolvedValue({
rutor,
...(rcInput ? { rcInputAccountTotals: rcInput } : {}),
})
}
/** Period debit balances for 2645/2647, in the wire shape the declaration uses. */
const rcInput = (accounts: Record<string, number>) =>
Object.fromEntries(
['2645', '2647'].map((a) => [a, { debit: accounts[a] ?? 0, credit: 0 }]),
)
/** A period that would be filed cleanly: RC basis, fiktiv moms and input all present. */
const CLEAN = makeRutor({ ruta05: 10000, ruta10: 2500, ruta21: 5000, ruta30: 1250, ruta48: 1250 })
/** The FK004 shape: fiktiv moms in ruta 30 with no beskattningsunderlag behind it. */
const INCOMPLETE = makeRutor({ ruta05: 10000, ruta10: 2500, ruta30: 1250, ruta48: 1450 })
const ARGS = { period_type: 'monthly', year: 2026, period: 1 }
const supabase = {} as never
function skvOk(body: unknown = { kontrollResultat: { status: 'OK', resultat: [] } }) {
mockSkvRequest.mockResolvedValue({ ok: true, status: 200, json: async () => body })
}
let prevEnv: string | undefined
beforeEach(() => {
vi.clearAllMocks()
prevEnv = process.env.SKATTEVERKET_ENABLED
process.env.SKATTEVERKET_ENABLED = 'true'
})
afterEach(() => {
if (prevEnv === undefined) delete process.env.SKATTEVERKET_ENABLED
else process.env.SKATTEVERKET_ENABLED = prevEnv
})
interface ValidateResult {
arithmetic_ok: boolean
completeness_ok: boolean
completeness_checks: { code: string; status: string; message: string; rutor: string[] }[]
summary: string
kontrollresultat: unknown
}
async function run(): Promise<ValidateResult> {
return (await validate.execute(ARGS, 'company-1', 'user-1', supabase, {
type: 'api_key',
})) as ValidateResult
}
describe('gnubok_vat_declaration_validate', () => {
it('separates arithmetic (Skatteverket) from completeness (local) on an incomplete declaration', async () => {
setDeclaration(INCOMPLETE)
skvOk()
const result = await run()
// Skatteverket is happy: the boxes add up.
expect(result.arithmetic_ok).toBe(true)
// We are not: ruta 30 has no basbelopp in rutor 20-24.
expect(result.completeness_ok).toBe(false)
const codes = result.completeness_checks.map((c) => c.code)
expect(codes).toContain('RC_BASIS_MISSING')
expect(
result.completeness_checks.find((c) => c.code === 'RC_BASIS_MISSING')?.status,
).toBe('ERROR')
// The summary must not let a green SKV result read as an all-clear.
expect(result.summary).toMatch(/Skatteverket/)
expect(result.summary).toMatch(/ofullständig/)
})
it('reports both green on a clean period, and still calls only /kontrollera', async () => {
setDeclaration(CLEAN)
skvOk()
const result = await run()
expect(result.arithmetic_ok).toBe(true)
expect(result.completeness_ok).toBe(true)
expect(result.completeness_checks).toEqual([])
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/kontrollera\//)
})
// The masking case: rutor 30-32 are compared against 2645/2647, so ordinary
// debiterad ingående moms sitting in the same ruta 48 can no longer hide a
// missing beräknad ingående moms on an otherwise well-formed declaration.
it('reports incomplete when no 2645/2647 backs the fiktiv utgående moms, even though ruta 48 is larger', async () => {
// Underlag correctly booked (ruta 21 = 200 000 against ruta 30 = 50 000), so
// this is NOT FK004; ruta 48 carries 60 000 kr of ordinary 2641 only.
const masked = makeRutor({ ruta21: 200000, ruta30: 50000, ruta48: 60000 })
setDeclaration(masked, rcInput({}))
skvOk()
const result = await run()
// Skatteverket still validates it: the boxes add up.
expect(result.arithmetic_ok).toBe(true)
const mismatch = result.completeness_checks.find((c) => c.code === 'RC_INPUT_VAT_MISMATCH')
expect(mismatch?.status).toBe('WARNING')
// \s, not a literal space: sv-SE groups thousands with a no-break space.
expect(mismatch?.message).toMatch(/50\s000 kr saknas/)
expect(result.completeness_checks.map((c) => c.code)).not.toContain('RC_BASIS_MISSING')
// A WARNING does not block filing (isFilingBlocked reads ERROR only): the
// shortfall is legally correct under limited avdragsrätt.
expect(result.completeness_ok).toBe(true)
// Without the pair the same declaration comes back with nothing to say,
// which is what the ruta 48 comparison alone does.
setDeclaration(masked)
skvOk()
expect((await run()).completeness_checks).toEqual([])
})
it('stays quiet when 2645 mirrors the fiktiv utgående moms exactly', async () => {
setDeclaration(
makeRutor({ ruta21: 200000, ruta30: 50000, ruta48: 110000 }),
rcInput({ '2645': 50000 }),
)
skvOk()
const result = await run()
expect(result.completeness_checks).toEqual([])
expect(result.completeness_ok).toBe(true)
})
it('arithmetic_ok is false when Skatteverket returns an ERROR, independently of completeness', async () => {
setDeclaration(CLEAN)
skvOk({
kontrollResultat: {
status: 'ERROR',
resultat: [{ kod: '49', status: 'ERROR', beskrivning: 'Summa moms stämmer inte' }],
},
})
const result = await run()
expect(result.arithmetic_ok).toBe(false)
expect(result.completeness_ok).toBe(true)
expect(result.summary).toMatch(/1 fel/)
})
it('reads an unwrapped kontrollresultat body too (no silent all-clear on a shape change)', async () => {
setDeclaration(CLEAN)
skvOk({ status: 'ERROR', resultat: [{ kod: '20', status: 'ERROR', beskrivning: 'fel' }] })
const result = await run()
expect(result.arithmetic_ok).toBe(false)
})
it('declares the two verdicts in its output schema and stays inside the description budget', () => {
const schema = validate.outputSchema as {
properties: Record<string, unknown>
required: string[]
}
expect(schema.properties).toHaveProperty('arithmetic_ok')
expect(schema.properties).toHaveProperty('completeness_ok')
expect(schema.properties).toHaveProperty('completeness_checks')
expect(schema.required).toContain('arithmetic_ok')
expect(schema.required).toContain('completeness_ok')
expect(validate.description.length).toBeLessThanOrEqual(280)
// The description must not imply Skatteverket checked the underlag.
expect(validate.description).toMatch(/completeness/i)
})
})