Add/customer mcp (#1055)

* feat(mcp): kontoplan account tools + verifikat notes exposure

Two gaps reported by an MCP-driven user: no account management in the
API, and verifikat notes invisible to agents (they exist in the product
but MCP could neither read nor write them).

- add staged gnubok_create_account / gnubok_update_account (BAS 2026
  prefill for catalog numbers; rename/VAT-default/SRU/activate via
  update; both LOW risk reference data)
- add staged gnubok_set_voucher_note (notes-only annotation, legal on
  posted entries per the 20260608120000 trigger carve-out) and return
  entry_notes from gnubok_query_journal
- new pending_operations types create_account / update_account /
  set_voucher_note (CHECK migration + validate companion, applied to
  staging)
- tools/list payload ceiling 54K -> 56K (documented; wire contract,
  descriptions trimmed first)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): unstick BankID connect flow and stale connection views

- respond to the OAuth callback immediately and run the post-connect
  refresh after the response (next/server after()): users no longer
  stare at Skatteverket's consumed consent page for up to 40s
- open the consent flow in a full tab instead of a 600x750 popup that
  hid the approve button below the fold
- disable connect buttons while the OAuth tab is open (parallel flows
  overwrote oauth_state + the PKCE verifier) and recover via a
  closed-tab watcher plus a delayed status refetch
- persist MISSING_SCOPE token health from the post-connect sync and
  show an actionable "approve all permissions" notice
- refetch connection state on tab visibility (settings connect panel,
  enable-banking panel, /skattekonto) so a connect completed in another
  tab or after a mobile app-switch shows up without a manual reload;
  fix /skattekonto never clearing its not-connected state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(article-form): add article number field with validation to ArticleForm

* feat(account): enforce account type consistency with BAS class and add validation

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-17 14:13:53 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent bfd5b42eb1
commit 0e9cca2750
28 changed files with 1944 additions and 101 deletions
+152
View File
@@ -79,6 +79,8 @@ import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/cre
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
import { CreateAccountParamsSchema, UpdateAccountParamsSchema } from '@/lib/pending-operations/schemas/account'
import { SetVoucherNoteParamsSchema } from '@/lib/pending-operations/schemas/voucher-note'
import { BulkBookInboxSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
@@ -383,6 +385,147 @@ async function commitUpdateArticle(
return { data: { article_id: data.id } }
}
async function commitCreateAccount(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
// Defense in depth: re-validate the staged params at the commit boundary so
// a tampered pending_operations row cannot inject unexpected fields into
// chart_of_accounts (ASVS V4.5): mirrors commitCreateArticle.
let validated
try {
validated = CreateAccountParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
// Same row shape as the dashboard create route
// (app/api/bookkeeping/accounts/route.ts): class/group/sort_order derive
// from the number so the two write paths cannot drift.
const { data, error } = await supabase
.from('chart_of_accounts')
.insert({
user_id: userId,
company_id: companyId,
account_number: validated.account_number,
account_name: validated.account_name,
account_class: parseInt(validated.account_number[0]),
account_group: validated.account_number.substring(0, 2),
account_type: validated.account_type,
normal_balance: validated.normal_balance,
plan_type: validated.plan_type,
is_active: true,
is_system_account: false,
description: validated.description ?? null,
default_vat_code: validated.default_vat_code ?? null,
default_vat_rate: validated.default_vat_rate ?? null,
sru_code: validated.sru_code ?? null,
sort_order: parseInt(validated.account_number),
})
.select('account_number, account_name')
.single()
if (error) {
if (error.code === '23505') {
return { error: `Kontonummer ${validated.account_number} finns redan i kontoplanen.`, status: 409 }
}
return { error: error.message, status: 500 }
}
return { data: { account_number: data.account_number, account_name: data.account_name } }
}
async function commitUpdateAccount(
supabase: SupabaseClient,
_userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
let validated
try {
validated = UpdateAccountParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
const { account_number, ...rest } = validated
const updateData: Record<string, unknown> = {}
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined) updateData[key] = value
}
if (Object.keys(updateData).length === 0) {
return { error: 'Inget att uppdatera', status: 400 }
}
const { data, error } = await supabase
.from('chart_of_accounts')
.update(updateData)
.eq('company_id', companyId)
.eq('account_number', account_number)
.select('account_number, account_name, is_active')
.single()
if (error) {
if (error.code === 'PGRST116') return { error: 'Kontot hittades inte', status: 404 }
return { error: error.message, status: 500 }
}
return { data: { account_number: data.account_number, account_name: data.account_name, is_active: data.is_active } }
}
async function commitSetVoucherNote(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
let validated
try {
validated = SetVoucherNoteParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
// Notes-only UPDATE: the journal_entries immutability trigger (migration
// 20260608120000) allows exactly this on committed entries and raises on
// anything else, so no status pre-check is needed here. Period-lock and
// company-lock-date triggers still apply and surface as errors.
const { data, error } = await supabase
.from('journal_entries')
.update({ notes: validated.notes })
.eq('id', validated.journal_entry_id)
.eq('company_id', companyId)
.select('id, voucher_series, voucher_number')
.maybeSingle()
if (error) return { error: error.message, status: 400 }
// Zero rows = the entry doesn't exist in this company: report it instead
// of a phantom success (same contract as the dashboard notes route).
if (!data) return { error: 'Verifikationen hittades inte.', status: 404 }
return {
data: {
journal_entry_id: data.id,
voucher_series: data.voucher_series,
voucher_number: data.voucher_number,
notes: validated.notes,
},
}
}
async function commitCreateSupplier(
supabase: SupabaseClient,
userId: string,
@@ -4059,6 +4202,15 @@ async function commitPendingOperationInner(
case 'create_supplier':
result = await commitCreateSupplier(supabase, userId, companyId, pendingOp.params)
break
case 'create_account':
result = await commitCreateAccount(supabase, userId, companyId, pendingOp.params)
break
case 'update_account':
result = await commitUpdateAccount(supabase, userId, companyId, pendingOp.params)
break
case 'set_voucher_note':
result = await commitSetVoucherNote(supabase, companyId, pendingOp.params)
break
case 'create_dimension_value':
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
break