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