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
@@ -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>
|
||||
Reference in New Issue
Block a user