Add/customer mcp (#1055)
* feat(mcp): kontoplan account tools + verifikat notes exposure Two gaps reported by an MCP-driven user: no account management in the API, and verifikat notes invisible to agents (they exist in the product but MCP could neither read nor write them). - add staged gnubok_create_account / gnubok_update_account (BAS 2026 prefill for catalog numbers; rename/VAT-default/SRU/activate via update; both LOW risk reference data) - add staged gnubok_set_voucher_note (notes-only annotation, legal on posted entries per the 20260608120000 trigger carve-out) and return entry_notes from gnubok_query_journal - new pending_operations types create_account / update_account / set_voucher_note (CHECK migration + validate companion, applied to staging) - tools/list payload ceiling 54K -> 56K (documented; wire contract, descriptions trimmed first) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): unstick BankID connect flow and stale connection views - respond to the OAuth callback immediately and run the post-connect refresh after the response (next/server after()): users no longer stare at Skatteverket's consumed consent page for up to 40s - open the consent flow in a full tab instead of a 600x750 popup that hid the approve button below the fold - disable connect buttons while the OAuth tab is open (parallel flows overwrote oauth_state + the PKCE verifier) and recover via a closed-tab watcher plus a delayed status refetch - persist MISSING_SCOPE token health from the post-connect sync and show an actionable "approve all permissions" notice - refetch connection state on tab visibility (settings connect panel, enable-banking panel, /skattekonto) so a connect completed in another tab or after a mobile app-switch shows up without a manual reload; fix /skattekonto never clearing its not-connected state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(article-form): add article number field with validation to ArticleForm * feat(account): enforce account type consistency with BAS class and add validation --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
bfd5b42eb1
commit
0e9cca2750
@@ -62,6 +62,34 @@ export default function BankingSettingsPanel() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Latest-ref so the visibility listener below (subscribed once) always
|
||||
// calls the current render's fetchConnections, which closes over company
|
||||
// context that may resolve after mount.
|
||||
const fetchConnectionsRef = useRef(fetchConnections)
|
||||
useEffect(() => {
|
||||
fetchConnectionsRef.current = fetchConnections
|
||||
})
|
||||
|
||||
// Safety net for completion signals that never reach this tab: a mobile
|
||||
// BankID app-switch can land the bank's redirect in a different browser
|
||||
// tab, the user can close the finalize page before its redirect, and a
|
||||
// bfcache-restored page shows a pre-connection snapshot. Refetch when the
|
||||
// tab regains visibility (background refresh, no spinner: fetchConnections
|
||||
// only blanks the panel on first load), throttled so rapid tab toggling
|
||||
// doesn't hammer the API.
|
||||
const lastVisibilityFetchRef = useRef(0)
|
||||
useEffect(() => {
|
||||
function onVisible() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
const now = Date.now()
|
||||
if (now - lastVisibilityFetchRef.current < 5_000) return
|
||||
lastVisibilityFetchRef.current = now
|
||||
void fetchConnectionsRef.current()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => document.removeEventListener('visibilitychange', onVisible)
|
||||
}, [])
|
||||
|
||||
// Auto-open the picker when the user lands here from the OAuth callback
|
||||
// (URL: /settings/banking?select_accounts=<id>). The query param is stripped
|
||||
// afterwards so a refresh doesn't keep reopening it.
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Unit tests for the staged kontoplan tools: gnubok_create_account and
|
||||
* gnubok_update_account. Covers registration/scope/risk-tier wiring, the
|
||||
* BAS 2026 prefill (resolve-don't-guess), the duplicate/inactive pre-flight
|
||||
* gates, and dry-run staging behaviour. Executor-side coverage
|
||||
* (commitCreateAccount / commitUpdateAccount) lives in
|
||||
* lib/pending-operations/__tests__/account-and-note-executors.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { tools } from '../server'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
const createAccount = tools.find((t) => t.name === 'gnubok_create_account')!
|
||||
const updateAccount = tools.find((t) => t.name === 'gnubok_update_account')!
|
||||
|
||||
/**
|
||||
* A 4-digit number guaranteed absent from the BAS 2026 catalog, in classes
|
||||
* 4-7 so the fixture's account_type 'expense' passes the class/type
|
||||
* consistency guard.
|
||||
*/
|
||||
function findNonBasNumber(): string {
|
||||
for (let n = 4000; n <= 7999; n++) {
|
||||
const candidate = String(n)
|
||||
if (!getBASReference(candidate)) return candidate
|
||||
}
|
||||
throw new Error('BAS catalog unexpectedly covers every 4-digit expense number')
|
||||
}
|
||||
const NON_BAS_NUMBER = findNonBasNumber()
|
||||
|
||||
const noopSupabase = { from: vi.fn() } as never
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('kontoplan tools: registration', () => {
|
||||
it('both tools exist, stage, and declare strict schemas', () => {
|
||||
for (const tool of [createAccount, updateAccount]) {
|
||||
expect(tool).toBeDefined()
|
||||
expect((tool.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe(false)
|
||||
const out = tool.outputSchema as { properties?: Record<string, unknown>; required?: string[] }
|
||||
expect(out?.properties?.staged).toBeDefined()
|
||||
expect(out?.required).toContain('staged')
|
||||
expect(tool.description).toMatch(/stag(e|es|ing)/i)
|
||||
expect(tool.annotations.readOnlyHint).toBe(false)
|
||||
expect(tool.annotations.destructiveHint).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('only requires account_number', () => {
|
||||
expect((createAccount.inputSchema as { required?: string[] }).required).toEqual(['account_number'])
|
||||
expect((updateAccount.inputSchema as { required?: string[] }).required).toEqual(['account_number'])
|
||||
})
|
||||
|
||||
it('is mapped to bookkeeping:write scope and low risk tier', () => {
|
||||
expect(TOOL_SCOPE_MAP.gnubok_create_account).toBe('bookkeeping:write')
|
||||
expect(TOOL_SCOPE_MAP.gnubok_update_account).toBe('bookkeeping:write')
|
||||
expect(OPERATION_RISK_TIERS.create_account).toBe('low')
|
||||
expect(OPERATION_RISK_TIERS.update_account).toBe('low')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_create_account: validation gates', () => {
|
||||
it('rejects a non-4-digit account number before any DB call', async () => {
|
||||
await expect(
|
||||
createAccount.execute({ account_number: '193' }, 'company-1', 'user-1', noopSupabase),
|
||||
).rejects.toThrow(/4 digits/)
|
||||
await expect(
|
||||
createAccount.execute({ account_number: '19300' }, 'company-1', 'user-1', noopSupabase),
|
||||
).rejects.toThrow(/4 digits/)
|
||||
})
|
||||
|
||||
it('rejects when the account already exists and is active', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true } })
|
||||
await expect(
|
||||
createAccount.execute({ account_number: '5410' }, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/finns redan/)
|
||||
})
|
||||
|
||||
it('points to gnubok_update_account when the account exists but is inactive', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: false } })
|
||||
await expect(
|
||||
createAccount.execute({ account_number: '5410' }, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/inaktivt.*is_active=true/s)
|
||||
})
|
||||
|
||||
it('rejects a non-BAS number without name/type/balance', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // no existing row
|
||||
await expect(
|
||||
createAccount.execute({ account_number: NON_BAS_NUMBER }, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/not in the BAS 2026 catalog/)
|
||||
})
|
||||
|
||||
it('rejects a percent-style default_vat_rate (must be a fraction)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
await expect(
|
||||
createAccount.execute(
|
||||
{ account_number: '5410', default_vat_rate: 25 },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/fraction, not percent/)
|
||||
})
|
||||
|
||||
it('rejects an account_type inconsistent with the BAS class digit', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // no existing row
|
||||
await expect(
|
||||
createAccount.execute(
|
||||
{ account_number: '2999', account_name: 'Fel', account_type: 'expense', normal_balance: 'debit' },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/BAS class 2/)
|
||||
})
|
||||
|
||||
it('exposes untaxed_reserves in the input schema enum (21xx round-trip)', () => {
|
||||
const props = (createAccount.inputSchema as { properties: Record<string, { enum?: string[] }> }).properties
|
||||
expect(props.account_type.enum).toContain('untaxed_reserves')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_create_account: staging behaviour (dry_run)', () => {
|
||||
it('prefills name/type/balance/SRU from the BAS catalog', async () => {
|
||||
const ref = getBASReference('5410')!
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // no existing row
|
||||
const result = (await createAccount.execute(
|
||||
{ account_number: '5410', dry_run: true },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { dry_run?: boolean; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.dry_run).toBe(true)
|
||||
expect(result.preview).toMatchObject({
|
||||
account_number: '5410',
|
||||
account_name: ref.account_name,
|
||||
account_type: ref.account_type,
|
||||
normal_balance: ref.normal_balance,
|
||||
plan_type: 'full_bas',
|
||||
source: 'bas_2026',
|
||||
})
|
||||
})
|
||||
|
||||
it('explicit args win over the BAS prefill', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
const result = (await createAccount.execute(
|
||||
{ account_number: '5410', account_name: 'Verktyg och maskiner', dry_run: true },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { preview: Record<string, unknown> }
|
||||
|
||||
expect(result.preview.account_name).toBe('Verktyg och maskiner')
|
||||
expect(result.preview.source).toBe('bas_2026')
|
||||
})
|
||||
|
||||
it('stages a fully-specified custom account as plan_type k1 / source custom', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
const result = (await createAccount.execute(
|
||||
{
|
||||
account_number: NON_BAS_NUMBER,
|
||||
account_name: 'Eget specialkonto',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
dry_run: true,
|
||||
},
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { preview: Record<string, unknown> }
|
||||
|
||||
expect(result.preview).toMatchObject({
|
||||
account_number: NON_BAS_NUMBER,
|
||||
account_name: 'Eget specialkonto',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
plan_type: 'k1',
|
||||
source: 'custom',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_update_account', () => {
|
||||
it('rejects a non-4-digit account number before any DB call', async () => {
|
||||
await expect(
|
||||
updateAccount.execute({ account_number: 'abcd' }, 'company-1', 'user-1', noopSupabase),
|
||||
).rejects.toThrow(/4 digits/)
|
||||
})
|
||||
|
||||
it('points to gnubok_create_account when the account does not exist', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
await expect(
|
||||
updateAccount.execute(
|
||||
{ account_number: '5410', account_name: 'Nytt namn' },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/finns inte.*gnubok_create_account/s)
|
||||
})
|
||||
|
||||
it('rejects a call with no fields to change', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true } })
|
||||
await expect(
|
||||
updateAccount.execute({ account_number: '5410' }, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/Nothing to update/)
|
||||
})
|
||||
|
||||
it('dry-run preview carries current values and the requested changes', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
account_number: '5410',
|
||||
account_name: 'Förbrukningsinventarier',
|
||||
description: null,
|
||||
default_vat_code: null,
|
||||
default_vat_rate: null,
|
||||
sru_code: '7321', // 5410's catalog value (lib/bookkeeping/bas-data)
|
||||
is_active: true,
|
||||
},
|
||||
})
|
||||
const result = (await updateAccount.execute(
|
||||
{ account_number: '5410', account_name: 'Verktyg', is_active: false, dry_run: true },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { dry_run?: boolean; preview: { current: Record<string, unknown>; changes: Record<string, unknown> } }
|
||||
|
||||
expect(result.dry_run).toBe(true)
|
||||
expect(result.preview.current.account_name).toBe('Förbrukningsinventarier')
|
||||
expect(result.preview.changes).toEqual({ account_name: 'Verktyg', is_active: false })
|
||||
})
|
||||
})
|
||||
@@ -116,9 +116,16 @@ describe('tools/list payload size guard', () => {
|
||||
// the client can target another authorized company without shared
|
||||
// mutable connection state. The repeated property is intentionally
|
||||
// minimal; gnubok_list_companies and initialize instructions explain it.
|
||||
// * 54K → 56K with kontoplan management + verifikat notes (MCP parity
|
||||
// requested by an MCP-driven user): staged gnubok_create_account /
|
||||
// gnubok_update_account (kontoplan reference data, BAS 2026 prefill)
|
||||
// + gnubok_set_voucher_note (notes-only annotation, trigger-guarded),
|
||||
// each inlining STAGED_OPERATION_SCHEMA + _meta + company_id routing.
|
||||
// Descriptions and property prose trimmed first; the remainder is
|
||||
// wire contract.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(54_000)
|
||||
expect(approxTokens).toBeLessThan(56_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,6 +128,7 @@ function makeLineRow(opts: {
|
||||
credit_amount?: number
|
||||
line_description?: string | null
|
||||
entry_description?: string
|
||||
entry_notes?: string | null
|
||||
voucher_number?: number
|
||||
entry_date?: string
|
||||
}) {
|
||||
@@ -147,12 +148,34 @@ function makeLineRow(opts: {
|
||||
voucher_series: 'A',
|
||||
entry_date: opts.entry_date ?? '2026-03-15',
|
||||
description: opts.entry_description ?? '',
|
||||
notes: opts.entry_notes ?? null,
|
||||
source_type: 'bank_transaction',
|
||||
status: 'posted',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('gnubok_query_journal: entry notes (verifikat-anteckningar)', () => {
|
||||
it('surfaces journal_entries.notes as entry_notes on every returned line', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const rows = [
|
||||
makeLineRow({ id: 'l1', entry_notes: 'Avser Q1-hyran, se mail 12/3' }),
|
||||
makeLineRow({ id: 'l2' }),
|
||||
]
|
||||
const supabase = makeChainMock(rows, rows.length)
|
||||
|
||||
const result = (await tool.execute(
|
||||
{ accounts: ['4010'] },
|
||||
'company-1', 'user-1', supabase,
|
||||
)) as { lines: Array<{ line_id: string; entry_notes: string | null }> }
|
||||
|
||||
expect(result.lines.find((l) => l.line_id === 'l1')?.entry_notes).toBe(
|
||||
'Avser Q1-hyran, se mail 12/3',
|
||||
)
|
||||
expect(result.lines.find((l) => l.line_id === 'l2')?.entry_notes).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_query_journal: execute', () => {
|
||||
it('applies amount_min filter and computes totals on the filtered set', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Unit tests for gnubok_set_voucher_note: registration/scope/risk wiring,
|
||||
* input validation, the not-found pre-flight, note normalisation ('' → null),
|
||||
* and dry-run staging with dateForPeriodCheck threading. Executor-side
|
||||
* coverage (commitSetVoucherNote) lives in
|
||||
* lib/pending-operations/__tests__/account-and-note-executors.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { tools } from '../server'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
|
||||
|
||||
const tool = tools.find((t) => t.name === 'gnubok_set_voucher_note')!
|
||||
|
||||
const ENTRY_ROW = {
|
||||
id: 'je-1',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 42,
|
||||
entry_date: '2026-03-15',
|
||||
description: 'Hyra mars',
|
||||
status: 'posted',
|
||||
notes: 'Gammal anteckning',
|
||||
}
|
||||
|
||||
const noopSupabase = { from: vi.fn() } as never
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('gnubok_set_voucher_note: registration', () => {
|
||||
it('is registered, stages, and declares a strict schema', () => {
|
||||
expect(tool).toBeDefined()
|
||||
expect((tool.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe(false)
|
||||
const out = tool.outputSchema as { properties?: Record<string, unknown>; required?: string[] }
|
||||
expect(out?.properties?.staged).toBeDefined()
|
||||
expect(out?.required).toContain('staged')
|
||||
expect(tool.description).toMatch(/stag(e|es|ing)/i)
|
||||
})
|
||||
|
||||
it('requires journal_entry_id AND notes (explicit null to clear, never implicit)', () => {
|
||||
expect((tool.inputSchema as { required?: string[] }).required).toEqual(['journal_entry_id', 'notes'])
|
||||
})
|
||||
|
||||
it('is mapped to bookkeeping:write scope and low risk tier', () => {
|
||||
expect(TOOL_SCOPE_MAP.gnubok_set_voucher_note).toBe('bookkeeping:write')
|
||||
expect(OPERATION_RISK_TIERS.set_voucher_note).toBe('low')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_set_voucher_note: validation gates', () => {
|
||||
it('rejects a missing journal_entry_id before any DB call', async () => {
|
||||
await expect(
|
||||
tool.execute({ journal_entry_id: '', notes: 'x' }, 'company-1', 'user-1', noopSupabase),
|
||||
).rejects.toThrow(/journal_entry_id/)
|
||||
})
|
||||
|
||||
it('rejects non-string non-null notes before any DB call', async () => {
|
||||
await expect(
|
||||
tool.execute({ journal_entry_id: 'je-1', notes: 42 }, 'company-1', 'user-1', noopSupabase),
|
||||
).rejects.toThrow(/string.*null/s)
|
||||
})
|
||||
|
||||
it('rejects notes longer than 2000 chars before any DB call', async () => {
|
||||
await expect(
|
||||
tool.execute(
|
||||
{ journal_entry_id: 'je-1', notes: 'x'.repeat(2001) },
|
||||
'company-1', 'user-1', noopSupabase,
|
||||
),
|
||||
).rejects.toThrow(/2000/)
|
||||
})
|
||||
|
||||
it('rejects when the entry does not exist in this company', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
await expect(
|
||||
tool.execute({ journal_entry_id: 'je-x', notes: 'x' }, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/hittades inte/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_set_voucher_note: staging behaviour (dry_run)', () => {
|
||||
it('previews old and new note with the voucher label', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: ENTRY_ROW })
|
||||
const result = (await tool.execute(
|
||||
{ journal_entry_id: 'je-1', notes: 'Avser Q1-hyran, se mail 12/3', dry_run: true },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { dry_run?: boolean; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.dry_run).toBe(true)
|
||||
expect(result.preview).toMatchObject({
|
||||
journal_entry_id: 'je-1',
|
||||
voucher: 'A42',
|
||||
entry_status: 'posted',
|
||||
old_notes: 'Gammal anteckning',
|
||||
new_notes: 'Avser Q1-hyran, se mail 12/3',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalises a whitespace-only note to null (clear)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: ENTRY_ROW })
|
||||
const result = (await tool.execute(
|
||||
{ journal_entry_id: 'je-1', notes: ' ', dry_run: true },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { preview: Record<string, unknown> }
|
||||
|
||||
expect(result.preview.new_notes).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts explicit null to clear the note', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: ENTRY_ROW })
|
||||
const result = (await tool.execute(
|
||||
{ journal_entry_id: 'je-1', notes: null, dry_run: true },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { preview: Record<string, unknown> }
|
||||
|
||||
expect(result.preview.new_notes).toBeNull()
|
||||
})
|
||||
|
||||
it('labels a draft (no voucher number yet) as utkast', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { ...ENTRY_ROW, voucher_number: null, status: 'draft' } })
|
||||
const result = (await tool.execute(
|
||||
{ journal_entry_id: 'je-1', notes: 'Utkast-anteckning', dry_run: true },
|
||||
'company-1', 'user-1', supabase as never,
|
||||
)) as { preview: Record<string, unknown> }
|
||||
|
||||
expect(result.preview.voucher).toBe('utkast')
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,8 @@ import type { SkillTier } from './skills'
|
||||
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
|
||||
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
|
||||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||||
import { accountClassTypeConflict } from '@/lib/pending-operations/schemas/account'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
|
||||
import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions'
|
||||
import {
|
||||
@@ -4947,6 +4949,196 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_create_account',
|
||||
title: 'Create Account (Kontoplan)',
|
||||
description: 'Stage a new kontoplan account. BAS 2026 numbers prefill name/type/SRU (overrides win); custom numbers need account_name, account_type, normal_balance. Inactive existing account? Use gnubok_update_account instead.',
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
account_number: { type: 'string', description: '4-digit number, e.g. "5410".' },
|
||||
account_name: { type: 'string', description: 'Optional for BAS numbers (prefilled).' },
|
||||
account_type: {
|
||||
type: 'string',
|
||||
enum: ['asset', 'equity', 'liability', 'revenue', 'expense', 'untaxed_reserves'],
|
||||
description: 'Required for non-BAS numbers. untaxed_reserves only for 21xx (obeskattade reserver).',
|
||||
},
|
||||
normal_balance: {
|
||||
type: 'string',
|
||||
enum: ['debit', 'credit'],
|
||||
description: 'Required for non-BAS numbers.',
|
||||
},
|
||||
description: { type: 'string' },
|
||||
default_vat_code: { type: 'string' },
|
||||
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
|
||||
sru_code: { type: 'string', description: 'Prefilled for BAS numbers.' },
|
||||
dry_run: { type: 'boolean', description: 'Validate and preview without staging.' },
|
||||
idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' },
|
||||
},
|
||||
required: ['account_number'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const accountNumber = String(args.account_number ?? '').trim()
|
||||
if (!/^\d{4}$/.test(accountNumber)) {
|
||||
throw new Error('account_number must be exactly 4 digits, e.g. "5410".')
|
||||
}
|
||||
|
||||
// Fail fast on numbers already in this company's chart so the approver
|
||||
// is never shown a create that would 409 at commit time.
|
||||
const { data: existing, error: existingErr } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', accountNumber)
|
||||
.maybeSingle()
|
||||
if (existingErr) throw new Error(`Database error: ${existingErr.message}`)
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
existing.is_active
|
||||
? `Konto ${accountNumber} (${existing.account_name}) finns redan i kontoplanen. Ändra det med gnubok_update_account.`
|
||||
: `Konto ${accountNumber} (${existing.account_name}) finns men är inaktivt. Aktivera det med gnubok_update_account (is_active=true).`,
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve-don't-guess: BAS 2026 catalog fills the gaps; explicit args win.
|
||||
const ref = getBASReference(accountNumber)
|
||||
const name = String(args.account_name ?? '').trim() || ref?.account_name
|
||||
const accountType = (args.account_type as string | undefined) ?? ref?.account_type
|
||||
const normalBalance = (args.normal_balance as string | undefined) ?? ref?.normal_balance
|
||||
if (!name || !accountType || !normalBalance) {
|
||||
throw new Error(
|
||||
`${accountNumber} is not in the BAS 2026 catalog: account_name, account_type and normal_balance are required for custom accounts.`,
|
||||
)
|
||||
}
|
||||
// Runtime guard (hosts don't always enforce inputSchema enums).
|
||||
if (!['asset', 'equity', 'liability', 'revenue', 'expense', 'untaxed_reserves'].includes(accountType)) {
|
||||
throw new Error('account_type must be one of: asset, equity, liability, revenue, expense, untaxed_reserves')
|
||||
}
|
||||
if (!['debit', 'credit'].includes(normalBalance)) {
|
||||
throw new Error('normal_balance must be debit or credit')
|
||||
}
|
||||
// Fail fast on a class/type contradiction (e.g. 2999 + expense): the
|
||||
// commit executor derives account_class from the first digit, so an
|
||||
// inconsistent pair would misclassify balance sheet vs income statement.
|
||||
const classConflict = accountClassTypeConflict(accountNumber, accountType)
|
||||
if (classConflict) throw new Error(classConflict)
|
||||
const vatRate = args.default_vat_rate as number | undefined
|
||||
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
|
||||
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
account_number: accountNumber,
|
||||
account_name: name,
|
||||
account_type: accountType,
|
||||
normal_balance: normalBalance,
|
||||
plan_type: ref ? 'full_bas' : 'k1',
|
||||
description: String(args.description ?? '').trim() || ref?.description || undefined,
|
||||
default_vat_code: String(args.default_vat_code ?? '').trim() || undefined,
|
||||
default_vat_rate: vatRate,
|
||||
sru_code: String(args.sru_code ?? '').trim() || ref?.sru_code || undefined,
|
||||
}
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'create_account',
|
||||
`Nytt konto: ${accountNumber} ${name}`,
|
||||
params,
|
||||
{ ...params, source: ref ? 'bas_2026' : 'custom' },
|
||||
actor,
|
||||
{
|
||||
description: 'Once approved, the account is active and can carry voucher lines via gnubok_create_voucher or gnubok_categorize_transaction.',
|
||||
tool: 'gnubok_list_accounts',
|
||||
},
|
||||
{
|
||||
dryRun: Boolean(args.dry_run),
|
||||
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_update_account',
|
||||
title: 'Update Account (Kontoplan)',
|
||||
description: 'Stage an edit to a kontoplan account: rename, description, default VAT, SRU code, or activate/deactivate via is_active. Stages for approval. Find accounts with gnubok_list_accounts.',
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
account_number: { type: 'string', description: '4-digit number of the account to update.' },
|
||||
account_name: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
default_vat_code: { type: 'string' },
|
||||
default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Default VAT rate as a fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' },
|
||||
sru_code: { type: 'string' },
|
||||
is_active: { type: 'boolean', description: 'false deactivates (hides from pickers, keeps history); true (re)activates.' },
|
||||
dry_run: { type: 'boolean' },
|
||||
idempotency_key: { type: 'string' },
|
||||
},
|
||||
required: ['account_number'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const accountNumber = String(args.account_number ?? '').trim()
|
||||
if (!/^\d{4}$/.test(accountNumber)) {
|
||||
throw new Error('account_number must be exactly 4 digits, e.g. "5410".')
|
||||
}
|
||||
|
||||
const { data: current, error: fetchErr } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, description, default_vat_code, default_vat_rate, sru_code, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', accountNumber)
|
||||
.maybeSingle()
|
||||
if (fetchErr) throw new Error(`Database error: ${fetchErr.message}`)
|
||||
if (!current) {
|
||||
throw new Error(`Konto ${accountNumber} finns inte i kontoplanen. Skapa det med gnubok_create_account.`)
|
||||
}
|
||||
|
||||
const vatRate = args.default_vat_rate as number | undefined
|
||||
if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) {
|
||||
throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)')
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = { account_number: accountNumber }
|
||||
const changes: Record<string, unknown> = {}
|
||||
for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'sru_code', 'is_active']) {
|
||||
if (args[key] !== undefined) {
|
||||
params[key] = args[key]
|
||||
changes[key] = args[key]
|
||||
}
|
||||
}
|
||||
if (Object.keys(changes).length === 0) {
|
||||
throw new Error('Nothing to update: pass at least one of account_name, description, default_vat_code, default_vat_rate, sru_code, is_active.')
|
||||
}
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'update_account',
|
||||
`Uppdatera konto ${accountNumber} ${current.account_name}`,
|
||||
params,
|
||||
{ account_number: accountNumber, current, changes },
|
||||
actor,
|
||||
undefined,
|
||||
{
|
||||
dryRun: Boolean(args.dry_run),
|
||||
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Dimensions (kostnadsställe/projekt) ──────────────────────
|
||||
|
||||
{
|
||||
@@ -5823,7 +6015,7 @@ export const tools: McpTool[] = [
|
||||
// The dimensions jsonb only rides along when a group needs it: it is
|
||||
// the widest column on the line and the aggregate pass fetches ALL rows.
|
||||
const dimsSelect = groupByDimension ? ', dimensions' : ''
|
||||
const DISPLAY_SELECT = `id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center${dimsSelect}, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)`
|
||||
const DISPLAY_SELECT = `id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center${dimsSelect}, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, notes, source_type, status, company_id)`
|
||||
// Lean projection for the full-match aggregate pass: only what totals
|
||||
// and group buckets need. journal_entries stays embedded (!inner)
|
||||
// because the entry-level filters bind to it.
|
||||
@@ -5892,6 +6084,7 @@ export const tools: McpTool[] = [
|
||||
voucher_series: string
|
||||
entry_date: string
|
||||
description: string
|
||||
notes: string | null
|
||||
source_type: string
|
||||
status: string
|
||||
}
|
||||
@@ -6073,6 +6266,7 @@ export const tools: McpTool[] = [
|
||||
voucher_number: r.journal_entries.voucher_number,
|
||||
entry_date: r.journal_entries.entry_date,
|
||||
entry_description: r.journal_entries.description,
|
||||
entry_notes: r.journal_entries.notes ?? null,
|
||||
source_type: r.journal_entries.source_type,
|
||||
status: r.journal_entries.status,
|
||||
account_number: r.account_number,
|
||||
@@ -10881,6 +11075,82 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_set_voucher_note',
|
||||
title: 'Set Voucher Note (Anteckning)',
|
||||
description: 'Stage setting, replacing or clearing the internal note (anteckning) on a verifikat. Notes are annotation metadata, editable even on posted entries: bookkeeping fields stay immutable. Read them via gnubok_query_journal (entry_notes).',
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
journal_entry_id: { type: 'string', description: 'Verifikat UUID (find via gnubok_query_journal).' },
|
||||
notes: {
|
||||
type: ['string', 'null'],
|
||||
description: 'New note (max 2000 chars), replaces the old one; null or empty clears.',
|
||||
},
|
||||
dry_run: { type: 'boolean', description: 'Validate and preview without staging.' },
|
||||
idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' },
|
||||
},
|
||||
required: ['journal_entry_id', 'notes'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const journalEntryId = String(args.journal_entry_id ?? '').trim()
|
||||
if (!journalEntryId) throw new Error('journal_entry_id is required')
|
||||
|
||||
if (args.notes !== null && typeof args.notes !== 'string') {
|
||||
throw new Error('notes must be a string (max 2000 chars) or null to clear the note')
|
||||
}
|
||||
// Whitespace-only → null so the column never stores visually-empty
|
||||
// annotations (same normalisation as the commit-boundary schema).
|
||||
const notes = typeof args.notes === 'string' && args.notes.trim() !== '' ? args.notes : null
|
||||
if (notes !== null && notes.length > 2000) {
|
||||
throw new Error('notes must be 2000 characters or shorter')
|
||||
}
|
||||
|
||||
const { data: entry, error: fetchErr } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, voucher_series, voucher_number, entry_date, description, status, notes')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (fetchErr) throw new Error(`Database error: ${fetchErr.message}`)
|
||||
if (!entry) throw new Error('Verifikationen hittades inte.')
|
||||
|
||||
const voucherLabel = entry.voucher_number
|
||||
? `${entry.voucher_series ?? ''}${entry.voucher_number}`
|
||||
: 'utkast'
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'set_voucher_note',
|
||||
notes === null
|
||||
? `Rensa anteckning på verifikat ${voucherLabel}`
|
||||
: `Anteckning på verifikat ${voucherLabel}`,
|
||||
{ journal_entry_id: journalEntryId, notes },
|
||||
{
|
||||
journal_entry_id: journalEntryId,
|
||||
voucher: voucherLabel,
|
||||
entry_description: entry.description,
|
||||
entry_status: entry.status,
|
||||
old_notes: entry.notes ?? null,
|
||||
new_notes: notes,
|
||||
},
|
||||
actor,
|
||||
undefined,
|
||||
{
|
||||
dryRun: Boolean(args.dry_run),
|
||||
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
|
||||
dateForPeriodCheck: entry.entry_date,
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_explain_voucher_gap',
|
||||
title: 'Explain Voucher Gap',
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// after() must be observable: the callback hands it the eager refresh
|
||||
// promise so the serverless function stays alive past the response.
|
||||
vi.mock('next/server', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next/server')>()
|
||||
return { ...actual, after: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('../lib/oauth', () => ({
|
||||
buildAuthorizeUrl: vi.fn().mockReturnValue('https://skv.test/authorize'),
|
||||
generatePkcePair: vi.fn().mockReturnValue({ verifier: 'v', challenge: 'c' }),
|
||||
exchangeCodeForTokens: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/token-store', () => ({
|
||||
storeTokens: vi.fn().mockResolvedValue(undefined),
|
||||
getTokens: vi.fn().mockResolvedValue(null),
|
||||
deleteTokens: vi.fn().mockResolvedValue(undefined),
|
||||
getTokenHealth: vi.fn().mockResolvedValue(null),
|
||||
markNeedsReconsent: vi.fn().mockResolvedValue(undefined),
|
||||
RECONSENT_ERROR_CODES: ['SESSION_EXPIRED', 'REFRESH_EXHAUSTED', 'MISSING_SCOPE', 'TOKEN_CORRUPTED'],
|
||||
}))
|
||||
|
||||
vi.mock('../lib/post-connect-refresh', () => ({
|
||||
runPostConnectRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const { mockCreateClient } = vi.hoisted(() => ({ mockCreateClient: vi.fn() }))
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: mockCreateClient,
|
||||
}))
|
||||
|
||||
import { after } from 'next/server'
|
||||
import { skatteverketExtension } from '../index'
|
||||
import { exchangeCodeForTokens } from '../lib/oauth'
|
||||
import { storeTokens } from '../lib/token-store'
|
||||
import { runPostConnectRefresh } from '../lib/post-connect-refresh'
|
||||
|
||||
const mockExchange = vi.mocked(exchangeCodeForTokens)
|
||||
const mockStoreTokens = vi.mocked(storeTokens)
|
||||
const mockRefresh = vi.mocked(runPostConnectRefresh)
|
||||
|
||||
const STATE = 'state-1'
|
||||
|
||||
/**
|
||||
* Supabase mock covering the callback's extension_data reads (keyed lookups
|
||||
* for oauth_state / oauth_redirect_uri / oauth_code_verifier /
|
||||
* oauth_return_to) and the post-exchange cleanup delete.
|
||||
*/
|
||||
function makeSupabase(overrides: Record<string, string | null> = {}) {
|
||||
const values: Record<string, string | null> = {
|
||||
oauth_state: STATE,
|
||||
oauth_redirect_uri: 'https://app.example/api/extensions/ext/skatteverket/callback',
|
||||
oauth_code_verifier: 'verifier-1',
|
||||
oauth_return_to: '/settings/tax',
|
||||
...overrides,
|
||||
}
|
||||
const from = vi.fn(() => {
|
||||
let key: string | null = null
|
||||
const result = () => ({
|
||||
data: key !== null && values[key] != null ? { value: values[key] } : null,
|
||||
})
|
||||
const chain: any = {
|
||||
select: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
eq: vi.fn((col: string, val: string) => {
|
||||
if (col === 'key') key = val
|
||||
return chain
|
||||
}),
|
||||
in: vi.fn(() => Promise.resolve({ error: null })),
|
||||
single: vi.fn(async () => result()),
|
||||
maybeSingle: vi.fn(async () => result()),
|
||||
}
|
||||
return chain
|
||||
})
|
||||
return {
|
||||
auth: {
|
||||
getUser: vi.fn(async () => ({ data: { user: { id: 'user-1' } } })),
|
||||
},
|
||||
from,
|
||||
}
|
||||
}
|
||||
|
||||
function callbackRoute() {
|
||||
const route = skatteverketExtension.apiRoutes?.find(
|
||||
(r) => r.method === 'GET' && r.path === '/callback',
|
||||
)
|
||||
expect(route, 'GET /callback must be registered').toBeDefined()
|
||||
expect(route!.skipAuth).toBe(true)
|
||||
return route!
|
||||
}
|
||||
|
||||
function callbackRequest(params: string) {
|
||||
return new Request(
|
||||
`https://app.example/api/extensions/ext/skatteverket/callback?${params}`,
|
||||
)
|
||||
}
|
||||
|
||||
describe('skatteverket OAuth callback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateClient.mockResolvedValue(makeSupabase() as any)
|
||||
mockExchange.mockResolvedValue({
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
expires_at: Date.now() + 3_600_000,
|
||||
refresh_count: 0,
|
||||
scope: 'momsdeklaration skahmst agd',
|
||||
})
|
||||
})
|
||||
|
||||
it('responds with the success page WITHOUT awaiting the post-connect refresh', async () => {
|
||||
// A refresh that never settles: if the handler regressed to awaiting it,
|
||||
// this test would hang into the vitest timeout instead of passing.
|
||||
let refreshStarted = false
|
||||
mockRefresh.mockImplementation(() => {
|
||||
refreshStarted = true
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-success')
|
||||
expect(html).toContain('window.close()')
|
||||
|
||||
expect(mockExchange).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
'https://app.example/api/extensions/ext/skatteverket/callback',
|
||||
'verifier-1',
|
||||
)
|
||||
expect(mockStoreTokens).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
expect.objectContaining({ access_token: 'at' }),
|
||||
'company-1',
|
||||
)
|
||||
// The refresh was started eagerly and handed to after() so it survives
|
||||
// past the response; it must not gate the response itself.
|
||||
expect(refreshStarted).toBe(true)
|
||||
expect(vi.mocked(after)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still succeeds when after() is unavailable (outside a request scope)', async () => {
|
||||
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 })
|
||||
vi.mocked(after).mockImplementation(() => {
|
||||
throw new Error('after called outside request scope')
|
||||
})
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain('skatteverket-oauth-success')
|
||||
})
|
||||
|
||||
it('returns the error page on a state (CSRF) mismatch without exchanging the code', async () => {
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest('code=abc&state=wrong-state'),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-error')
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
expect(mockRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the error page when the token exchange fails', async () => {
|
||||
mockExchange.mockRejectedValueOnce(new Error('exchange boom'))
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-error')
|
||||
expect(mockRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -32,15 +32,35 @@ vi.mock('../lib/agi-kvittens-reconcile', () => ({
|
||||
reconcileAgiDeclaration: vi.fn(),
|
||||
}))
|
||||
|
||||
// Real SkatteverketAuthError shape (message, code) without dragging the full
|
||||
// api-client module (and its fetch plumbing) into the test.
|
||||
vi.mock('../lib/api-client', () => ({
|
||||
SkatteverketAuthError: class SkatteverketAuthError extends Error {
|
||||
code: string
|
||||
constructor(message: string, code: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../lib/token-store', () => ({
|
||||
markNeedsReconsent: vi.fn().mockResolvedValue(undefined),
|
||||
RECONSENT_ERROR_CODES: ['SESSION_EXPIRED', 'REFRESH_EXHAUSTED', 'MISSING_SCOPE', 'TOKEN_CORRUPTED'],
|
||||
}))
|
||||
|
||||
import { runPostConnectRefresh } from '../lib/post-connect-refresh'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { syncSkattekonto } from '../lib/skattekonto-sync'
|
||||
import { SkatteverketAuthError } from '../lib/api-client'
|
||||
import { markNeedsReconsent } from '../lib/token-store'
|
||||
import { reconcileAgiDeclaration } from '../lib/agi-kvittens-reconcile'
|
||||
|
||||
const mockCreateExtensionContext = vi.mocked(createExtensionContext)
|
||||
const mockHasCapability = vi.mocked(hasCapability)
|
||||
const mockSyncSkattekonto = vi.mocked(syncSkattekonto)
|
||||
const mockMarkNeedsReconsent = vi.mocked(markNeedsReconsent)
|
||||
const mockReconcile = vi.mocked(reconcileAgiDeclaration)
|
||||
|
||||
const USER = 'user-1'
|
||||
@@ -118,6 +138,33 @@ describe('runPostConnectRefresh', () => {
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result).toEqual({ synced: false, reconciled: 1 })
|
||||
// A plain network/timeout failure is transient: it must not flag the
|
||||
// freshly granted token as needing re-consent.
|
||||
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists needs_reconsent when the sync hits a terminal auth error (MISSING_SCOPE)', async () => {
|
||||
mockSyncSkattekonto.mockRejectedValueOnce(
|
||||
new SkatteverketAuthError('The required scopes are not authorized', 'MISSING_SCOPE'),
|
||||
)
|
||||
const supabase = makeSupabase({ data: [] })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result.synced).toBe(false)
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(supabase, USER, 'MISSING_SCOPE')
|
||||
})
|
||||
|
||||
it('does not persist needs_reconsent for non-terminal auth error codes', async () => {
|
||||
mockSyncSkattekonto.mockRejectedValueOnce(
|
||||
new SkatteverketAuthError('temporary auth hiccup', 'NOT_CONNECTED'),
|
||||
)
|
||||
const supabase = makeSupabase({ data: [] })
|
||||
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result.synced).toBe(false)
|
||||
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues with remaining declarations when one reconcile throws', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from 'crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { NextResponse, after } from 'next/server'
|
||||
import {
|
||||
AGIKontrolleraHUSchema,
|
||||
AGIKontrolleraIUSchema,
|
||||
@@ -109,7 +109,8 @@ const log = createLogger('skatteverket')
|
||||
* SKATTEVERKET_AGD_INLAMNING_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
|
||||
* SKATTEVERKET_AGD_PERIOD_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1
|
||||
* SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
|
||||
* SKATTEVERKET_OAUTH_BASE_URL=https://oauth2.skatteverket.se/oauth2
|
||||
* SKATTEVERKET_OAUTH_BASE_URL=https://peroauth2.skatteverket.se/oauth2/v1/per
|
||||
* (per-flow prod host, verified resolving; oauth2.skatteverket.se does not exist)
|
||||
* 8. Verify Sentry alerts on /api/extensions/ext/skatteverket/* 5xx.
|
||||
* 9. Verify 7-year retention of `agi_declarations.xml_content` +
|
||||
* `kvittensnummer` (BFL 7 kap.).
|
||||
@@ -433,35 +434,29 @@ export const skatteverketExtension: Extension = {
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.in('key', ['oauth_state', 'oauth_return_to', 'oauth_code_verifier'])
|
||||
|
||||
// Refresh Skatteverket-derived data NOW, while the fresh token is
|
||||
// guaranteed alive: SKV per-flow tokens live ~65 minutes, so the
|
||||
// nightly crons usually find them dead and right-after-consent is
|
||||
// the one reliable window for a personal-token fetch. Awaited on
|
||||
// purpose: when the popup closes, the salary/skattekonto pages can
|
||||
// refetch and see synced + auto-settled data instead of racing a
|
||||
// background job. Best-effort: a refresh failure must never fail
|
||||
// the connect that just succeeded.
|
||||
// 30-second deadline: a hung SKV call must not hold the OAuth
|
||||
// callback open. On timeout the refresh keeps running best-effort
|
||||
// (no cancellation); the user still gets the success response.
|
||||
const refreshTimeoutMs = 30000
|
||||
// Refresh Skatteverket-derived data AFTER the response is sent.
|
||||
// Right-after-consent is still the one reliable window for a
|
||||
// personal-token fetch (SKV per-flow tokens live ~65 minutes), but
|
||||
// the sync (skattekonto fetch + AGI auto-settle + kvittens
|
||||
// re-checks) can take tens of seconds. This handler previously
|
||||
// awaited it, which held the redirect open while the popup kept
|
||||
// displaying SKV's already-consumed consent page; users read that
|
||||
// as "I approved and nothing happened". The eager promise +
|
||||
// after() pattern (mirrors the enable-banking finalize page) sends
|
||||
// the success page immediately and keeps the serverless function
|
||||
// alive until the refresh settles; the connect panels do a delayed
|
||||
// status refetch to pick up the synced data. Best-effort: a
|
||||
// refresh failure must never fail the connect that just succeeded.
|
||||
const refreshPromise = runPostConnectRefresh(supabase, user.id, companyId)
|
||||
.then(() => undefined)
|
||||
.catch((refreshErr) => {
|
||||
log.error('post-connect refresh failed', refreshErr, { companyId, userId: user.id })
|
||||
})
|
||||
try {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timedOut = await Promise.race([
|
||||
runPostConnectRefresh(supabase, user.id, companyId).then(() => false),
|
||||
new Promise<true>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), refreshTimeoutMs)
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer))
|
||||
if (timedOut) {
|
||||
log.warn('post-connect refresh timed out', {
|
||||
companyId,
|
||||
userId: user.id,
|
||||
timeoutMs: refreshTimeoutMs,
|
||||
})
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
log.error('post-connect refresh failed', refreshErr, { companyId, userId: user.id })
|
||||
after(() => refreshPromise)
|
||||
} catch {
|
||||
// Outside a request scope (unit tests, plain node server): the
|
||||
// eager promise still drives the refresh to completion.
|
||||
}
|
||||
|
||||
return respondWithSuccess(successPath)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { syncSkattekonto } from './skattekonto-sync'
|
||||
import { SkatteverketAuthError } from './api-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from './token-store'
|
||||
import { reconcileAgiDeclaration, type PendingAgiDeclaration } from './agi-kvittens-reconcile'
|
||||
|
||||
const log = createLogger('skatteverket-post-connect')
|
||||
@@ -64,6 +66,18 @@ export async function runPostConnectRefresh(
|
||||
companyId,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
// A terminal auth error immediately after a fresh consent means the
|
||||
// just-granted token itself is unusable; in practice MISSING_SCOPE,
|
||||
// when the user skipped a behörighet on SKV's consent page. Persist
|
||||
// the health flag (same mechanism as the crons) so /status reports
|
||||
// needsReconsent and the connect panel can prompt with a specific
|
||||
// "godkänn alla behörigheter" message instead of a silent success.
|
||||
if (
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
) {
|
||||
await markNeedsReconsent(supabase, userId, err.code)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user