* 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>
292 lines
9.6 KiB
TypeScript
292 lines
9.6 KiB
TypeScript
/**
|
||
* 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/)
|
||
})
|
||
})
|