Fix/supp ag fb (#1023)

* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
This commit is contained in:
Mattsson
2026-07-15 15:53:15 +02:00
committed by GitHub
parent a558c75678
commit 072aedeaf9
116 changed files with 5708 additions and 669 deletions
+185 -35
View File
@@ -58,6 +58,15 @@ import {
IdempotencyKeyReuseError,
} from '@/lib/api/idempotency'
import { toToolError, type NextActionHint } from './tool-result'
import {
addCompanyToNextHint,
addCompanyToTopLevelNext,
assertMcpCompanyWriteAccess,
extractRequestedCompany,
isCompanyDependentTool,
projectToolInputSchema,
resolveMcpCompanyContext,
} from './company-routing'
import { findSupplierCandidates } from './supplier-candidates'
import { assertNoPlaintextPersonnummer } from './staging-pii-guard'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
@@ -112,6 +121,7 @@ import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { commitPendingOperation } from '@/lib/pending-operations/commit'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { getUserCompanies } from '@/lib/company/context'
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
// which dispatches to this handler: no duplicate call needed here.
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType } from '@/types'
@@ -333,7 +343,7 @@ async function stagePendingOperation(
message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`,
preview: previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
...(next ? { next } : {}),
...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
}
}
@@ -358,7 +368,12 @@ async function stagePendingOperation(
? `Replayed cached response for idempotency_key "${options.idempotencyKey}": already staged as pending_operation ${cachedOpId}. No new side-effects. ${buildApprovalGuidance(cachedOpId, riskLevel)}`
: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`,
...(cachedOpId
? { approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: cachedOpId } } }
? {
approve: {
tool: 'gnubok_approve_pending_operation',
args: { operation_id: cachedOpId, company_id: companyId },
},
}
: {}),
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
@@ -399,11 +414,11 @@ async function stagePendingOperation(
message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.`,
approve: {
tool: 'gnubok_approve_pending_operation',
args: { operation_id: data.id } as Record<string, unknown>,
args: { operation_id: data.id, company_id: companyId } as Record<string, unknown>,
},
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
...(next ? { next } : {}),
...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
} as const
if (options.idempotencyKey && requestHash) {
@@ -1760,7 +1775,7 @@ export const tools: McpTool[] = [
name: t.name,
description: t.description,
scope: requiredScope,
inputSchema: t.inputSchema,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
@@ -1779,6 +1794,109 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_list_companies',
title: 'List Companies',
description: 'List every non-archived company this API-key user can access. Use company_id from this result on other tools; omit it there to use the API key default.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
companies: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
company_id: { type: 'string' },
name: { type: 'string' },
org_number: { type: ['string', 'null'] },
entity_type: { type: ['string', 'null'] },
role: { type: 'string', enum: ['owner', 'admin', 'member', 'viewer'] },
is_default: { type: 'boolean' },
},
required: ['company_id', 'name', 'org_number', 'entity_type', 'role', 'is_default'],
},
},
count: { type: 'number' },
default_company_id: { type: ['string', 'null'] },
},
required: ['companies', 'count', 'default_company_id'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, defaultCompanyId, userId, supabase) {
type CompanyRow = {
id: string
name: string
org_number: string | null
entity_type: string | null
archived_at: string | null
}
type MembershipRow = {
company_id: string
role: 'owner' | 'admin' | 'member' | 'viewer'
companies: CompanyRow | CompanyRow[] | null
}
const memberships = (await getUserCompanies(supabase, userId)) as unknown as MembershipRow[]
const accessible = memberships.flatMap((membership) => {
const company = Array.isArray(membership.companies)
? membership.companies[0]
: membership.companies
return company && company.archived_at === null ? [{ membership, company }] : []
})
const companyIds = accessible.map(({ company }) => company.id)
const displayNames = new Map<string, string>()
if (companyIds.length > 0) {
try {
const settings = await fetchAllRows<{ company_id: string; company_name: string | null }>(
({ from, to }) =>
supabase
.from('company_settings')
.select('company_id, company_name')
.in('company_id', companyIds)
.order('company_id', { ascending: true })
.range(from, to),
)
for (const row of settings) {
if (row.company_name) displayNames.set(row.company_id, row.company_name)
}
} catch (error) {
log.warn('gnubok_list_companies display-name lookup failed', {
error: error instanceof Error ? error.message : 'unknown',
})
}
}
const companies = accessible.map(({ membership, company }) => ({
company_id: company.id,
name: displayNames.get(company.id) ?? company.name,
org_number: company.org_number,
entity_type: company.entity_type,
role: membership.role,
is_default: company.id === defaultCompanyId,
}))
const hasAccessibleDefault = companies.some((company) => company.is_default)
return {
companies,
count: companies.length,
default_company_id: hasAccessibleDefault ? defaultCompanyId : null,
}
},
},
{
name: 'gnubok_list_skills',
title: 'List Domain Skills',
@@ -2245,10 +2363,10 @@ export const tools: McpTool[] = [
type: 'object',
additionalProperties: false,
description:
'The single company every tool call in this session reads and writes. Confirm this is the entity the user means BEFORE any staged write: there is no per-call company switch; scope is fixed by the API key.',
'The company selected for this call. Confirm it is the entity the user means before staging a write. Pass company_id on later calls to keep working in a non-default company.',
properties: {
id: { type: 'string', description: 'Deprecated: read company_id instead.' },
company_id: { type: 'string', description: 'company_id this session is scoped to.' },
company_id: { type: 'string', description: 'company_id selected for this call.' },
name: { type: ['string', 'null'] },
org_number: { type: ['string', 'null'] },
entity_type: { type: ['string', 'null'], description: 'e.g. "aktiebolag", "enskild_firma". Null if unset.' },
@@ -2493,10 +2611,10 @@ export const tools: McpTool[] = [
.select('full_name')
.eq('id', userId)
.maybeSingle(),
// Company identity so the agent can confirm WHICH entity it operates on
// before any write. Scope is fixed by the API key: there is no per-call
// switch: so this is the session's "whoami for the company". Best-effort:
// a failed read still yields a company block with at least the id.
// Company identity so the agent can confirm which entity it operates on
// before any write. The dispatcher has already resolved and authorized
// the optional per-call company_id. A failed read still yields a company
// block with at least the id.
supabase
.from('companies')
.select('name, org_number, entity_type')
@@ -12006,20 +12124,6 @@ export const tools: McpTool[] = [
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const assetIds = Array.isArray(args.asset_ids) ? (args.asset_ids as string[]) : undefined
// Mirror the HTTP route's `requireWrite: true` guard so a viewer-role
// member can't post depreciation through the MCP surface. RLS would
// reject the underlying INSERTs anyway, but failing fast here
// produces a much cleaner error than the cascaded RLS rejection.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!membership || membership.role === 'viewer') {
throw new Error('Write permission required')
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
@@ -12629,7 +12733,7 @@ function emitToolCallTelemetry(payload: {
success: boolean
isError: boolean
errorCode: string | null
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
errorMessage: string | null
requestId: string | number | null
userId: string
@@ -12957,6 +13061,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'',
'Discovery:',
'• tools/list returns the full schema for every tool. To narrow a large catalog, call gnubok_search_tools(query="…"): it ranks tools by relevance; pass detail="name"|"summary"|"full" to control payload size.',
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`,
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
'',
'Common workflows:',
@@ -13014,7 +13120,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description,
inputSchema: t.inputSchema,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
@@ -13026,7 +13132,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string
const toolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
const rawToolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
string,
unknown
>
@@ -13085,12 +13191,55 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
)
}
let toolArgs: Record<string, unknown>
let effectiveCompanyId = companyId
const companyRoutingStartedAt = Date.now()
try {
const extracted = extractRequestedCompany(rawToolArgs)
toolArgs = extracted.toolArgs
if (isCompanyDependentTool(toolName)) {
const companyContext = await resolveMcpCompanyContext({
supabase,
userId,
defaultCompanyId: companyId,
requestedCompanyId: extracted.requestedCompanyId,
})
assertMcpCompanyWriteAccess(companyContext, requiredScope)
effectiveCompanyId = companyContext.companyId
}
} catch (err) {
const structured = toToolError(err, { toolName })
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
actor,
latencyMs: Date.now() - companyRoutingStartedAt,
success: false,
isError: true,
errorCode: structured.error.code,
errorKind: 'company_access_denied',
errorMessage: structured.error.message_sv,
requestId: id ?? null,
userId,
// Keep denied attempts attributed to the key default. An arbitrary,
// unauthorized target must never create tenant telemetry there.
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
isError: true,
})
)
}
// Enforce the capability paywall: the MCP/agent path is a paid chokepoint
// just like the HTTP routes (send_invoice → email_send, the two SKV
// submissions → skatteverket). Fail-closed; self-hosted short-circuits to
// all-on inside hasCapability. Blocks before any pending op is staged.
const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName]
if (requiredCapability && !(await hasCapability(supabase, companyId, requiredCapability))) {
if (requiredCapability && !(await hasCapability(supabase, effectiveCompanyId, requiredCapability))) {
const capError = { error: capabilityBlockedError(requiredCapability) }
emitToolCallTelemetry({
tool: toolName,
@@ -13104,7 +13253,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorMessage: capError.error.message_sv,
requestId: id ?? null,
userId,
companyId,
companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
@@ -13144,7 +13293,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorMessage: blocked.error.message_sv,
requestId: id ?? null,
userId,
companyId,
companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
@@ -13158,7 +13307,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// Detect if THIS call follows the previous call's `next` hint: must
// run before execute() so we don't double-store on this call. Emits
// mcp.next_hint_followed when the agent's behaviour matches the hint.
checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, companyId)
checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, effectiveCompanyId)
const callStartedAt = Date.now()
try {
@@ -13167,7 +13316,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
if (toolName === 'gnubok_search_tools') {
(toolArgs as Record<string, unknown>).__keyScopes = keyScopes
}
const result = await tool.execute(toolArgs, companyId, userId, supabase, actor)
const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor)
const result = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
const latencyMs = Date.now() - callStartedAt
const response: Record<string, unknown> = {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -13208,7 +13358,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorMessage: null,
requestId: id ?? null,
userId,
companyId,
companyId: effectiveCompanyId,
})
return NextResponse.json(jsonRpc(id ?? null, response))
} catch (err) {
@@ -13229,7 +13379,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorMessage: structured.error.message_sv,
requestId: id ?? null,
userId,
companyId,
companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {