feat(mcp): accept article_id on gnubok_create_invoice lines (#1638)

* feat(mcp): accept article_id on gnubok_create_invoice lines

Invoice lines staged via MCP can now reference a catalog article
(artikelregister). Staging prefills description, unit, unit_price,
vat_rate and revenue account from the article with explicit-wins
semantics, mirroring the web line picker. Unknown, foreign-company and
deactivated articles are refused at staging, as is a price prefill from
an article priced in another currency. The approval executor gains a
company-scope gate for staged article_id values: the FK on
invoice_items.article_id proves existence, not tenancy.

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

* fix(mcp): adopt article VAT rate only from the customer's default set

Web line picker parity (InvoiceEditor's applyArticle guard): an article's
stored vat_rate is its domestic rate. The prefill previously staged it
unconditionally, and the staging/commit gates check the wider permitted
set (which includes 25/12/6 for taxed-where-performed supplies), so
{article_id, quantity} to a validated EU business staged 25% Swedish VAT
onto a reverse-charge invoice. The customer is now fetched before the
prefill and the article rate is adopted only when it is in the
customer's default rate set; foreign-business lines fall back to the 0%
reverse-charge/export default unless the agent sets vat_rate explicitly.
Prefill logic extracted into resolveInvoiceLineFromArticle (CodeRabbit).

Found by the skeptic review pass (two independent refutations) and the
Swedish accounting compliance bot, all converging on the same defect.

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

* fix(invoices): name the article VAT adoption set in vat-rules, not the MCP server

The vat-rate-gate-parity guard pins that no invoice write path mentions
getAvailableVatRates: gating on the picker default is the bug it exists
to prevent. The article-rate adoption in gnubok_create_invoice needs the
default set for a different purpose (prefill, not gating), so the
semantics move into lib/invoices/vat-rules.ts as
getArticleVatRateAdoptionSet(), with tests pinning that adoption is
empty for single-rate foreign customers and always a subset of the
permitted set. server.ts keeps gating on getPermittedVatRates.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-17 11:42:51 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f8db38f989
commit caa0c3b41d
9 changed files with 591 additions and 40 deletions
@@ -0,0 +1,291 @@
/**
* gnubok_create_invoice article references (artikelregister).
*
* A line may set article_id (from gnubok_list_articles): staging prefills
* description, unit, unit_price and revenue_account from the article, with the
* same explicit-wins semantics as the web line picker (InvoiceEditor's
* applyArticle). The article's stored vat_rate is its DOMESTIC rate and is
* adopted only when it is in the customer's DEFAULT rate set: a foreign
* business locked to 0% reverse charge / export must NOT inherit 25% from the
* article, because the permitted-set gate (widened for taxed-where-performed
* supplies) would let it through and book Swedish VAT onto a reverse-charge
* invoice. Unknown, foreign-company and deactivated articles are refused at
* staging; a price prefill from an article in another currency is refused
* rather than silently misread as the invoice currency.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
const createInvoice = tools.find((t) => t.name === 'gnubok_create_invoice')!
const CUSTOMER = {
id: 'cust-1',
name: 'Synthetic Kund AB',
customer_type: 'swedish_business',
vat_number_validated: false,
default_payment_terms: 30,
}
/** Synthetic VAT-validated EU business: reverse charge, single locked 0%. */
const EU_CUSTOMER = {
id: 'cust-eu',
name: 'Muster GmbH',
customer_type: 'eu_business',
vat_number_validated: true,
default_payment_terms: 30,
}
/** Synthetic non-EU business: export, single locked 0%. */
const EXPORT_CUSTOMER = {
id: 'cust-export',
name: 'Overseas Inc',
customer_type: 'non_eu_business',
vat_number_validated: false,
default_payment_terms: 30,
}
const ARTICLE = {
id: 'art-1',
name: 'Konsulttimme',
unit: 'tim',
price_excl_vat: 1200,
vat_rate: 25,
revenue_account: '3041',
currency: 'SEK',
active: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
/** Queue order: customers → articles → period layers ×2 → pending_operations insert. */
function enqueueHappyPath(
enqueue: (r: { data: unknown; error: unknown }) => void,
customer: Record<string, unknown>,
articleRows: Array<Record<string, unknown>>,
) {
enqueue({ data: customer, error: null })
enqueue({ data: articleRows, error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { id: 'op-1' }, error: null })
}
describe('gnubok_create_invoice: article_id on items', () => {
it('prefills description, unit, price, VAT and revenue account from the article', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueHappyPath(enqueue, CUSTOMER, [ARTICLE])
const result = (await createInvoice.execute(
{
customer_id: 'cust-1',
invoice_date: '2026-05-12',
items: [{ article_id: 'art-1', quantity: 3 }],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { items: Array<Record<string, unknown>>; total?: number } }
expect(result.staged).toBe(true)
expect(result.preview.items[0]).toMatchObject({
article_id: 'art-1',
description: 'Konsulttimme',
unit: 'tim',
unit_price: 1200,
vat_rate: 25,
revenue_account: '3041',
line_total: 3600,
})
expect(result.preview.total).toBe(4500)
})
it('lets explicit line values win over the article', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueHappyPath(enqueue, CUSTOMER, [ARTICLE])
const result = (await createInvoice.execute(
{
customer_id: 'cust-1',
invoice_date: '2026-05-12',
items: [{ article_id: 'art-1', quantity: 1, description: 'Rabatterad timme', unit_price: 800 }],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { items: Array<Record<string, unknown>>; total?: number } }
expect(result.staged).toBe(true)
expect(result.preview.items[0]).toMatchObject({
description: 'Rabatterad timme',
unit_price: 800,
unit: 'tim',
vat_rate: 25,
})
expect(result.preview.total).toBe(1000)
})
it('does NOT adopt the article domestic rate for a reverse-charge EU customer', async () => {
// The skeptic counterexample: {article_id, quantity} to a VIES-validated
// German GmbH. The article's 25% is in the PERMITTED set (taxed-where-
// performed widening) so a naive prefill would sail through the gate and
// book Swedish VAT onto a reverse-charge invoice. The default set for this
// customer is a single locked 0%, so the article rate must not be adopted.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueHappyPath(enqueue, EU_CUSTOMER, [ARTICLE])
const result = (await createInvoice.execute(
{
customer_id: 'cust-eu',
invoice_date: '2026-05-12',
items: [{ article_id: 'art-1', quantity: 10 }],
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: { items: Array<Record<string, unknown>>; total?: number; vat_amount?: number; vat_treatment?: string }
}
expect(result.staged).toBe(true)
expect(result.preview.items[0]).toMatchObject({ vat_rate: 0, unit_price: 1200 })
expect(result.preview.vat_amount).toBe(0)
expect(result.preview.total).toBe(12000)
expect(result.preview.vat_treatment).toBe('reverse_charge')
})
it('does NOT adopt the article domestic rate for an export customer', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueHappyPath(enqueue, EXPORT_CUSTOMER, [ARTICLE])
const result = (await createInvoice.execute(
{
customer_id: 'cust-export',
invoice_date: '2026-05-12',
items: [{ article_id: 'art-1', quantity: 2 }],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { vat_amount?: number; total?: number; vat_treatment?: string } }
expect(result.staged).toBe(true)
expect(result.preview.vat_amount).toBe(0)
expect(result.preview.total).toBe(2400)
expect(result.preview.vat_treatment).toBe('export')
})
it('still honors an explicit line vat_rate for a foreign customer (taxed where performed)', async () => {
// A Stockholm hotel night sold to a German company legitimately carries
// 12%: the explicit-line escape hatch must survive the adoption guard.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueHappyPath(enqueue, EU_CUSTOMER, [{ ...ARTICLE, name: 'Hotellnatt', vat_rate: 12, price_excl_vat: 1000 }])
const result = (await createInvoice.execute(
{
customer_id: 'cust-eu',
invoice_date: '2026-05-12',
items: [{ article_id: 'art-1', quantity: 2, vat_rate: 12 }],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { total?: number } }
expect(result.staged).toBe(true)
expect(result.preview.total).toBe(2240)
})
it('refuses an article_id that does not exist in this company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: CUSTOMER, error: null })
enqueue({ data: [], error: null }) // articles fetch: no company-scoped hit
await expect(
createInvoice.execute(
{
customer_id: 'cust-1',
items: [{ article_id: 'art-other-company', quantity: 1 }],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/not found in this company/)
})
it('refuses a deactivated article', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: CUSTOMER, error: null })
enqueue({ data: [{ ...ARTICLE, active: false }], error: null })
await expect(
createInvoice.execute(
{
customer_id: 'cust-1',
items: [{ article_id: 'art-1', quantity: 1 }],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/deactivated/)
})
it('refuses a price prefill when the article is priced in another currency', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: CUSTOMER, error: null })
enqueue({ data: [{ ...ARTICLE, currency: 'EUR' }], error: null })
await expect(
createInvoice.execute(
{
customer_id: 'cust-1',
items: [{ article_id: 'art-1', quantity: 1 }],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/priced in EUR/)
})
it('accepts a foreign-currency article when the line sets unit_price explicitly', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueHappyPath(enqueue, CUSTOMER, [{ ...ARTICLE, currency: 'EUR' }])
const result = (await createInvoice.execute(
{
customer_id: 'cust-1',
invoice_date: '2026-05-12',
items: [{ article_id: 'art-1', quantity: 1, unit_price: 950 }],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { total?: number } }
expect(result.staged).toBe(true)
expect(result.preview.total).toBe(1187.5)
})
it('still requires description, unit and unit_price on a line without article_id', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: CUSTOMER, error: null })
await expect(
createInvoice.execute(
{
customer_id: 'cust-1',
items: [{ quantity: 1 }],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/description is required/)
})
})
@@ -627,16 +627,16 @@ describe('gnubok_create_invoice: dimensions bag', () => {
it('stages resolved default_dimensions top-level and per-item bags (default NOT merged into items)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
// customers fetch (now FIRST: article-rate adoption needs the customer)
enqueue({
data: { id: 'cust-1', name: 'Acme AB', customer_type: 'swedish_business', vat_number_validated: false, default_payment_terms: 30 },
error: null,
})
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: REGISTRY_ROWS, error: null })
enqueue({ data: VALUE_ROWS, error: null })
// customers fetch
enqueue({
data: { id: 'cust-1', name: 'Acme AB', customer_type: 'swedish_business', vat_number_validated: false, default_payment_terms: 30 },
error: null,
})
// resolvePeriodStatusForDate (auto-extracted from invoice_date): 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
+139 -34
View File
@@ -29,7 +29,7 @@ import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCount
import { formatVoucherLabel, hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import { eventBus } from '@/lib/events/bus'
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
import { getVatRules, getPermittedVatRates, getArticleVatRateAdoptionSet } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { getBranding } from '@/lib/branding/service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
@@ -219,6 +219,88 @@ import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoic
// ── Actor context ────────────────────────────────────────────
type StagedInvoiceLineInput = {
description?: string
quantity: number
unit?: string
unit_price?: number
vat_rate?: number
article_id?: string
revenue_account?: string | null
dimensions?: unknown
}
type ResolvedInvoiceLine = StagedInvoiceLineInput & {
description: string
unit: string
unit_price: number
}
type InvoiceLineArticle = {
id: string
name: string
unit: string | null
price_excl_vat: number | null
vat_rate: number | null
revenue_account: string | null
currency: string | null
active: boolean
}
/**
* Article prefill for one staged invoice line (web line picker parity,
* InvoiceEditor's applyArticle): the line's own values win and the article
* fills whatever the agent left out. The article's VAT rate is adopted ONLY
* when it is in the customer's DEFAULT rate set (adoptableVatRates, empty for
* a customer locked to a single rate): an article's stored rate is its
* domestic rate, and adopting it against the wider permitted set would
* silently put Swedish VAT on a reverse-charge or export invoice.
*/
function resolveInvoiceLineFromArticle(
item: StagedInvoiceLineInput,
article: InvoiceLineArticle | undefined,
currency: string,
adoptableVatRates: ReadonlySet<number>,
index: number,
): ResolvedInvoiceLine {
const lineNo = index + 1
if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${lineNo}: quantity must be positive`)
if (item.article_id && !article) {
throw new Error(`Item ${lineNo}: article ${item.article_id} not found in this company. Use gnubok_list_articles to find valid IDs.`)
}
if (article && !article.active) {
throw new Error(`Item ${lineNo}: article "${article.name}" is deactivated. Reactivate it with gnubok_update_article (active: true) or drop article_id.`)
}
const description = item.description?.trim() || article?.name
if (!description) throw new Error(`Item ${lineNo}: description is required (or set article_id)`)
const unit = item.unit?.trim() || (article ? article.unit || 'st' : undefined)
if (!unit) throw new Error(`Item ${lineNo}: unit is required (st, tim, dag)`)
let unitPrice = item.unit_price
if (unitPrice == null && article) {
if (article.price_excl_vat == null) {
throw new Error(`Item ${lineNo}: article "${article.name}" has no price; pass unit_price explicitly.`)
}
if (article.currency && article.currency !== currency) {
throw new Error(
`Item ${lineNo}: article "${article.name}" is priced in ${article.currency} but the invoice is in ${currency}. ` +
`Set the invoice currency to ${article.currency} or pass unit_price explicitly.`
)
}
unitPrice = article.price_excl_vat
}
if (unitPrice == null) throw new Error(`Item ${lineNo}: unit_price is required (or set article_id)`)
return {
...item,
description,
unit,
unit_price: unitPrice,
...(item.vat_rate == null && article?.vat_rate != null && adoptableVatRates.has(article.vat_rate)
? { vat_rate: article.vat_rate }
: {}),
...(item.revenue_account == null && article?.revenue_account ? { revenue_account: article.revenue_account } : {}),
}
}
interface ActorContext {
type: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
id?: string
@@ -4946,13 +5028,17 @@ export const tools: McpTool[] = [
unit: { type: 'string', description: 'st, tim, dag, mån' },
unit_price: { type: 'number', description: 'Price per unit excl. VAT' },
vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' },
article_id: {
type: 'string',
description: 'Optional article UUID from gnubok_list_articles. Prefills description, unit, unit_price, revenue account and, only when compatible with the customer VAT rules, vat_rate. Values set on the line win.',
},
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.',
},
},
required: ['description', 'quantity', 'unit', 'unit_price'],
required: ['quantity'],
},
description: 'Invoice line items',
},
@@ -4982,25 +5068,62 @@ export const tools: McpTool[] = [
},
async execute(args, companyId, userId, supabase, actor) {
const customerId = args.customer_id as string
const items = args.items as Array<{
description: string
quantity: number
unit: string
unit_price: number
vat_rate?: number
dimensions?: unknown
}>
const rawItems = args.items as StagedInvoiceLineInput[]
if (!customerId) throw new Error('customer_id is required. Use gnubok_list_customers to find IDs.')
if (!items?.length) throw new Error('At least one item is required.')
if (!rawItems?.length) throw new Error('At least one item is required.')
for (const [i, item] of items.entries()) {
if (!item.description?.trim()) throw new Error(`Item ${i + 1}: description is required`)
if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${i + 1}: quantity must be positive`)
if (!item.unit?.trim()) throw new Error(`Item ${i + 1}: unit is required (st, tim, dag)`)
if (item.unit_price == null) throw new Error(`Item ${i + 1}: unit_price is required`)
const today = new Date().toISOString().split('T')[0]
const currency = ((args.currency as string) || 'SEK') as Currency
const invoiceDate = (args.invoice_date as string) || today
// Fetch customer (full row for VAT rules) BEFORE the article prefill:
// an article's stored rate may only be adopted against this customer's
// default rate set (see resolveInvoiceLineFromArticle).
const { data: customer, error: custError } = await supabase
.from('customers')
.select('*')
.eq('id', customerId)
.eq('company_id', companyId)
.single()
if (custError || !customer) {
throw new Error('Customer not found. Use gnubok_list_customers to find valid IDs.')
}
// VAT rules from customer type (same logic as web UI)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
// The DEFAULT set governs article-rate adoption (web parity: the picker
// only adopts a rate the customer could have picked themselves); a
// customer locked to a single rate (foreign business 0%) adopts nothing.
// Gating below stays on the PERMITTED set: adoption and validation are
// deliberately different sets.
const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated)
// Article prefill (web line picker parity): the line's own values win,
// the referenced article fills whatever the agent left out.
const articleIds = Array.from(new Set(rawItems.map((i) => i.article_id).filter((a): a is string => !!a)))
const articlesById = new Map<string, InvoiceLineArticle>()
if (articleIds.length > 0) {
const { data: articleRows, error: articleError } = await supabase
.from('articles')
.select('id, name, unit, price_excl_vat, vat_rate, revenue_account, currency, active')
.eq('company_id', companyId)
.in('id', articleIds)
if (articleError) throw new Error(`Failed to load articles: ${articleError.message}`)
for (const row of articleRows ?? []) articlesById.set(row.id, row)
}
const items = rawItems.map((item, i) =>
resolveInvoiceLineFromArticle(
item,
item.article_id ? articlesById.get(item.article_id) : undefined,
currency,
adoptableVatRates,
i,
),
)
// Resolve-don't-select: parse the invoice-level default bag + each item's
// own bag, then resolve codes AND natural-language names against the
// registry in ONE pass (zero queries when nothing is tagged; free-text
@@ -5035,24 +5158,6 @@ export const tools: McpTool[] = [
}
}
const today = new Date().toISOString().split('T')[0]
const currency = ((args.currency as string) || 'SEK') as Currency
const invoiceDate = (args.invoice_date as string) || today
// Fetch customer (full row for VAT rules)
const { data: customer, error: custError } = await supabase
.from('customers')
.select('*')
.eq('id', customerId)
.eq('company_id', companyId)
.single()
if (custError || !customer) {
throw new Error('Customer not found. Use gnubok_list_customers to find valid IDs.')
}
// VAT rules from customer type (same logic as web UI)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
// Gate on the PERMITTED set, not the picker default, exactly like
// buildInvoiceWriteData and commitCreateInvoice: huvudregeln (ML 6 kap.
// 34 §) taxes a B2B service where the buyer is established, so 0% is the
@@ -71,7 +71,9 @@ Use \`getAvailableVatRates(customerType, vatNumberValidated)\` semantics: Accoun
### Step 3: Create the invoice
\`gnubok_create_invoice({ customer_id, items: [{ description, quantity, unit, unit_price, vat_rate? }], invoice_date?, due_date?, currency? })\`
\`gnubok_create_invoice({ customer_id, items: [{ description?, quantity, unit?, unit_price?, vat_rate?, article_id? }], invoice_date?, due_date?, currency? })\`
Set \`article_id\` (from \`gnubok_list_articles\`) to invoice a catalog article: it prefills description, unit, unit_price and revenue account from the article, and any value set on the line wins. Without \`article_id\`, description, unit and unit_price are required. The article's stored \`vat_rate\` is a DOMESTIC rate: it is adopted only when it is in the customer's default VAT set, so a reverse-charge or export customer keeps 0% unless the line sets \`vat_rate\` explicitly (taxed-where-performed supplies like hotel nights).
Returns staged operation. User approves in web app → invoice number is allocated atomically (gap-free) and journal entry posted (under accrual / faktureringsmetoden).