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:
Mattsson
2026-07-17 14:13:53 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent bfd5b42eb1
commit 0e9cca2750
28 changed files with 1944 additions and 101 deletions
+5
View File
@@ -190,6 +190,10 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-17] Settings PUT keeps update(body) without a pick() allow-list: UpdateSettingsSchema is the write boundary (z.object strips unknown keys; company_id/onboarding_complete are not in the schema, org_number is deleted post-onboarding), so the mass-assignment finding does not apply.
[2026-07-17] Turning vat_registered off (or vat_has_eu_trade off) now coerces the dependent flags (vat_taxable_base_over_40m, vat_has_eu_trade, periodisk_sammanstallning_enabled) to false server-side instead of 400-ing on the stale stored combination; explicitly enabling PS without registration or EU trade still 400s. PS period/filing-method preferences are deliberately preserved while PS is disabled (they are inert until re-enabled).
[2026-07-17] Kept the cron's cross-tenant company_settings/deadlines scans on the service client: a daily all-company repair job is inherently cross-tenant, is cron-secret-gated, and per-company scoping would turn one paginated query into N queries; the per-company writes remain scoped by company_id inside the generator.
[2026-07-17] Skatteverket OAuth callback no longer awaits runPostConnectRefresh (eager promise + next/server after(), mirroring the enable-banking finalize decoupling): awaiting held the redirect open up to ~40s while the popup kept showing SKV's consumed consent page, which users read as a hung connect; the connect panels compensate with a delayed (15s) status refetch. The 2026-07-12 "awaited on purpose" rationale is superseded.
[2026-07-17] Skatteverket connect switched from a 600x750 named popup to window.open(url, '_blank') (new tab): SKV's consent page did not fit the popup (approve button below the fold; prod logs show authorize attempts with no callback ever arriving), and the named-window reuse made retry clicks appear dead. Parallel flows are prevented UI-side (button disabled while the tab lives) because each /authorize overwrites oauth_state + the PKCE verifier.
[2026-07-17] MCP kontoplan + verifikat-note tools stage at LOW risk and tools/list ceiling bumped 54K to 56K: create/update_account are reference data with no journal impact (update_article precedent), set_voucher_note is trigger-guaranteed notes-only (20260608120000 carve-out); the 3 tools inline STAGED_OPERATION_SCHEMA which is wire contract, descriptions were trimmed first.
[2026-07-17] Stale-tab recovery for Skatteverket/bank connections uses throttled visibilitychange refetches (settings connect panel, banking panel, /skattekonto not-connected state) instead of listening for the skatteverket-connection-updated CustomEvent on /skattekonto: that event only propagates within one window, and the connect flow always lives on another route or tab, so a listener there is dead code; cross-tab push (BroadcastChannel) was rejected as disproportionate since a tab switch necessarily precedes the user looking at the stale view.
[2026-07-17] YearEndPreview.netResult now derived from the closing-lines totals (the signed 2099/2010 transfer) instead of generateIncomeStatement: the income statement excludes source_type='year_end' entries, so bokslut-flow depreciation/dispositioner were missing from the preview summary card while the bokslutsverifikation table included them (issue #766); patching the income statement instead was rejected because its exclusion is load-bearing (post-closing RR would collapse to zero) and executeYearEndClosing never reads netResult.
[2026-07-17] Issue #310 vat_rate normalizer lives in lib/vat/supplier-invoice-line-checks.ts (shared by MCP staging and the pending-operation executor) instead of a local helper in mcp-server/server.ts: core cannot import extensions, and the legal-rate set (LEGAL_VAT_RATES) already lives there; duplicating the statutory set in two files invites drift.
[2026-07-09] Billing checkout/portal guard BOTH is_anonymous and is_sandbox: a sandbox-company user created a live Stripe customer via POST /api/billing/checkout (no subscription, so never charged) because neither route checked either flag and withRouteContext admits anonymous users. Both conditions are checked even though they co-occur today (they are orthogonal), the anon check runs first (no DB round trip), and GET /api/billing/status exposes isDemo so the client hides the upgrade CTA instead of showing a button that 403s. One orphan Stripe customer and its company_subscriptions row left for manual cleanup (external destructive action, not done unilaterally).
@@ -200,4 +204,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-10] Reopened the no-LLM call for account intelligence (founder directive): dev_docs/data_quality_master.md v2.1 revises D6. AI is scoped to where the deterministic engine has zero signal (supplier cold-start classification, no-hard-key residue, fleet-cluster naming) under D8 guardrails: persist-first (no model calls on booking hot paths), approval-gated suggest-band only (never preselect, never auto-book), AI priors in a sidecar table not categorization_templates, reuse the existing eu-north-1 Bedrock client (BFL residency), self-hosted/no-creds degrades to deterministic. The deterministic floor still ships first in every workstream; the AI-subsystem removal migration stays valid for what it removed (the auto-booking agent), not for AI as such.
[2026-07-12] Issue #988 (transaction stuck on a removed verifikat): root cause was predicate divergence, the re-booking guards treated ANY non-null transactions.journal_entry_id as linked while the UI counts only status='posted'. Fix is a shared hasLiveJournalEntryLink() so stale pointers (reversed/cancelled/missing) are re-linkable, failing CLOSED on read errors, plus optimistic locks on the exact previous pointer instead of always .is(null). Chose guard self-heal over a null-on-reverse DB trigger because a trigger would fight correctEntry's re-point-to-correction behavior and race the correction insert; reverseEntry/correctEntry re-links stay best-effort. match-invoice/match-supplier-invoice untouched (they key on invoice ids; prod had zero dangling there).
[2026-07-15] Issue #735 result-appropriation: did NOT run the mass 2099-to-2098 backfill. Prod audit showed the script read the FROZEN opening-balance 2099, not the CURRENT posted balance: 202 of 360 planned periods already carried their own disposition (mostly via SIE import) and 43 would have double-moved equity (worst case tens of MSEK on a single company), while periods without an OB entry fabricated balances via the cumulative-history fallback. Posted ONE verified entry for the reporting company (unblocking their arsredovisning) and rewrote the script to a current-balance-safe backfill: eligibility decided from current posted 2099, explicit OB entry required, already-disposed and ambiguous periods skipped to a manual-review report. planResultAppropriation/generateResultAppropriation untouched (steady-state year-end path, covered by pg-real tests); the safety lives in the sweep.
[2026-07-17] create_account now enforces first-digit vs account_type consistency (superRefine at the commit boundary + fail-fast in the MCP tool): class 8 legitimately allows both revenue and expense (financial items per the BAS catalog), classes 0/9 stay unconstrained (free-use per the BAS standard); prevents contradictory rows like 2999+expense whose derived account_class would misclassify balance sheet vs income statement. PR-review findings on set_voucher_note (posted-entry immutability, BFL 5:5 audit trail) were refuted, not fixed: the notes-only carve-out is migration 20260608120000's whole-row to_jsonb diff and audit_journal_entries already records old/new on every UPDATE.
[2026-07-17] Assistenten settings opens on Kunskap (konteringskarta) instead of Minne; dropped the nested Kompetens/Minne tab row inside Kunskap: it duplicated the top-level tabs one row above.
+1
View File
@@ -344,6 +344,7 @@ export default function ArticleDetailPage({
onSubmit={handleUpdate}
isLoading={isUpdating}
initialData={{
article_number: article.article_number || undefined,
name: article.name,
name_en: article.name_en || undefined,
type: article.type,
+3
View File
@@ -78,6 +78,8 @@ const OPERATION_LABEL_KEYS: Record<string, string> = {
create_supplier: 'type_create_supplier',
create_article: 'type_create_article',
update_article: 'type_update_article',
create_account: 'type_create_account',
update_account: 'type_update_account',
create_dimension_value: 'type_create_dimension_value',
// Supplier invoices
create_supplier_invoice_from_inbox: 'type_create_supplier_invoice_from_inbox',
@@ -95,6 +97,7 @@ const OPERATION_LABEL_KEYS: Record<string, string> = {
link_transaction_journal_entry: 'type_link_transaction_journal_entry',
uncategorize_transaction: 'type_uncategorize_transaction',
retag_line_dimensions: 'type_retag_line_dimensions',
set_voucher_note: 'type_set_voucher_note',
// Bulk booking / allocation
match_batch_allocate: 'type_match_batch_allocate',
bulk_book_transactions: 'type_bulk_book_transactions',
+29 -1
View File
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -101,6 +101,10 @@ export default function SkattekontoPage() {
setNotConnected(true)
return
}
// A non-401 response proves a connection now exists: clear a stale
// not-connected state so a reload after connecting (in another tab or
// via the visibility refetch below) actually flips the page over.
setNotConnected(false)
// A non-auth failure must NOT fall through to the "inget saldo hämtat
// ännu"-tomvy — that reads as "not configured" when the truth is "the
@@ -128,6 +132,30 @@ export default function SkattekontoPage() {
void reload()
}, [reload])
// Auto-recover from the "inte anslutet" empty state when the user returns
// to this tab: the connect flow lives in Inställningar (often completed in
// another tab or after a mobile BankID app-switch), so no in-window signal
// can reach this page. Only fires while notConnected is showing: a routine
// tab switch on a healthy page must not flash the loading state. Throttled
// so rapid tab toggling doesn't hammer the API.
const notConnectedRef = useRef(false)
useEffect(() => {
notConnectedRef.current = notConnected
}, [notConnected])
const lastVisibilityReloadRef = useRef(0)
useEffect(() => {
function onVisible() {
if (document.visibilityState !== 'visible') return
if (!notConnectedRef.current) return
const now = Date.now()
if (now - lastVisibilityReloadRef.current < 5_000) return
lastVisibilityReloadRef.current = now
void reload()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [reload])
async function syncNow() {
setSyncing(true)
try {
+35 -17
View File
@@ -131,6 +131,7 @@ export default function ArticleForm({
const schema = useMemo(
() =>
z.object({
article_number: z.string().trim().max(64, t('number_too_long')).optional(),
name: z.string().min(1, t('name_required')),
name_en: z.string().optional(),
type: z.enum(['vara', 'tjanst']),
@@ -161,6 +162,7 @@ export default function ArticleForm({
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
article_number: initialData?.article_number || '',
name: initialData?.name || '',
name_en: initialData?.name_en || '',
type: initialData?.type || 'tjanst',
@@ -180,6 +182,7 @@ export default function ArticleForm({
const onFormSubmit = (data: FormData) => {
onSubmit({
article_number: data.article_number?.trim() || null,
name: data.name,
name_en: data.name_en || null,
type: data.type,
@@ -197,24 +200,39 @@ export default function ArticleForm({
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
{/* Type */}
<div className="space-y-2">
<Label>{t('type_label')}</Label>
<Controller
name="type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue placeholder={t('type_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="vara">{t('type_vara')}</SelectItem>
<SelectItem value="tjanst">{t('type_tjanst')}</SelectItem>
</SelectContent>
</Select>
{/* Type + article number */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('type_label')}</Label>
<Controller
name="type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue placeholder={t('type_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="vara">{t('type_vara')}</SelectItem>
<SelectItem value="tjanst">{t('type_tjanst')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="article_number">{t('number_label')}</Label>
<Input
id="article_number"
className="tabular-nums"
{...register('article_number')}
/>
{errors.article_number ? (
<p className="text-sm text-destructive">{errors.article_number.message}</p>
) : (
<p className="text-xs text-muted-foreground">{t('number_hint')}</p>
)}
/>
</div>
</div>
{/* Name */}
+72 -27
View File
@@ -150,6 +150,11 @@ export function AGIPanel(props: AGIPanelProps) {
const [success, setSuccess] = useState<string | null>(null)
const [chain, setChain] = useState<ChainProgress | null>(null)
const [showAdvanced, setShowAdvanced] = useState(false)
// True while an OAuth tab opened from this panel is still alive. Disables
// the connect buttons so a second click cannot start a parallel flow: each
// /authorize call overwrites the stored oauth_state + PKCE verifier, so a
// parallel flow guarantees a CSRF failure for whichever tab finishes last.
const [connecting, setConnecting] = useState(false)
// "2026-06" for user-facing copy; the period prop is compact YYYYMM.
const prettyPeriod = `${period.slice(0, 4)}-${period.slice(4)}`
@@ -213,9 +218,26 @@ export function AGIPanel(props: AGIPanelProps) {
fetchStatus()
}, [fetchStatus])
// Handle of the OAuth popup opened by handleConnect: used to verify the
// sender identity of incoming postMessages.
// Handle of the OAuth tab opened by handleConnect: used to verify the
// sender identity of incoming postMessages and to detect abandonment.
const popupRef = useRef<Window | null>(null)
const watchTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const delayedRefetchRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const stopWatchingOauthTab = useCallback(() => {
if (watchTimerRef.current) {
clearInterval(watchTimerRef.current)
watchTimerRef.current = null
}
setConnecting(false)
}, [])
useEffect(() => {
return () => {
if (watchTimerRef.current) clearInterval(watchTimerRef.current)
if (delayedRefetchRef.current) clearTimeout(delayedRefetchRef.current)
}
}, [])
// Listen for OAuth completion from the BankID popup. When the popup posts
// back a success/error message we re-fetch status so the panel flips from
@@ -228,6 +250,7 @@ export function AGIPanel(props: AGIPanelProps) {
// same-origin scripts.
if (!popupRef.current || event.source !== popupRef.current) return
if (event.data?.type === 'skatteverket-oauth-success') {
stopWatchingOauthTab()
setError(null)
setSuccess(t('oauth_success'))
fetchStatus()
@@ -235,7 +258,18 @@ export function AGIPanel(props: AGIPanelProps) {
// consumers (e.g. the salary page) can react without trusting raw
// postMessage.
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
// The post-connect refresh (skattekonto sync, AGI settle, token
// health) now runs server-side AFTER the callback responds, so the
// status fetched above predates it. Refetch once more when it has
// plausibly settled so synced data and health flags show up
// without a manual reload.
if (delayedRefetchRef.current) clearTimeout(delayedRefetchRef.current)
delayedRefetchRef.current = setTimeout(() => {
fetchStatus()
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
}, 15_000)
} else if (event.data?.type === 'skatteverket-oauth-error') {
stopWatchingOauthTab()
const reason =
typeof event.data.reason === 'string' && event.data.reason
? event.data.reason
@@ -245,7 +279,7 @@ export function AGIPanel(props: AGIPanelProps) {
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [fetchStatus, t])
}, [fetchStatus, stopWatchingOauthTab, t])
// Drop a stale "AGI-XML saknas" error once the run's AGI is (re)generated.
// That error is set when "Skicka in underlag" runs before the XML exists; if
@@ -377,6 +411,9 @@ export function AGIPanel(props: AGIPanelProps) {
}, [submission?.status, checkKvittens])
const handleDisconnect = useCallback(async () => {
// No disconnect while an OAuth tab is in flight: the callback completing
// right after the disconnect would silently recreate the tokens.
if (connecting) return
setActionLoading('disconnect')
setError(null)
setSuccess(null)
@@ -397,35 +434,43 @@ export function AGIPanel(props: AGIPanelProps) {
} finally {
setActionLoading(null)
}
}, [fetchStatus, onRefreshSubmission, t])
}, [connecting, fetchStatus, onRefreshSubmission, t])
const handleConnect = () => {
// Open the BankID OAuth flow in a centered popup. The callback page
// Open the BankID OAuth flow in a NEW TAB, not a popup. The old 600x750
// popup could not fit Skatteverket's consent page: the approve button
// sat below the fold and users got stranded mid-consent. A tab gets the
// full viewport (and behaves natively on mobile). The callback page
// detects `window.opener` and posts back a `skatteverket-oauth-success`
// (or `-error`) message, then closes itself: see the postMessage
// listener below. `return_to` is still passed so the popup-less fallback
// path (e.g. popup blockers) lands on the salary run page rather than
// the default /reports tab.
// listener below. `return_to` is still passed so the tab-blocked
// fallback path lands on the salary run page rather than the default
// /reports tab.
const returnTo = typeof window !== 'undefined'
? window.location.pathname + window.location.search
: ''
const url = `/api/extensions/ext/skatteverket/authorize${
returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''
}`
const w = 600
const h = 750
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
const popup = window.open(
url,
'skatteverket-oauth',
`width=${w},height=${h},left=${left},top=${top}`,
)
popupRef.current = popup
if (!popup) {
// Popup blocked: fall back to a full-page navigation.
const tab = window.open(url, '_blank')
popupRef.current = tab
if (!tab) {
// Tab blocked: fall back to a full-page navigation.
window.location.href = url
return
}
setConnecting(true)
// Detect abandonment: if the tab goes away without posting a message
// (closed manually, stranded on Skatteverket's side), re-enable the
// buttons and refresh status. This also fires after a successful
// self-close; the extra status fetch is harmless.
if (watchTimerRef.current) clearInterval(watchTimerRef.current)
watchTimerRef.current = setInterval(() => {
if (popupRef.current?.closed) {
stopWatchingOauthTab()
fetchStatus()
}
}, 1000)
}
/**
@@ -803,9 +848,9 @@ export function AGIPanel(props: AGIPanelProps) {
{t('connect_description')}
</p>
{!readOnly && (
<Button onClick={handleConnect}>
<Button onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-2 h-4 w-4" />
{t('connect_button')}
{connecting ? t('connect_waiting') : t('connect_button')}
</Button>
)}
</CardContent>
@@ -851,7 +896,7 @@ export function AGIPanel(props: AGIPanelProps) {
<button
type="button"
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect'}
disabled={actionLoading === 'disconnect' || connecting}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground transition-colors hover:border-destructive/50 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title={t('disconnect_title')}
>
@@ -911,9 +956,9 @@ export function AGIPanel(props: AGIPanelProps) {
<p className="mt-1 text-xs text-muted-foreground">
{t('expired_banner_description')}
</p>
<Button size="sm" variant="outline" className="mt-2" onClick={handleConnect}>
<Button size="sm" variant="outline" className="mt-2" onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
{t('reconnect_button')}
{connecting ? t('connect_waiting') : t('reconnect_button')}
</Button>
</div>
)}
@@ -1076,9 +1121,9 @@ export function AGIPanel(props: AGIPanelProps) {
{error}
{sessionExpired && !readOnly && (
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleConnect}>
<Button size="sm" variant="outline" onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
{t('reconnect_button')}
{connecting ? t('connect_waiting') : t('reconnect_button')}
</Button>
</div>
)}
@@ -45,9 +45,31 @@ function SkatteverketPersonalConnectionCard() {
const [status, setStatus] = useState<Status | null>(null)
const [loading, setLoading] = useState(true)
const [disconnecting, setDisconnecting] = useState(false)
// Handle of the OAuth popup opened by startConnect: used to verify the
// sender identity of incoming postMessages.
// True while an OAuth tab opened from this panel is still alive. Disables
// the connect button so a second click cannot start a parallel flow: each
// /authorize call overwrites the stored oauth_state + PKCE verifier, so a
// parallel flow guarantees a CSRF failure for whichever tab finishes last.
const [connecting, setConnecting] = useState(false)
// Handle of the OAuth tab opened by startConnect: used to verify the
// sender identity of incoming postMessages and to detect abandonment.
const popupRef = useRef<Window | null>(null)
const watchTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const delayedRefetchRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const stopWatchingOauthTab = useCallback(() => {
if (watchTimerRef.current) {
clearInterval(watchTimerRef.current)
watchTimerRef.current = null
}
setConnecting(false)
}, [])
useEffect(() => {
return () => {
if (watchTimerRef.current) clearInterval(watchTimerRef.current)
if (delayedRefetchRef.current) clearTimeout(delayedRefetchRef.current)
}
}, [])
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
const SCOPE_LABELS: Record<string, string> = {
@@ -58,8 +80,12 @@ function SkatteverketPersonalConnectionCard() {
agd: t('scope_agd'),
}
// Only the first load blanks the card to the loading state: later refetches
// (postMessage, closed-tab watcher, delayed sync refetch, visibility) update
// in the background so the panel doesn't flash on every signal.
const hasLoadedRef = useRef(false)
const loadStatus = useCallback(async () => {
setLoading(true)
if (!hasLoadedRef.current) setLoading(true)
try {
const res = await fetch('/api/extensions/ext/skatteverket/status')
if (res.status === 503) {
@@ -71,6 +97,7 @@ function SkatteverketPersonalConnectionCard() {
} catch {
setStatus({ connected: false })
} finally {
hasLoadedRef.current = true
setLoading(false)
}
}, [])
@@ -79,6 +106,24 @@ function SkatteverketPersonalConnectionCard() {
loadStatus()
}, [loadStatus])
// Safety net for completion signals that never reach this tab: a mobile
// BankID app-switch can land the OAuth return in a different browser tab,
// and a bfcache-restored page shows a pre-connection snapshot. Refetch
// status whenever the tab regains visibility, 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
loadStatus()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [loadStatus])
// Listen for OAuth completion from the BankID popup (same pattern as
// AGIPanel): the callback page posts success/error and closes itself, so
// the settings page never navigates and we just re-fetch the status.
@@ -90,6 +135,7 @@ function SkatteverketPersonalConnectionCard() {
// same-origin scripts.
if (!popupRef.current || event.source !== popupRef.current) return
if (event.data?.type === 'skatteverket-oauth-success') {
stopWatchingOauthTab()
toast({
title: tOauth('connected_title'),
description: tOauth('connected_description'),
@@ -99,7 +145,18 @@ function SkatteverketPersonalConnectionCard() {
// consumers (e.g. the salary page) can react without trusting raw
// postMessage.
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
// The post-connect refresh (skattekonto sync, AGI settle, token
// health) now runs server-side AFTER the callback responds, so the
// status fetched above predates it. Refetch once more when it has
// plausibly settled so synced data and health flags (e.g.
// MISSING_SCOPE) show up without a manual reload.
if (delayedRefetchRef.current) clearTimeout(delayedRefetchRef.current)
delayedRefetchRef.current = setTimeout(() => {
loadStatus()
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
}, 15_000)
} else if (event.data?.type === 'skatteverket-oauth-error') {
stopWatchingOauthTab()
toast({
title: tOauth('connect_failed_title'),
description:
@@ -112,35 +169,46 @@ function SkatteverketPersonalConnectionCard() {
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [loadStatus, toast, tOauth])
}, [loadStatus, stopWatchingOauthTab, toast, tOauth])
function startConnect() {
// Open the BankID OAuth flow in a centered popup. The callback page
// Open the BankID OAuth flow in a NEW TAB, not a popup. The old 600x750
// popup could not fit Skatteverket's consent page: the approve button
// sat below the fold and users got stranded mid-consent. A tab gets the
// full viewport (and behaves natively on mobile). The callback page
// detects `window.opener`, posts back a message and closes itself: the
// settings page never navigates, so browser history stays clean and
// closing the settings afterwards cannot walk Back into the consumed
// OAuth chain (the "redirected to Skatteverket again" bug).
const returnTo = encodeURIComponent('/settings/tax')
const url = `/api/extensions/ext/skatteverket/authorize?return_to=${returnTo}`
const w = 600
const h = 750
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
const popup = window.open(
url,
'skatteverket-oauth',
`width=${w},height=${h},left=${left},top=${top}`,
)
popupRef.current = popup
if (!popup) {
// Popup blocked: fall back to the full-page flow. The callback then
const tab = window.open(url, '_blank')
popupRef.current = tab
if (!tab) {
// Tab blocked: fall back to the full-page flow. The callback then
// lands on /settings/tax?skv_connected=true, handled by
// TaxSettingsContent's query-param effect.
window.location.href = url
return
}
setConnecting(true)
// Detect abandonment: if the tab goes away without posting a message
// (closed manually, stranded on Skatteverket's side), re-enable the
// button and refresh status. This also fires after a successful
// self-close; the extra status fetch is harmless.
if (watchTimerRef.current) clearInterval(watchTimerRef.current)
watchTimerRef.current = setInterval(() => {
if (popupRef.current?.closed) {
stopWatchingOauthTab()
loadStatus()
}
}, 1000)
}
async function disconnect() {
// No disconnect while an OAuth tab is in flight: the callback completing
// right after the disconnect would silently recreate the tokens.
if (connecting) return
setDisconnecting(true)
try {
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
@@ -206,11 +274,11 @@ function SkatteverketPersonalConnectionCard() {
)}
<Button
onClick={startConnect}
disabled={status?.disabled || !hasSkatteverket}
disabled={status?.disabled || !hasSkatteverket || connecting}
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
>
<ExternalLink className="mr-2 h-4 w-4" />
{t('connect_with_bankid')}
{connecting ? t('connect_waiting') : t('connect_with_bankid')}
</Button>
</CardContent>
</Card>
@@ -245,7 +313,14 @@ function SkatteverketPersonalConnectionCard() {
{status.needsReconsent && (
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>{t('needs_reconsent_message')}</p>
{/* MISSING_SCOPE right after a connect means the user skipped a
behörighet on SKV's consent page; tell them exactly that
instead of the generic "session expired" prompt. */}
<p>
{status.lastErrorCode === 'MISSING_SCOPE'
? t('missing_scope_message')
: t('needs_reconsent_message')}
</p>
</div>
)}
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
@@ -306,17 +381,17 @@ function SkatteverketPersonalConnectionCard() {
{(status.expired || status.needsReconsent || !status.canRefresh || !(scopes.includes('skahmst') || scopes.includes('skattekonto')) || !scopes.includes('agd')) && (
<Button
onClick={startConnect}
disabled={status.disabled || !hasSkatteverket}
disabled={status.disabled || !hasSkatteverket || connecting}
title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined}
>
<ExternalLink className="mr-2 h-4 w-4" />
{t('reconnect')}
{connecting ? t('connect_waiting') : t('reconnect')}
</Button>
)}
<Button
variant="outline"
onClick={disconnect}
disabled={disconnecting}
disabled={disconnecting || connecting}
>
<ShieldOff className="mr-2 h-4 w-4" />
{disconnecting ? t('disconnecting') : t('disconnect')}
@@ -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')
})
})
+271 -1
View File
@@ -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 () => {
+25 -30
View File
@@ -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 {
+6 -1
View File
@@ -18,7 +18,7 @@ export const API_KEY_SCOPES = {
'suppliers:read': { label: 'Leverantörer: läs', description: 'Lista leverantörer och leverantörsfakturor, hitta verifikat-kandidater (3 verktyg)' },
'suppliers:write': { label: 'Leverantörer: skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor (6 verktyg)' },
'reports:read': { label: 'Rapporter: läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' },
'bookkeeping:write': { label: 'Bokföring: skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar' },
'bookkeeping:write': { label: 'Bokföring: skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar, kontoplan (skapa/ändra konton), verifikat-anteckningar' },
'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
// v1 REST API: added Phase 1
@@ -198,6 +198,11 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_get_kpi_report: 'reports:read',
gnubok_get_income_statement: 'reports:read',
gnubok_list_accounts: 'reports:read',
// Kontoplan management: staged reference-data writes
gnubok_create_account: 'bookkeeping:write',
gnubok_update_account: 'bookkeeping:write',
// Verifikat annotation (notes-only edit: allowed on posted entries)
gnubok_set_voucher_note: 'bookkeeping:write',
gnubok_get_balance_sheet: 'reports:read',
gnubok_get_general_ledger: 'reports:read',
gnubok_query_journal: 'reports:read',
@@ -0,0 +1,316 @@
/**
* Executor tests for the staged kontoplan + verifikat-note operations:
* commitCreateAccount, commitUpdateAccount, commitSetVoucherNote. The
* executors are private to lib/pending-operations/commit.ts and reached
* through commitPendingOperation, same pattern as
* dimension-value-executor.test.ts. Staging-side coverage (the MCP tools'
* pre-flight gates) lives in extensions/general/mcp-server/__tests__/.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { commitPendingOperation } from '../commit'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'create_account',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'low',
created_at: '2026-07-17T00:00:00Z',
resolved_at: null,
updated_at: '2026-07-17T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: create_account', () => {
const validParams = {
account_number: '5410',
account_name: 'Förbrukningsinventarier',
account_type: 'expense',
normal_balance: 'debit',
plan_type: 'full_bas',
sru_code: '7321', // 5410's catalog value (lib/bookkeeping/bas-data)
}
it('happy path: inserts the account and returns committed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { account_number: '5410', account_name: 'Förbrukningsinventarier' } }) // insert
enqueue({ data: null }) // finalize update
const op = makePendingOp({ params: validParams })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
account_number: '5410',
account_name: 'Förbrukningsinventarier',
})
})
it('duplicate account number (23505) auto-rejects with 409', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value' } }) // insert conflict
enqueue({ data: null }) // dispatcher reject update
const op = makePendingOp({ params: validParams })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
// 404/409 are auto-rejected by the dispatcher (re-stageable), not failed.
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(409)
expect(result.error).toMatch(/finns redan/)
})
it('re-validates staged params at the commit boundary (tampered account_type rejected)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null }) // dispatcher reject update
const op = makePendingOp({
params: { ...validParams, account_type: 'weapons_cache' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Invalid account_type/)
})
it('rejects a tampered non-4-digit account_number', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null }) // dispatcher reject update
const op = makePendingOp({
params: { ...validParams, account_number: '5410; DROP TABLE' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Invalid account_number/)
})
it('rejects an account_type inconsistent with the BAS class digit', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null }) // dispatcher reject update
// Class 2 is equity/liability/untaxed_reserves; an expense there would
// put a P&L account on the balance-sheet side of every report.
const op = makePendingOp({
params: { ...validParams, account_number: '2999', account_type: 'expense' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/BAS class 2/)
})
it('accepts class 8 revenue and class 2 untaxed_reserves (legal combinations)', async () => {
for (const params of [
{ ...validParams, account_number: '8310', account_name: 'Ränteintäkter', account_type: 'revenue', normal_balance: 'credit' },
{ ...validParams, account_number: '2150', account_name: 'Ackumulerade överavskrivningar', account_type: 'untaxed_reserves', normal_balance: 'credit' },
]) {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { account_number: params.account_number, account_name: params.account_name } }) // insert
enqueue({ data: null }) // finalize update
const op = makePendingOp({ params })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
}
})
})
describe('commitPendingOperation: update_account', () => {
it('happy path: applies only the provided fields', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { account_number: '5410', account_name: 'Verktyg', is_active: false } }) // update
enqueue({ data: null }) // finalize update
const op = makePendingOp({
operation_type: 'update_account',
params: { account_number: '5410', account_name: 'Verktyg', is_active: false },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
account_number: '5410',
account_name: 'Verktyg',
is_active: false,
})
})
it('empty string clears a stored text field (description → null)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true } }) // update
enqueue({ data: null }) // finalize update
const op = makePendingOp({
operation_type: 'update_account',
params: { account_number: '5410', description: '' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
// '' normalises to null in the update payload (clear), so the op commits
// rather than tripping the empty-change-set guard.
expect(result.status).toBe('committed')
})
it('unknown account (PGRST116) auto-rejects with 404', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null, error: { code: 'PGRST116', message: 'zero rows' } }) // update miss
enqueue({ data: null }) // dispatcher reject update
const op = makePendingOp({
operation_type: 'update_account',
params: { account_number: '5410', is_active: true },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(404)
expect(result.error).toMatch(/hittades inte/)
})
it('rejects an empty change set (tampered params)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null }) // dispatcher reject update
const op = makePendingOp({
operation_type: 'update_account',
params: { account_number: '5410' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Inget att uppdatera/)
})
})
describe('commitPendingOperation: set_voucher_note', () => {
const JE_ID = '3f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b'
it('happy path: notes-only update returns the voucher reference', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: JE_ID, voucher_series: 'A', voucher_number: 42 } }) // update
enqueue({ data: null }) // finalize update
const op = makePendingOp({
operation_type: 'set_voucher_note',
params: { journal_entry_id: JE_ID, notes: 'Avser Q1-hyran' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
journal_entry_id: JE_ID,
voucher_series: 'A',
voucher_number: 42,
notes: 'Avser Q1-hyran',
})
})
it('null clears the note', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: JE_ID, voucher_series: 'A', voucher_number: 42 } }) // update
enqueue({ data: null }) // finalize update
const op = makePendingOp({
operation_type: 'set_voucher_note',
params: { journal_entry_id: JE_ID, notes: null },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ notes: null })
})
it('unknown entry auto-rejects with 404 (no phantom success)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null }) // update matches zero rows (maybeSingle → null)
enqueue({ data: null }) // dispatcher reject update
const op = makePendingOp({
operation_type: 'set_voucher_note',
params: { journal_entry_id: JE_ID, notes: 'x' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(404)
expect(result.error).toMatch(/hittades inte/)
})
it('re-validates staged params at the commit boundary (oversized note rejected)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null }) // dispatcher reject update
const op = makePendingOp({
operation_type: 'set_voucher_note',
params: { journal_entry_id: JE_ID, notes: 'x'.repeat(2001) },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Invalid notes/)
})
it('trigger rejection (e.g. locked period) surfaces as a failed op, not a throw', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null, error: { code: 'P0001', message: 'Bokföringen är låst till och med 2026-03-31' } })
enqueue({ data: null }) // dispatcher reject update
const op = makePendingOp({
operation_type: 'set_voucher_note',
params: { journal_entry_id: JE_ID, notes: 'x' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.error).toMatch(/låst/)
})
})
+152
View File
@@ -79,6 +79,8 @@ import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/cre
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
import { CreateAccountParamsSchema, UpdateAccountParamsSchema } from '@/lib/pending-operations/schemas/account'
import { SetVoucherNoteParamsSchema } from '@/lib/pending-operations/schemas/voucher-note'
import { BulkBookInboxSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
@@ -383,6 +385,147 @@ async function commitUpdateArticle(
return { data: { article_id: data.id } }
}
async function commitCreateAccount(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
// Defense in depth: re-validate the staged params at the commit boundary so
// a tampered pending_operations row cannot inject unexpected fields into
// chart_of_accounts (ASVS V4.5): mirrors commitCreateArticle.
let validated
try {
validated = CreateAccountParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
// Same row shape as the dashboard create route
// (app/api/bookkeeping/accounts/route.ts): class/group/sort_order derive
// from the number so the two write paths cannot drift.
const { data, error } = await supabase
.from('chart_of_accounts')
.insert({
user_id: userId,
company_id: companyId,
account_number: validated.account_number,
account_name: validated.account_name,
account_class: parseInt(validated.account_number[0]),
account_group: validated.account_number.substring(0, 2),
account_type: validated.account_type,
normal_balance: validated.normal_balance,
plan_type: validated.plan_type,
is_active: true,
is_system_account: false,
description: validated.description ?? null,
default_vat_code: validated.default_vat_code ?? null,
default_vat_rate: validated.default_vat_rate ?? null,
sru_code: validated.sru_code ?? null,
sort_order: parseInt(validated.account_number),
})
.select('account_number, account_name')
.single()
if (error) {
if (error.code === '23505') {
return { error: `Kontonummer ${validated.account_number} finns redan i kontoplanen.`, status: 409 }
}
return { error: error.message, status: 500 }
}
return { data: { account_number: data.account_number, account_name: data.account_name } }
}
async function commitUpdateAccount(
supabase: SupabaseClient,
_userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
let validated
try {
validated = UpdateAccountParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
const { account_number, ...rest } = validated
const updateData: Record<string, unknown> = {}
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined) updateData[key] = value
}
if (Object.keys(updateData).length === 0) {
return { error: 'Inget att uppdatera', status: 400 }
}
const { data, error } = await supabase
.from('chart_of_accounts')
.update(updateData)
.eq('company_id', companyId)
.eq('account_number', account_number)
.select('account_number, account_name, is_active')
.single()
if (error) {
if (error.code === 'PGRST116') return { error: 'Kontot hittades inte', status: 404 }
return { error: error.message, status: 500 }
}
return { data: { account_number: data.account_number, account_name: data.account_name, is_active: data.is_active } }
}
async function commitSetVoucherNote(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
let validated
try {
validated = SetVoucherNoteParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
// Notes-only UPDATE: the journal_entries immutability trigger (migration
// 20260608120000) allows exactly this on committed entries and raises on
// anything else, so no status pre-check is needed here. Period-lock and
// company-lock-date triggers still apply and surface as errors.
const { data, error } = await supabase
.from('journal_entries')
.update({ notes: validated.notes })
.eq('id', validated.journal_entry_id)
.eq('company_id', companyId)
.select('id, voucher_series, voucher_number')
.maybeSingle()
if (error) return { error: error.message, status: 400 }
// Zero rows = the entry doesn't exist in this company: report it instead
// of a phantom success (same contract as the dashboard notes route).
if (!data) return { error: 'Verifikationen hittades inte.', status: 404 }
return {
data: {
journal_entry_id: data.id,
voucher_series: data.voucher_series,
voucher_number: data.voucher_number,
notes: validated.notes,
},
}
}
async function commitCreateSupplier(
supabase: SupabaseClient,
userId: string,
@@ -4059,6 +4202,15 @@ async function commitPendingOperationInner(
case 'create_supplier':
result = await commitCreateSupplier(supabase, userId, companyId, pendingOp.params)
break
case 'create_account':
result = await commitCreateAccount(supabase, userId, companyId, pendingOp.params)
break
case 'update_account':
result = await commitUpdateAccount(supabase, userId, companyId, pendingOp.params)
break
case 'set_voucher_note':
result = await commitSetVoucherNote(supabase, companyId, pendingOp.params)
break
case 'create_dimension_value':
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
break
+12
View File
@@ -32,6 +32,18 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// payment-routing surface. Staged (agents never silently mint reporting
// values) but at the lowest tier next to create_customer/create_article.
create_dimension_value: 'low',
// Kontoplan reference data: adding an account has no journal impact (a
// wrong account only becomes bookable, nothing is booked), and update is
// limited to name/description/VAT-default/SRU/is_active: the same surface
// update_article covers for articles. No payment routing, no external
// side-effects.
create_account: 'low',
update_account: 'low',
// Verifikat notes are annotation metadata, not räkenskapsinformation: the
// journal_entries immutability trigger (20260608120000) permits exactly a
// notes-only diff on committed entries and rejects anything more, so the
// op cannot touch booking data even if tampered with.
set_voucher_note: 'low',
// ── Medium: reversible booking ─────────────────────────────────────
categorize_transaction: 'medium',
+107
View File
@@ -0,0 +1,107 @@
import { z } from 'zod'
// Commit-boundary re-validation for staged chart-of-accounts operations
// (gnubok_create_account / gnubok_update_account). A staged
// pending_operations row is re-parsed here before it touches
// chart_of_accounts so a tampered row cannot inject unexpected fields
// (defense in depth, ASVS V4.5): mirrors lib/pending-operations/schemas/article.ts.
//
// account_type includes 'untaxed_reserves' beyond the web UI's five values:
// BAS 2026 carries it for the 21xx group and the batch-activate route already
// inserts it, so a BAS-prefilled staged create must round-trip it too.
const accountNumber = z
.string()
.regex(/^\d{4}$/, 'Account number must be exactly 4 digits')
const accountType = z.enum([
'asset', 'equity', 'liability', 'revenue', 'expense', 'untaxed_reserves',
])
const normalBalance = z.enum(['debit', 'credit'])
// Same shape as defaultVatRate in lib/api/schemas.ts (the dashboard route):
// fraction-of-one, not percent, so the two write paths cannot drift.
const defaultVatRate = z
.union([z.literal(0), z.literal(0.06), z.literal(0.12), z.literal(0.25)])
.nullable()
.optional()
/** Empty string / null → undefined, then bounded string. */
const optString = (max: number) =>
z.preprocess((v) => (v == null || v === '' ? undefined : v), z.string().max(max).optional())
/**
* Update-side variant: empty string → null so an agent can CLEAR a stored
* value ('' and null both mean "remove"); undefined still means "unchanged".
* The executor copies null through to the UPDATE payload.
*/
const clearableString = (max: number) =>
z.preprocess((v) => (v === '' ? null : v), z.string().max(max).nullable().optional())
const trimmedName = z.preprocess(
(v) => (typeof v === 'string' ? v.trim() : v),
z.string().min(1, 'Account name is required').max(200),
)
/**
* BAS class (first digit) → account types that may live there, matching the
* BAS 2026 catalog in lib/bookkeeping/bas-data. Class 8 legitimately holds
* both financial revenue (80xx-83xx) and financial expense (84xx-89xx).
* Classes 0 and 9 are free-use per the BAS standard and stay unconstrained.
* Without this guard a custom account like 2999+expense would be inserted
* with account_class 2, an internally contradictory row that misclassifies
* balance sheet vs income statement in every report.
*/
const BAS_CLASS_ACCOUNT_TYPES: Record<string, readonly string[]> = {
'1': ['asset'],
'2': ['equity', 'liability', 'untaxed_reserves'],
'3': ['revenue'],
'4': ['expense'],
'5': ['expense'],
'6': ['expense'],
'7': ['expense'],
'8': ['revenue', 'expense'],
}
/** Returns an error message when account_type is illegal for the account's BAS class, else null. */
export function accountClassTypeConflict(
accountNumber: string,
accountType: string,
): string | null {
const allowed = BAS_CLASS_ACCOUNT_TYPES[accountNumber[0]]
if (!allowed || allowed.includes(accountType)) return null
return `Account ${accountNumber} is in BAS class ${accountNumber[0]}, which cannot hold account_type '${accountType}' (allowed: ${allowed.join(', ')}).`
}
export const CreateAccountParamsSchema = z
.object({
account_number: accountNumber,
account_name: trimmedName,
account_type: accountType,
normal_balance: normalBalance,
plan_type: z.enum(['k1', 'full_bas']).default('k1'),
description: optString(2000),
default_vat_code: optString(32),
default_vat_rate: defaultVatRate,
sru_code: optString(16),
})
.superRefine((v, ctx) => {
const conflict = accountClassTypeConflict(v.account_number, v.account_type)
if (conflict) {
ctx.addIssue({ code: 'custom', message: conflict, path: ['account_type'] })
}
})
export const UpdateAccountParamsSchema = z.object({
account_number: accountNumber,
account_name: trimmedName.optional(),
description: clearableString(2000),
default_vat_code: clearableString(32),
default_vat_rate: defaultVatRate,
sru_code: clearableString(16),
is_active: z.boolean().optional(),
})
export type CreateAccountParams = z.infer<typeof CreateAccountParamsSchema>
export type UpdateAccountParams = z.infer<typeof UpdateAccountParamsSchema>
@@ -0,0 +1,21 @@
import { z } from 'zod'
// Commit-boundary re-validation for the staged set_voucher_note operation
// (gnubok_set_voucher_note). Notes are annotation metadata alongside the
// verifikat, not räkenskapsinformation: the journal_entries immutability
// trigger (migration 20260608120000) allows a notes-only UPDATE on committed
// entries and rejects anything that touches a bookkeeping field, so this
// schema only has to bound the shape. Max length matches the dashboard
// PATCH route (app/api/bookkeeping/journal-entries/[id]/notes/route.ts).
export const SetVoucherNoteParamsSchema = z.object({
journal_entry_id: z.string().uuid(),
// null clears the note; empty/whitespace-only strings are normalised to
// null so the column never stores visually-empty annotations.
notes: z.preprocess(
(v) => (typeof v === 'string' && v.trim() === '' ? null : v),
z.string().max(2000).nullable(),
),
})
export type SetVoucherNoteParams = z.infer<typeof SetVoucherNoteParamsSchema>
+9
View File
@@ -445,6 +445,8 @@
"type_create_supplier": "New supplier",
"type_create_article": "New article",
"type_update_article": "Updated article",
"type_create_account": "New account",
"type_update_account": "Updated account",
"type_create_dimension_value": "New dimension value",
"type_create_supplier_invoice_from_inbox": "Supplier invoice",
"type_create_self_billed_supplier_invoice": "Self-billed invoice",
@@ -459,6 +461,7 @@
"type_link_transaction_journal_entry": "Link transaction",
"type_uncategorize_transaction": "Undo categorization",
"type_retag_line_dimensions": "Update dimensions",
"type_set_voucher_note": "Voucher note",
"type_match_batch_allocate": "Allocate payment",
"type_bulk_book_transactions": "Bulk booking",
"type_bulk_book_inbox_items": "Bulk-book documents",
@@ -1947,6 +1950,8 @@
"system_verify_failed": "Verification failed",
"system_verify_rate_limited": "Wait a minute between verifications.",
"needs_reconsent_message": "The Skatteverket connection needs to be renewed. Automatic syncing is paused until you reconnect with BankID.",
"missing_scope_message": "The connection lacks permission for one or more services, for example the tax account. Reconnect and approve all permissions on Skatteverket's consent page.",
"connect_waiting": "Waiting for BankID…",
"scope_momsdeklaration": "VAT declaration",
"scope_inkforetag": "Company information",
"scope_skahmst": "Tax account: balance & transactions",
@@ -4381,6 +4386,9 @@
"type_placeholder": "Choose type",
"type_vara": "Goods",
"type_tjanst": "Service",
"number_label": "Article number",
"number_hint": "Leave empty to assign a number automatically.",
"number_too_long": "The article number can be at most 64 characters",
"name_label": "Name *",
"name_placeholder": "E.g. Consulting hour",
"name_required": "Name is required",
@@ -5050,6 +5058,7 @@
"connect_description": "Connect to Skatteverket with BankID to submit AGI directly from accounted.",
"connect_button": "Connect with BankID",
"reconnect_button": "Reconnect with BankID",
"connect_waiting": "Waiting for BankID…",
"connected": "Connected",
"disconnect_button": "Disconnect",
"disconnect_title": "Disconnect the Skatteverket connection",
+9
View File
@@ -445,6 +445,8 @@
"type_create_supplier": "Ny leverantör",
"type_create_article": "Ny artikel",
"type_update_article": "Uppdaterad artikel",
"type_create_account": "Nytt konto",
"type_update_account": "Uppdaterat konto",
"type_create_dimension_value": "Nytt dimensionsvärde",
"type_create_supplier_invoice_from_inbox": "Leverantörsfaktura",
"type_create_self_billed_supplier_invoice": "Självfaktura",
@@ -459,6 +461,7 @@
"type_link_transaction_journal_entry": "Länka transaktion",
"type_uncategorize_transaction": "Ångra kategorisering",
"type_retag_line_dimensions": "Ändra dimensioner",
"type_set_voucher_note": "Anteckning på verifikat",
"type_match_batch_allocate": "Fördela betalning",
"type_bulk_book_transactions": "Massbokföring",
"type_bulk_book_inbox_items": "Massbokför underlag",
@@ -1947,6 +1950,8 @@
"system_verify_failed": "Verifieringen misslyckades",
"system_verify_rate_limited": "Vänta en minut mellan verifieringar.",
"needs_reconsent_message": "Anslutningen till Skatteverket behöver förnyas. Den automatiska synkroniseringen är pausad tills du ansluter igen med BankID.",
"missing_scope_message": "Anslutningen saknar behörighet för en eller flera tjänster, till exempel skattekontot. Anslut igen och godkänn alla behörigheter på Skatteverkets samtyckessida.",
"connect_waiting": "Väntar på BankID…",
"scope_momsdeklaration": "Momsdeklaration",
"scope_inkforetag": "Företagsinformation",
"scope_skahmst": "Skattekonto: saldo & transaktioner",
@@ -4381,6 +4386,9 @@
"type_placeholder": "Välj typ",
"type_vara": "Vara",
"type_tjanst": "Tjänst",
"number_label": "Artikelnummer",
"number_hint": "Lämna tomt så sätts ett nummer automatiskt.",
"number_too_long": "Artikelnumret får vara högst 64 tecken",
"name_label": "Benämning *",
"name_placeholder": "T.ex. Konsulttimme",
"name_required": "Benämning krävs",
@@ -5050,6 +5058,7 @@
"connect_description": "Anslut till Skatteverket med BankID för att skicka AGI direkt från accounted.",
"connect_button": "Anslut med BankID",
"reconnect_button": "Återanslut med BankID",
"connect_waiting": "Väntar på BankID…",
"connected": "Ansluten",
"disconnect_button": "Koppla bort",
"disconnect_title": "Koppla bort anslutningen mot Skatteverket",
@@ -0,0 +1,82 @@
-- Add 'create_account', 'update_account' and 'set_voucher_note' to the
-- pending_operations operation_type CHECK.
--
-- create_account / update_account (gnubok_create_account /
-- gnubok_update_account): staged kontoplan reference-data writes. Risk tier
-- LOW (lib/pending-operations/risk-tiers.ts): no journal impact, no payment
-- routing, no external side-effects.
--
-- set_voucher_note (gnubok_set_voucher_note): notes-only annotation on a
-- verifikat. The journal_entries immutability trigger (20260608120000)
-- permits exactly a notes-only diff on committed entries, so the operation
-- cannot touch booking data. Risk tier LOW.
--
-- The list below is the union with 20260713121000 (previous expansion).
-- tests/pg/pending-operations-op-type-audit.pg.test.ts asserts every op type
-- staged in server.ts or tiered in risk-tiers.ts is accepted here.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher',
'submit_vat_declaration',
'submit_agi',
'create_article',
'update_article',
'bulk_book_inbox_items',
'create_dimension_value',
'retag_line_dimensions',
'link_document_to_voucher',
'update_payslip_line',
'register_absence',
'create_employee',
'update_employee',
'set_employee_opening_balances',
'vacation_year_close',
'create_account', -- kontoplan: stage a new account
'update_account', -- kontoplan: stage rename/VAT-default/SRU/is_active edit
'set_voucher_note' -- verifikat annotation (notes-only, trigger-guarded)
)) NOT VALID;
-- NOT VALID for the same reason as 20260713121000: no full-table scan under
-- ACCESS EXCLUSIVE. Validated in 20260717091000.
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,6 @@
-- Validate the operation_type CHECK re-added NOT VALID in 20260717090000.
-- Runs in its own transaction so the scan takes SHARE UPDATE EXCLUSIVE only
-- (same split as 20260713123000 after 20260713121000).
ALTER TABLE public.pending_operations
VALIDATE CONSTRAINT pending_operations_operation_type_check;
+6
View File
@@ -1960,6 +1960,9 @@ export type PendingOperationType =
| 'create_customer'
| 'create_article'
| 'update_article'
// Kontoplan reference data (gnubok_create_account / gnubok_update_account)
| 'create_account'
| 'update_account'
| 'create_supplier'
| 'create_invoice'
| 'mark_invoice_paid'
@@ -2002,6 +2005,9 @@ export type PendingOperationType =
| 'correct_entry'
// Pure makulering (storno) of a posted entry: agent-native API plan item 38
| 'reverse_entry'
// Notes-only annotation on a verifikat: the immutability trigger's carve-out
// (migration 20260608120000) makes this legal even on posted entries.
| 'set_voucher_note'
// Bokslut: planenlig avskrivning (one journal entry per asset)
| 'post_annual_depreciation'
// Payroll: salary run creation + AGI declaration