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:
co-authored by
Claude Fable 5
parent
f8db38f989
commit
caa0c3b41d
@@ -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 })
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user