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