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
+1
View File
@@ -1019,6 +1019,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-15] Confirmed intentional (Swedish-review note): with override=true and an unresolvable filename, the attach endpoint links a document to any same-company, same-declared-year, posted verifikat, migrated or not. This mirrors /api/documents/[id]/link, which imposes no filename check at all, so it introduces no new capability class; tenant, year and period-lock enforcement always apply.
[2026-08-15] BankID tabs bind to a random non-secret `flowId` signed into the shared flow cookie and sent as a request header after start or explicit resume: mode pinning alone cannot distinguish two same-mode tabs, so an older tab could otherwise silently follow, cancel, or complete a newer person's identification after `/start` replaced the origin-wide cookie. This supersedes the 2026-08-15 decision that deliberately skipped mode matching on active polls.
[2026-08-15] Did not apply BankID migration `20260815120000` to Supabase staging during PR #1625 follow-through: read-only reconciliation found 14 staging-only and 99 branch-only migration versions, so applying on top of that divergent ledger would violate the no-orphan rule. Production is reconciled with zero remote-only versions and exactly this PR migration local-only; hosted pg-real validates the migration until staging is reconciled.
[2026-08-17] MCP article refs on create_invoice only, not update_invoice: the update tool's full-replace item semantics need their own design pass; also refuse cross-currency price prefill instead of converting, the agent must pick the currency explicitly.
[2026-08-17] Session replay masking inverted to deny-by-default (founder-directed after user pushback on session recording): ALL input values are masked (maskAllInputs with no maskInputFn, so rrweb masks wholesale; placeholders are attributes and stay visible) and ALL text is masked unless it sits under data-ph-unmask chrome or a th (dry-table column headers are raw th per page, so the mask function treats th as chrome rather than tagging hundreds of sites). Chrome tags live on the shared primitives (PageHeader, Label, Button except role=combobox triggers which render selected values, TabsTrigger, Badge, Card/Dialog/Sheet titles and descriptions, tooltips, help popovers, empty states, settings labels); tagged chrome is still pattern-scrubbed for amounts and identity numbers, and data-ph-mask beats data-ph-unmask so call sites that interpolate user data into chrome stay masked. Toasts (title AND description) deliberately NOT tagged: they interpolate user data at too many call sites to audit, and an audit found live leaks (deadline titles, bank account names) in titles alone. Confirm-dialog wrappers (ConfirmDialog, ConfirmationDialog, DestructiveConfirmDialog) force data-ph-mask on their titles/descriptions centrally: convention 10 makes confirm copy describe the object being acted on, so it is user data by design; that one change closed 14+ audited leak sites. A very-thorough audit of user data flowing into unmasked primitives ran in the same change and every found site got a call-site data-ph-mask. Failure mode for untagged new UI is over-masking, never leakage. Supersedes the 2026-08-06 pattern-based default; privacy policy and RoPA updated in the same change.
[2026-08-17] Betalfil missing-bankgiro UX: advisory warning in PaymentFilePanel (download stays enabled, route stays the authority) + click-to-prefill from tic_snapshot instead of auto-seeding company_settings.bankgiro: sender payment data must be user-confirmed, and the snapshot is unvalidated registry JSON.
[2026-08-16] /transactions FyPicker double-fetch fixed by gating the initial fetch on FyPicker's existing onReady (fires after its restore onChange) instead of the analysis doc's literal "read the persisted period synchronously in initial state": localStorage only holds the period ID, not the FiscalPeriod bounds, so a synchronous read would suppress FyPicker's restore (value !== null) and leave the fetch permanently unscoped while the chip claimed a year. Same outcome (one scoped fetch per mount, background refetch on period change) without a stale-bounds cache or new FyPicker API.
@@ -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).
+41
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
getAvailableVatRates,
getArticleVatRateAdoptionSet,
getPermittedVatRates,
getVatTreatmentForRate,
getVatRules,
@@ -355,3 +356,43 @@ describe('getMomsRutaDescription', () => {
expect(getMomsRutaDescription('99')).toBe('99')
})
})
// ============================================================
// getArticleVatRateAdoptionSet
// ============================================================
describe('getArticleVatRateAdoptionSet', () => {
it('adopts nothing for a VAT-validated EU business (single locked 0% reverse charge)', () => {
expect(getArticleVatRateAdoptionSet('eu_business', true).size).toBe(0)
})
it('adopts nothing for a non-EU business (single locked 0% export)', () => {
expect(getArticleVatRateAdoptionSet('non_eu_business', false).size).toBe(0)
})
it('adopts the full domestic set for a Swedish business', () => {
const set = getArticleVatRateAdoptionSet('swedish_business', false)
expect([...set].sort((a, b) => a - b)).toEqual([0, 6, 12, 25])
})
it('adopts the full domestic set for an EU business WITHOUT validated VAT', () => {
const set = getArticleVatRateAdoptionSet('eu_business', false)
expect(set.has(25)).toBe(true)
})
it('is always a subset of the permitted set (adoption can never stage an unlawful rate)', () => {
const combos: Array<['individual' | 'swedish_business' | 'eu_business' | 'non_eu_business', boolean]> = [
['individual', false],
['swedish_business', false],
['eu_business', false],
['eu_business', true],
['non_eu_business', false],
]
for (const [type, validated] of combos) {
const permitted = new Set(getPermittedVatRates(type, validated).map((r) => r.rate))
for (const rate of getArticleVatRateAdoptionSet(type, validated)) {
expect(permitted.has(rate)).toBe(true)
}
}
})
})
+18
View File
@@ -48,6 +48,24 @@ export function getAvailableVatRates(
]
}
/**
* The set of VAT rates an article's stored rate may be ADOPTED from when the
* article prefills an invoice line (web line picker parity: the picker only
* adopts a rate the customer could have picked themselves). Empty when the
* customer is locked to a single rate (foreign business 0% reverse charge /
* export): an article's stored rate is its DOMESTIC rate, and adopting it
* there would silently put Swedish VAT on a reverse-charge or export invoice
* even though the wider permitted set would accept it. This governs PREFILL
* only; every validation gate keeps using getPermittedVatRates().
*/
export function getArticleVatRateAdoptionSet(
customerType: CustomerType,
vatNumberValidated: boolean = false,
): ReadonlySet<number> {
const offered = getAvailableVatRates(customerType, vatNumberValidated)
return new Set(offered.length > 1 ? offered.map((r) => r.rate) : [])
}
/**
* Get the VAT rates that may LEGALLY appear on an invoice line for this
* customer type. This is the set validation must gate on.
@@ -265,6 +265,79 @@ describe('commitPendingOperation: create_invoice: VAT rates for a foreign busine
})
})
describe('commitPendingOperation: create_invoice: staged article references', () => {
it('writes a company-scoped article_id through to the invoice_items row', async () => {
// Queue: CAS claim → customers → company_settings → articles scope check →
// invoices insert → invoice_items insert → complete select → update.
const { supabase, inserts } = createCapturingSupabase([
{ data: { id: 'op-1' } },
{ data: customer },
{ data: { vat_registered: true } },
{ data: [{ id: 'art-1' }] },
{ data: { id: 'inv-1', invoice_number: null } },
{ data: null },
{ data: { id: 'inv-1' } },
{ data: null },
])
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [
{
description: 'Konsulttimme',
quantity: 2,
unit: 'tim',
unit_price: 1200,
vat_rate: 25,
article_id: 'art-1',
},
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows[0]).toMatchObject({ article_id: 'art-1', line_total: 2400 })
})
it('fails when a staged article_id belongs to another company (drift/tamper gate)', async () => {
// The FK on invoice_items.article_id only proves existence, not tenancy:
// the executor must refuse an id the scoped select cannot see.
const { supabase, inserts } = createCapturingSupabase([
{ data: { id: 'op-1' } },
{ data: customer },
{ data: { vat_registered: true } },
{ data: [] },
{ data: null },
])
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [
{
description: 'Konsulttimme',
quantity: 1,
unit: 'tim',
unit_price: 1200,
vat_rate: 25,
article_id: 'art-foreign',
},
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.error).toMatch(/Artikel art-foreign finns inte i företaget/)
expect(inserts['invoices']).toBeUndefined()
})
})
describe('commitPendingOperation: create_invoice: dimensions propagation (PR7)', () => {
it('staged default_dimensions lands on the invoices row and item bags on invoice_items rows', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
+20
View File
@@ -1554,6 +1554,26 @@ async function commitCreateInvoice(
}
}
// Drift/tamper gate for staged article references: the FK on
// invoice_items.article_id only proves the article exists, not that it
// belongs to THIS company, so scope-check here like revenue_account above.
const stagedArticleIds = Array.from(
new Set(items.map((i) => i.article_id).filter((a): a is string => !!a)),
)
if (stagedArticleIds.length > 0) {
const { data: articleRows, error: articleError } = await supabase
.from('articles')
.select('id')
.eq('company_id', companyId)
.in('id', stagedArticleIds)
if (articleError) return { error: articleError.message, status: 500 }
const foundArticleIds = new Set((articleRows ?? []).map((a: { id: string }) => a.id))
const missingArticleId = stagedArticleIds.find((a) => !foundArticleIds.has(a))
if (missingArticleId) {
return { error: `Artikel ${missingArticleId} finns inte i företaget`, status: 400 }
}
}
const total = subtotal + vatAmount
const currency = ((params.currency as string) || 'SEK') as Currency
const invoiceDate = (params.invoice_date as string) || new Date().toISOString().split('T')[0]