feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview (#1993)
* feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview gnubok_update_invoice items are a FULL REPLACE, had no article fields, and no MCP tool returned invoice lines, so a quantity fix rebuilt from memory wrote article_id/revenue_account null and reverted vat_rate to the customer default: revenue silently moved from the article account (3041) to the VAT-derived default, invisible in the approval preview. - gnubok_get_invoice (invoices:read, search-only): header plus every line with article_id, revenue_account, vat_rate, dimensions, editable_draft - gnubok_update_invoice lines accept article_id with the same prefill and default-set VAT adoption guard as create; permitted-set VAT gate at staging; preview carries the new lines' effective booking and a snapshot of the lines being replaced - commitUpdateInvoice scope-checks staged article ids like create does - OperationPreview: update_invoice preview (current vs new lines, header diffs, totals); create_invoice lines show VAT rate and posting account - invoicing skill points at the read-before-replace round trip Closes #1642 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(mcp): use roundOre for update-invoice preview totals so the ore ratchet stays at baseline The preview-building code in gnubok_update_invoice introduced five naive Math.round(x * 100) / 100 occurrences, tripping check:guards (naive-ore-round 627 vs baseline 622) and failing Core Build on PR #1993. roundOre from @/lib/money is the sanctioned helper and was already imported in this file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(mcp): make the invoice round trip lossless for text, ROT/RUT and accrual lines Skeptic review of #1993 found three round-trip breaks for web-created drafts edited via MCP (the exact silent-loss class issue #1642 reports): - Text rows: the update pre-gate and resolveInvoiceLineFromArticle rejected quantity <= 0 before looking at line_type, so any draft with a free-text spacer row could not be edited at all, and the natural agent recovery (drop the row and retry the FULL REPLACE) silently deleted invoice content. Text rows are now exempt from the quantity/description/unit/price gates (CreateInvoiceItemSchema parity), normalized to the zeroed stored shape, excluded from the staged totals and the VAT gate (commitCreateInvoice billableItems parity), and line_type is declared on both the create and update item schemas. - ROT/RUT: gnubok_get_invoice omitted housing_designation, apartment_number and brf_org_number, so an items replace on a ROT draft either failed AFTER approval ('Fastighetsbeteckning krävs för ROT-avdrag') or, for a schema-conformant agent, silently stripped the avdrag and the stored personnummer. The three property columns (property identifiers, never the personnummer ciphertext) are now returned per line, the deduction fields are declared on the update item schema, deduction_type rides on the current_items snapshot and the new-lines preview, and a staging-time completeness gate (arbetstyp/timmar via validateDeductionLines, fastighetsbeteckning for ROT, personnummer availability on the invoice or the individual's kundkort) surfaces the failure to the agent instead of the approver. - Declared-schema gap: revenue_account and the accrual fields were accepted on pass-through but undeclared, so a schema-conformant agent dropped a manual posting-account override or a periodisering on pass-back. They are now declared on the update item schema (revenue_account also on create; create deliberately does NOT declare deduction/accrual fields because commitCreateInvoice drops them), and the approval preview shows ROT/RUT-avdrag and the periodisering period per line. tools/list ceiling check after the two new create-schema properties: 63337 of 63400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
273af39994
commit
17caf9d80a
@@ -289,3 +289,44 @@ describe('gnubok_create_invoice: article_id on items', () => {
|
||||
).rejects.toThrow(/description is required/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_create_invoice: free-text rows (issue #1642 follow-up)', () => {
|
||||
it('declares line_type and revenue_account on items so agents know they exist', () => {
|
||||
const items = (createInvoice.inputSchema.properties as Record<string, unknown>).items as {
|
||||
items: { properties: Record<string, unknown> }
|
||||
}
|
||||
expect(items.items.properties.line_type).toBeDefined()
|
||||
expect(items.items.properties.revenue_account).toBeDefined()
|
||||
})
|
||||
|
||||
it('accepts a text spacer row without amounts and keeps it out of the totals', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// No article on any line: customers, period layers x2, pending_operations.
|
||||
enqueue({ data: CUSTOMER, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { id: 'op-text-1' }, error: null })
|
||||
|
||||
const result = (await createInvoice.execute(
|
||||
{
|
||||
customer_id: 'cust-1',
|
||||
invoice_date: '2026-05-12',
|
||||
items: [
|
||||
{ line_type: 'text', description: 'Avser sprint 12', quantity: 0 },
|
||||
{ description: 'Konsultation', quantity: 2, unit: 'tim', unit_price: 1000, vat_rate: 25 },
|
||||
],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; preview: { items: Array<Record<string, unknown>>; subtotal: number; vat_amount: number; total: number } }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
// The text row is normalized to the zeroed stored shape and contributes
|
||||
// nothing to the totals (commitCreateInvoice billableItems parity).
|
||||
expect(result.preview.items[0]).toMatchObject({ line_type: 'text', quantity: 0, unit_price: 0, line_total: 0 })
|
||||
expect(result.preview.subtotal).toBe(2000)
|
||||
expect(result.preview.vat_amount).toBe(500)
|
||||
expect(result.preview.total).toBe(2500)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Tests for gnubok_get_invoice (issue #1642).
|
||||
*
|
||||
* The round-trip read surface for gnubok_update_invoice: its items are a
|
||||
* FULL REPLACE and no other MCP tool returned invoice lines, so an agent
|
||||
* fixing a quantity had to rebuild the lines from memory and silently dropped
|
||||
* article_id / revenue_account / vat_rate. This tool returns every line with
|
||||
* its booking fields in display order, plus editable_draft so the agent knows
|
||||
* whether an edit is possible at all. Privacy contract: the invoices row
|
||||
* carries the encrypted ROT/RUT personnummer; the tool maps an explicit field
|
||||
* list and this suite pins that nothing else leaks.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
|
||||
import { tools } from '../server'
|
||||
|
||||
const getInvoice = tools.find((t) => t.name === 'gnubok_get_invoice')!
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const USER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const INVOICE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const CUSTOMER_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
const ARTICLE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
|
||||
|
||||
function invoiceRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: INVOICE_ID,
|
||||
invoice_number: null,
|
||||
status: 'draft',
|
||||
document_type: 'invoice',
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-08-01',
|
||||
due_date: '2026-08-31',
|
||||
delivery_date: null,
|
||||
currency: 'SEK',
|
||||
subtotal: 3400,
|
||||
vat_amount: 850,
|
||||
total: 4250,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 4250,
|
||||
your_reference: 'Anna',
|
||||
our_reference: null,
|
||||
notes: null,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
journal_entry_id: null,
|
||||
is_self_billed: false,
|
||||
credited_invoice_id: null,
|
||||
customer: { name: 'Synthetic Kund AB' },
|
||||
// Deliberately out of display order: PostgREST does not order embeds.
|
||||
items: [
|
||||
{
|
||||
id: 'item-2',
|
||||
sort_order: 2,
|
||||
line_type: 'product',
|
||||
description: 'Resa',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 1000,
|
||||
line_total: 1000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 250,
|
||||
article_id: null,
|
||||
revenue_account: null,
|
||||
deduction_type: null,
|
||||
labor_hours: null,
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
dimensions: null,
|
||||
},
|
||||
{
|
||||
id: 'item-1',
|
||||
sort_order: 1,
|
||||
line_type: 'product',
|
||||
description: 'Konsulttimme',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1200,
|
||||
line_total: 2400,
|
||||
vat_rate: 25,
|
||||
vat_amount: 600,
|
||||
article_id: ARTICLE_ID,
|
||||
revenue_account: '3041',
|
||||
deduction_type: null,
|
||||
labor_hours: null,
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
dimensions: { '6': 'P001' },
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('gnubok_get_invoice: registration', () => {
|
||||
it('is registered as a plain read-only tool (not a staged operation)', () => {
|
||||
expect(getInvoice).toBeDefined()
|
||||
expect(getInvoice.annotations.readOnlyHint).toBe(true)
|
||||
expect(getInvoice.annotations.destructiveHint).toBe(false)
|
||||
expect(getInvoice.annotations.idempotentHint).toBe(true)
|
||||
const outputProps = (getInvoice.outputSchema as { properties: Record<string, unknown> }).properties
|
||||
expect(outputProps.items).toBeDefined()
|
||||
expect(outputProps.editable_draft).toBeDefined()
|
||||
expect(outputProps.staged).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires invoice_id and rejects unknown input properties', () => {
|
||||
const schema = getInvoice.inputSchema as { additionalProperties: boolean; required: string[] }
|
||||
expect(schema.additionalProperties).toBe(false)
|
||||
expect(schema.required).toEqual(['invoice_id'])
|
||||
})
|
||||
|
||||
it('is mapped to invoices:read scope', () => {
|
||||
expect(TOOL_SCOPE_MAP.gnubok_get_invoice).toBe('invoices:read')
|
||||
})
|
||||
|
||||
it('is search-only in the catalog (tools/list context budget)', () => {
|
||||
// payload-size.bench.test.ts sits at its ceiling, and the tool this one
|
||||
// serves (gnubok_update_invoice) is search-only as well.
|
||||
expect(getInvoice.catalogVisibility).toBe('search')
|
||||
})
|
||||
|
||||
it('keeps its description within the 280-char budget and names the update tool', () => {
|
||||
expect(getInvoice.description.length).toBeLessThanOrEqual(280)
|
||||
expect(getInvoice.description).toContain('gnubok_update_invoice')
|
||||
})
|
||||
|
||||
it('exposes the booking fields per line in the output schema', () => {
|
||||
const itemProps = (
|
||||
getInvoice.outputSchema as { properties: { items: { items: { properties: Record<string, unknown> } } } }
|
||||
).properties.items.items.properties
|
||||
for (const key of ['invoice_item_id', 'article_id', 'revenue_account', 'vat_rate', 'line_total', 'deduction_type', 'housing_designation', 'apartment_number', 'brf_org_number', 'accrual_period_start', 'dimensions']) {
|
||||
expect(itemProps[key], key).toBeDefined()
|
||||
}
|
||||
expect(itemProps.id).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_get_invoice: execute', () => {
|
||||
it('returns the header and every line in sort order with its booking fields', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: invoiceRow() })
|
||||
|
||||
const result = (await getInvoice.execute(
|
||||
{ invoice_id: INVOICE_ID },
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
supabase as never,
|
||||
{ type: 'api_key' } as never,
|
||||
)) as Record<string, unknown> & { items: Array<Record<string, unknown>> }
|
||||
|
||||
expect(result).toMatchObject({
|
||||
invoice_id: INVOICE_ID,
|
||||
invoice_number: null,
|
||||
status: 'draft',
|
||||
document_type: 'invoice',
|
||||
customer_id: CUSTOMER_ID,
|
||||
customer_name: 'Synthetic Kund AB',
|
||||
currency: 'SEK',
|
||||
subtotal: 3400,
|
||||
vat_amount: 850,
|
||||
total: 4250,
|
||||
remaining_amount: 4250,
|
||||
your_reference: 'Anna',
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
editable_draft: true,
|
||||
item_count: 2,
|
||||
})
|
||||
expect(result.items.map((i) => i.invoice_item_id)).toEqual(['item-1', 'item-2'])
|
||||
expect(result.items[0]).toEqual({
|
||||
invoice_item_id: 'item-1',
|
||||
line_type: 'product',
|
||||
description: 'Konsulttimme',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1200,
|
||||
line_total: 2400,
|
||||
vat_rate: 25,
|
||||
vat_amount: 600,
|
||||
article_id: ARTICLE_ID,
|
||||
revenue_account: '3041',
|
||||
deduction_type: null,
|
||||
labor_hours: null,
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
expect(result.items[1]).toMatchObject({ article_id: null, revenue_account: null, dimensions: {} })
|
||||
// Company scoping is explicit (defense in depth: service-role paths have no RLS).
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
expect(supabase.from).toHaveBeenCalledWith('invoices')
|
||||
expect(findCalls('invoices', 'eq')).toContainEqual(['company_id', COMPANY_ID])
|
||||
expect(findCalls('invoices', 'eq')).toContainEqual(['id', INVOICE_ID])
|
||||
})
|
||||
|
||||
it('reports editable_draft false for an issued invoice', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: invoiceRow({ status: 'sent', invoice_number: '2026-0042', journal_entry_id: 'je-1' }) })
|
||||
|
||||
const result = (await getInvoice.execute(
|
||||
{ invoice_id: INVOICE_ID },
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
supabase as never,
|
||||
{ type: 'api_key' } as never,
|
||||
)) as { editable_draft: boolean; invoice_number: string | null }
|
||||
|
||||
expect(result.editable_draft).toBe(false)
|
||||
expect(result.invoice_number).toBe('2026-0042')
|
||||
})
|
||||
|
||||
it('returns an empty line list for a draft without lines', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: invoiceRow({ items: [] }) })
|
||||
|
||||
const result = (await getInvoice.execute(
|
||||
{ invoice_id: INVOICE_ID },
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
supabase as never,
|
||||
{ type: 'api_key' } as never,
|
||||
)) as { items: unknown[]; item_count: number }
|
||||
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.item_count).toBe(0)
|
||||
})
|
||||
|
||||
it('returns the ROT property identifiers per line so a deduction draft can round-trip', async () => {
|
||||
// The housing columns are property identifiers, not personal data; without
|
||||
// them an items update on a ROT draft fails at approval with
|
||||
// 'Fastighetsbeteckning kravs for ROT-avdrag' (rot-rut-rules.ts).
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const base = invoiceRow()
|
||||
const items = (base.items as Array<Record<string, unknown>>).map((row) =>
|
||||
row.id === 'item-1'
|
||||
? {
|
||||
...row,
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 10,
|
||||
work_type: 'EL',
|
||||
housing_designation: 'Almgren 1:23',
|
||||
apartment_number: '1101',
|
||||
brf_org_number: '769600-1234',
|
||||
}
|
||||
: row,
|
||||
)
|
||||
enqueue({ data: { ...base, items } })
|
||||
|
||||
const result = (await getInvoice.execute(
|
||||
{ invoice_id: INVOICE_ID },
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
supabase as never,
|
||||
{ type: 'api_key' } as never,
|
||||
)) as { items: Array<Record<string, unknown>> }
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 10,
|
||||
work_type: 'EL',
|
||||
housing_designation: 'Almgren 1:23',
|
||||
apartment_number: '1101',
|
||||
brf_org_number: '769600-1234',
|
||||
})
|
||||
})
|
||||
|
||||
it('never returns the encrypted ROT/RUT personnummer columns even if selected by mistake', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: invoiceRow({
|
||||
deduction_personnummer_encrypted: 'LEAKED-CIPHERTEXT',
|
||||
deduction_personnummer_last4: '1234',
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await getInvoice.execute(
|
||||
{ invoice_id: INVOICE_ID },
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
supabase as never,
|
||||
{ type: 'api_key' } as never,
|
||||
)
|
||||
|
||||
const serialized = JSON.stringify(result)
|
||||
expect(serialized).not.toContain('LEAKED-CIPHERTEXT')
|
||||
expect(serialized).not.toContain('deduction_personnummer')
|
||||
})
|
||||
|
||||
it('throws Invoice not found for an invoice outside the routed company', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
|
||||
await expect(
|
||||
getInvoice.execute(
|
||||
{ invoice_id: INVOICE_ID },
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
supabase as never,
|
||||
{ type: 'api_key' } as never,
|
||||
),
|
||||
).rejects.toThrow(/invoice not found/i)
|
||||
})
|
||||
|
||||
it('requires invoice_id', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
getInvoice.execute({}, COMPANY_ID, USER_ID, supabase as never, { type: 'api_key' } as never),
|
||||
).rejects.toThrow(/invoice_id is required/)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a database error instead of reporting a missing invoice', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'connection reset' } })
|
||||
|
||||
await expect(
|
||||
getInvoice.execute(
|
||||
{ invoice_id: INVOICE_ID },
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
supabase as never,
|
||||
{ type: 'api_key' } as never,
|
||||
),
|
||||
).rejects.toThrow(/Database error/)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
|
||||
import { tools } from '../server'
|
||||
|
||||
const INVOICE_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const ARTICLE_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_update_invoice')!
|
||||
|
||||
function draftInvoice(overrides: Record<string, unknown> = {}) {
|
||||
@@ -18,11 +20,68 @@ function draftInvoice(overrides: Record<string, unknown> = {}) {
|
||||
credited_invoice_id: null,
|
||||
total: 12500,
|
||||
currency: 'SEK',
|
||||
customer_id: CUSTOMER_ID,
|
||||
customer: { name: 'Acme AB' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the VAT-rule columns the items branch selects. */
|
||||
const CUSTOMER = { customer_type: 'swedish_business', vat_number_validated: false }
|
||||
/** VIES-validated EU business: reverse charge, single locked 0%. */
|
||||
const EU_CUSTOMER = { customer_type: 'eu_business', vat_number_validated: true }
|
||||
/** Non-EU business: export, single locked 0%. */
|
||||
const EXPORT_CUSTOMER = { customer_type: 'non_eu_business', vat_number_validated: false }
|
||||
|
||||
const ARTICLE = {
|
||||
id: ARTICLE_ID,
|
||||
name: 'Konsulttimme',
|
||||
unit: 'tim',
|
||||
price_excl_vat: 1200,
|
||||
vat_rate: 25,
|
||||
revenue_account: '3041',
|
||||
currency: 'SEK',
|
||||
active: true,
|
||||
}
|
||||
|
||||
/** What the draft holds today: an article line booked to 3041 at 25%. */
|
||||
const CURRENT_ROWS = [
|
||||
{
|
||||
line_type: 'product',
|
||||
description: 'Konsulttimme',
|
||||
quantity: 1,
|
||||
unit: 'tim',
|
||||
unit_price: 1200,
|
||||
line_total: 1200,
|
||||
vat_rate: 25,
|
||||
revenue_account: '3041',
|
||||
article_id: ARTICLE_ID,
|
||||
},
|
||||
]
|
||||
|
||||
type StagedResult = {
|
||||
staged: boolean
|
||||
preview: Record<string, unknown> & {
|
||||
items?: Array<Record<string, unknown>>
|
||||
current_items?: Array<Record<string, unknown>>
|
||||
changes?: { items?: Array<Record<string, unknown>> }
|
||||
}
|
||||
}
|
||||
|
||||
/** Queue order for an items edit: invoices, customers, [articles], invoice_items snapshot, pending_operations. */
|
||||
function enqueueItemsEdit(
|
||||
enqueue: (r: { data: unknown; error?: unknown }) => void,
|
||||
customer: Record<string, unknown>,
|
||||
articleRows: Array<Record<string, unknown>> | null,
|
||||
currentRows: Array<Record<string, unknown>> = CURRENT_ROWS,
|
||||
) {
|
||||
enqueue({ data: draftInvoice() })
|
||||
enqueue({ data: customer })
|
||||
if (articleRows) enqueue({ data: articleRows })
|
||||
enqueue({ data: currentRows })
|
||||
enqueue({ data: { id: 'op-invoice-2' } })
|
||||
}
|
||||
|
||||
describe('gnubok_update_invoice: registration', () => {
|
||||
it('is a strict, staged invoices:write tool at medium risk', () => {
|
||||
expect(tool()).toBeDefined()
|
||||
@@ -46,6 +105,51 @@ describe('gnubok_update_invoice: registration', () => {
|
||||
expect(tool().description).toMatch(/stag(e|ing)/i)
|
||||
})
|
||||
|
||||
it('points the agent at the round-trip read tool and states the replace semantics', () => {
|
||||
expect(tool().description).toMatch(/FULL REPLACE/)
|
||||
expect(tool().description).toContain('gnubok_get_invoice')
|
||||
const items = (tool().inputSchema.properties as Record<string, { description?: string }>).items
|
||||
expect(items.description).toMatch(/FULL REPLACE/)
|
||||
expect(items.description).toContain('gnubok_get_invoice')
|
||||
})
|
||||
|
||||
it('accepts article_id on a line with the same optional shape as gnubok_create_invoice', () => {
|
||||
const items = (tool().inputSchema.properties as Record<string, unknown>).items as {
|
||||
items: { properties: Record<string, unknown>; required: string[] }
|
||||
}
|
||||
expect(items.items.properties.article_id).toBeDefined()
|
||||
expect(items.items.required).toEqual(['quantity'])
|
||||
const create = tools.find((candidate) => candidate.name === 'gnubok_create_invoice')!
|
||||
const createItems = (create.inputSchema.properties as Record<string, unknown>).items as {
|
||||
items: { required: string[] }
|
||||
}
|
||||
expect(items.items.required).toEqual(createItems.items.required)
|
||||
})
|
||||
|
||||
it('declares the full round-trip line shape (text, ROT/RUT, accrual, account override)', () => {
|
||||
// A schema-conformant agent constructs arguments from the declared
|
||||
// properties: anything undeclared is silently dropped on pass-back, which
|
||||
// is exactly the silent-rebooking class issue #1642 reports.
|
||||
const items = (tool().inputSchema.properties as Record<string, unknown>).items as {
|
||||
items: { properties: Record<string, unknown> }
|
||||
}
|
||||
for (const key of [
|
||||
'line_type',
|
||||
'revenue_account',
|
||||
'deduction_type',
|
||||
'labor_hours',
|
||||
'work_type',
|
||||
'housing_designation',
|
||||
'apartment_number',
|
||||
'brf_org_number',
|
||||
'accrual_period_start',
|
||||
'accrual_period_end',
|
||||
'accrual_balance_account',
|
||||
]) {
|
||||
expect(items.items.properties[key], key).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not accept structural or server-controlled fields', () => {
|
||||
const properties = tool().inputSchema.properties as Record<string, unknown>
|
||||
for (const forbidden of ['customer_id', 'currency', 'document_type', 'invoice_number', 'status']) {
|
||||
@@ -148,6 +252,21 @@ describe('gnubok_update_invoice: validation and staging', () => {
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses an items edit on a non-draft before touching customer or articles', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice({ status: 'sent' }) })
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ article_id: ARTICLE_ID, quantity: 1 }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/not an editable draft/i)
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns a dry-run preview without staging', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() })
|
||||
@@ -169,7 +288,7 @@ describe('gnubok_update_invoice: validation and staging', () => {
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stages a header edit for approval', async () => {
|
||||
it('stages a header edit for approval with exactly one read', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() })
|
||||
enqueue({ data: { id: 'op-invoice-1' } })
|
||||
@@ -179,20 +298,23 @@ describe('gnubok_update_invoice: validation and staging', () => {
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; operation_id?: string; risk_level: string }
|
||||
)) as { staged: boolean; operation_id?: string; risk_level: string; preview: Record<string, unknown> }
|
||||
|
||||
expect(result).toMatchObject({
|
||||
staged: true,
|
||||
operation_id: 'op-invoice-1',
|
||||
risk_level: 'medium',
|
||||
})
|
||||
expect(supabase.from).toHaveBeenCalledTimes(2)
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
|
||||
// No line snapshot on a header-only edit: nothing is replaced.
|
||||
expect(result.preview.items).toBeUndefined()
|
||||
expect(result.preview.current_items).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stages a full item replace with the replace marker in the preview', async () => {
|
||||
it('stages a full item replace with the effective booking and the lines being replaced', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() })
|
||||
enqueue({ data: { id: 'op-invoice-2' } })
|
||||
enqueueItemsEdit(enqueue, CUSTOMER, null)
|
||||
|
||||
const result = (await tool().execute(
|
||||
{
|
||||
@@ -204,13 +326,532 @@ describe('gnubok_update_invoice: validation and staging', () => {
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; preview: Record<string, unknown> }
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview).toMatchObject({
|
||||
invoice_id: INVOICE_ID,
|
||||
items_replace: true,
|
||||
item_count: 1,
|
||||
currency: 'SEK',
|
||||
subtotal: 2000,
|
||||
vat_amount: 500,
|
||||
total: 2500,
|
||||
})
|
||||
// The approver sees the per-line booking, not only a row count: a line
|
||||
// without an article books by VAT treatment (revenue_account null).
|
||||
expect(result.preview.items?.[0]).toEqual({
|
||||
line_type: 'product',
|
||||
description: 'Konsultation',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 2000,
|
||||
vat_rate: 25,
|
||||
revenue_account: null,
|
||||
article_id: null,
|
||||
deduction_type: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
})
|
||||
// ... next to what the replace deletes (the 3041 article line), with the
|
||||
// ROT/RUT and periodisering markers the approver needs to see a removal.
|
||||
expect(result.preview.current_items).toEqual(
|
||||
CURRENT_ROWS.map((row) => ({
|
||||
...row,
|
||||
deduction_type: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
})),
|
||||
)
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(1, 'invoices')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(2, 'customers')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(3, 'invoice_items')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(4, 'pending_operations')
|
||||
expect(supabase.from).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('applies the customer default VAT rate to a line that omits vat_rate', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueItemsEdit(enqueue, CUSTOMER, null)
|
||||
|
||||
const result = (await tool().execute(
|
||||
{
|
||||
invoice_id: INVOICE_ID,
|
||||
items: [{ description: 'Konsultation', quantity: 1, unit: 'tim', unit_price: 1000 }],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.preview.items?.[0]).toMatchObject({ vat_rate: 25, line_total: 1000 })
|
||||
expect(result.preview.total).toBe(1250)
|
||||
})
|
||||
|
||||
it('fails when the draft customer is gone (VAT rules cannot be resolved)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() })
|
||||
enqueue({ data: null, error: { message: 'no rows' } })
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ description: 'Rad', quantity: 1, unit: 'st', unit_price: 100 }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/customer not found/i)
|
||||
expect(supabase.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_update_invoice: article_id on items (issue #1642)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('prefills description, unit, price, VAT and revenue account from the article', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueItemsEdit(enqueue, CUSTOMER, [ARTICLE])
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ article_id: ARTICLE_ID, quantity: 2 }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
const expected = {
|
||||
article_id: ARTICLE_ID,
|
||||
description: 'Konsulttimme',
|
||||
unit: 'tim',
|
||||
unit_price: 1200,
|
||||
vat_rate: 25,
|
||||
revenue_account: '3041',
|
||||
}
|
||||
// Both what the executor will write (params.changes.items) and what the
|
||||
// approver sees (preview.items) carry the article linkage: the quantity
|
||||
// fix no longer rebooks 3041 to the VAT-derived default.
|
||||
expect(result.preview.changes?.items?.[0]).toMatchObject(expected)
|
||||
expect(result.preview.items?.[0]).toMatchObject({ ...expected, quantity: 2, line_total: 2400 })
|
||||
expect(result.preview.total).toBe(3000)
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(1, 'invoices')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(2, 'customers')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(3, 'articles')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(4, 'invoice_items')
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(5, 'pending_operations')
|
||||
})
|
||||
|
||||
it('lets explicit line values win over the article', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueItemsEdit(enqueue, CUSTOMER, [ARTICLE])
|
||||
|
||||
const result = (await tool().execute(
|
||||
{
|
||||
invoice_id: INVOICE_ID,
|
||||
items: [
|
||||
{ article_id: ARTICLE_ID, quantity: 1, description: 'Rabatterad timme', unit_price: 800, revenue_account: '3051' },
|
||||
],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.preview.items?.[0]).toMatchObject({
|
||||
description: 'Rabatterad timme',
|
||||
unit_price: 800,
|
||||
unit: 'tim',
|
||||
vat_rate: 25,
|
||||
revenue_account: '3051',
|
||||
article_id: ARTICLE_ID,
|
||||
})
|
||||
expect(result.preview.total).toBe(1000)
|
||||
})
|
||||
|
||||
it('does NOT adopt the article domestic rate for a reverse-charge EU customer', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueItemsEdit(enqueue, EU_CUSTOMER, [ARTICLE])
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ article_id: ARTICLE_ID, quantity: 10 }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.items?.[0]).toMatchObject({ vat_rate: 0, unit_price: 1200, revenue_account: '3041' })
|
||||
expect(result.preview.vat_amount).toBe(0)
|
||||
expect(result.preview.total).toBe(12000)
|
||||
})
|
||||
|
||||
it('does NOT adopt the article domestic rate for an export customer', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueItemsEdit(enqueue, EXPORT_CUSTOMER, [ARTICLE])
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ article_id: ARTICLE_ID, quantity: 2 }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.preview.items?.[0]).toMatchObject({ vat_rate: 0 })
|
||||
expect(result.preview.vat_amount).toBe(0)
|
||||
expect(result.preview.total).toBe(2400)
|
||||
})
|
||||
|
||||
it('gates the effective rate against the permitted set at staging, not at approval', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() })
|
||||
enqueue({ data: CUSTOMER })
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{
|
||||
invoice_id: INVOICE_ID,
|
||||
// 19% is not a Swedish VAT rate for any customer type: the agent
|
||||
// gets the error here instead of a failed approval later.
|
||||
items: [{ description: 'Konsultation', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 19 }],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/not allowed/)
|
||||
expect(supabase.from).not.toHaveBeenCalledWith('pending_operations')
|
||||
})
|
||||
|
||||
it('refuses an article_id that does not exist in this company', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() })
|
||||
enqueue({ data: CUSTOMER })
|
||||
enqueue({ data: [] }) // articles: no company-scoped hit
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ article_id: ARTICLE_ID, quantity: 1 }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/gnubok_list_articles/)
|
||||
})
|
||||
|
||||
it('refuses a deactivated article', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() })
|
||||
enqueue({ data: CUSTOMER })
|
||||
enqueue({ data: [{ ...ARTICLE, active: false }] })
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ article_id: ARTICLE_ID, quantity: 1 }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/deactivated/)
|
||||
})
|
||||
|
||||
it('refuses a price prefill from an article in another currency than the draft', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() }) // SEK draft
|
||||
enqueue({ data: CUSTOMER })
|
||||
enqueue({ data: [{ ...ARTICLE, currency: 'EUR', price_excl_vat: 100 }] })
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ article_id: ARTICLE_ID, quantity: 1 }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/priced in EUR but the invoice is in SEK/)
|
||||
})
|
||||
|
||||
it('still requires description, unit and unit_price on a line without an article', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() })
|
||||
enqueue({ data: CUSTOMER })
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ quantity: 1, unit: 'st', unit_price: 100 }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/description is required/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_update_invoice: free-text rows (round-trip, issue #1642)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('accepts a text spacer row passed back from gnubok_get_invoice (quantity 0)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueItemsEdit(enqueue, CUSTOMER, null)
|
||||
|
||||
const result = (await tool().execute(
|
||||
{
|
||||
invoice_id: INVOICE_ID,
|
||||
items: [
|
||||
// Exactly the shape gnubok_get_invoice returns for a web-created
|
||||
// spacer row (build-invoice-write stores quantity 0, unit '', price 0).
|
||||
{ line_type: 'text', description: 'Avser sprint 12', quantity: 0, unit: '', unit_price: 0, vat_rate: 0 },
|
||||
{ description: 'Konsultation', quantity: 2, unit: 'tim', unit_price: 1000, vat_rate: 25 },
|
||||
],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
// Totals exclude the text row (commitCreateInvoice billableItems parity).
|
||||
expect(result.preview).toMatchObject({ subtotal: 2000, vat_amount: 500, total: 2500, item_count: 2 })
|
||||
expect(result.preview.items?.[0]).toMatchObject({
|
||||
line_type: 'text',
|
||||
description: 'Avser sprint 12',
|
||||
quantity: 0,
|
||||
line_total: 0,
|
||||
vat_rate: 0,
|
||||
})
|
||||
// The staged params keep the row so the FULL REPLACE does not delete it.
|
||||
expect(result.preview.changes?.items?.[0]).toMatchObject({ line_type: 'text', description: 'Avser sprint 12' })
|
||||
})
|
||||
|
||||
it('does not require description, unit or unit_price on a text row', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueItemsEdit(enqueue, CUSTOMER, null)
|
||||
|
||||
const result = (await tool().execute(
|
||||
{
|
||||
invoice_id: INVOICE_ID,
|
||||
items: [
|
||||
{ line_type: 'text', quantity: 0 },
|
||||
{ description: 'Konsultation', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 },
|
||||
],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.items?.[0]).toMatchObject({ line_type: 'text', description: '', line_total: 0 })
|
||||
})
|
||||
|
||||
it('skips the permitted-VAT gate for text rows (0% is not a real supply)', async () => {
|
||||
// A domestic draft's text row comes back with vat_rate 0: the gate must
|
||||
// not treat it as a zero-rated product line.
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueItemsEdit(enqueue, CUSTOMER, null)
|
||||
|
||||
const result = (await tool().execute(
|
||||
{
|
||||
invoice_id: INVOICE_ID,
|
||||
items: [
|
||||
{ line_type: 'text', description: 'Mellanrubrik', quantity: 0, unit: '', unit_price: 0, vat_rate: 0 },
|
||||
{ description: 'Konsultation', quantity: 1, unit: 'tim', unit_price: 100, vat_rate: 25 },
|
||||
],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.vat_amount).toBe(25)
|
||||
})
|
||||
|
||||
it('rejects a text row carrying article_id', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() })
|
||||
enqueue({ data: CUSTOMER })
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{
|
||||
invoice_id: INVOICE_ID,
|
||||
items: [{ line_type: 'text', article_id: ARTICLE_ID, quantity: 0 }],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/text row cannot carry article_id/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_update_invoice: ROT/RUT round trip (issue #1642)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
/** A ROT line exactly as gnubok_get_invoice returns it from a web-created draft. */
|
||||
const ROT_LINE = {
|
||||
description: 'Elarbete',
|
||||
quantity: 10,
|
||||
unit: 'tim',
|
||||
unit_price: 800,
|
||||
vat_rate: 25,
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 10,
|
||||
work_type: 'EL',
|
||||
housing_designation: 'Almgren 1:23',
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
}
|
||||
|
||||
it('stages a ROT line pass-back with the deduction visible to the approver', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// The draft carries the personnummer as ciphertext: staging only checks
|
||||
// presence and must never stage or return it.
|
||||
enqueue({ data: draftInvoice({ deduction_personnummer_encrypted: 'ROT-CIPHERTEXT' }) })
|
||||
enqueue({ data: CUSTOMER })
|
||||
enqueue({ data: [{ ...CURRENT_ROWS[0], deduction_type: 'rot' }] })
|
||||
enqueue({ data: { id: 'op-invoice-3' } })
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [ROT_LINE] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.items?.[0]).toMatchObject({ deduction_type: 'rot' })
|
||||
expect(result.preview.current_items?.[0]).toMatchObject({ deduction_type: 'rot' })
|
||||
// The staged params carry the claim fields the executor derives the
|
||||
// invoice-level property info from (commitUpdateInvoice firstDeduction).
|
||||
expect(result.preview.changes?.items?.[0]).toMatchObject({
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 10,
|
||||
work_type: 'EL',
|
||||
housing_designation: 'Almgren 1:23',
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('ROT-CIPHERTEXT')
|
||||
})
|
||||
|
||||
it('fails at staging, not approval, when a ROT set lacks the property info', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice({ deduction_personnummer_encrypted: 'ROT-CIPHERTEXT' }) })
|
||||
enqueue({ data: CUSTOMER })
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ ...ROT_LINE, housing_designation: null }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/housing_designation|fastighetsbeteckning/i)
|
||||
expect(supabase.from).not.toHaveBeenCalledWith('pending_operations')
|
||||
})
|
||||
|
||||
it('fails at staging when a deduction line lacks its arbetstyp', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice({ deduction_personnummer_encrypted: 'ROT-CIPHERTEXT' }) })
|
||||
enqueue({ data: CUSTOMER })
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [{ ...ROT_LINE, work_type: null }] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/Arbetstyp/)
|
||||
expect(supabase.from).not.toHaveBeenCalledWith('pending_operations')
|
||||
})
|
||||
|
||||
it('fails at staging when no personnummer exists on the invoice or the kundkort', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() }) // no stored ciphertext
|
||||
enqueue({ data: CUSTOMER }) // business customer: no kundkort fallback
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [ROT_LINE] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/personnummer/i)
|
||||
expect(supabase.from).not.toHaveBeenCalledWith('pending_operations')
|
||||
})
|
||||
|
||||
it('accepts a deduction set when the individual customer card holds a personnummer', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: draftInvoice() }) // no stored ciphertext on the draft
|
||||
enqueue({ data: { customer_type: 'individual', vat_number_validated: false, personal_number: 'enc:v1:abc' } })
|
||||
enqueue({ data: CURRENT_ROWS })
|
||||
enqueue({ data: { id: 'op-invoice-4' } })
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ invoice_id: INVOICE_ID, items: [ROT_LINE] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(JSON.stringify(result)).not.toContain('enc:v1:abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_update_invoice: accrual and override pass-back (issue #1642)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps periodisering fields on a passed-back line and shows the deferral in the preview', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueItemsEdit(enqueue, CUSTOMER, null)
|
||||
|
||||
const result = (await tool().execute(
|
||||
{
|
||||
invoice_id: INVOICE_ID,
|
||||
items: [
|
||||
{
|
||||
description: 'Licens 12 manader',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 12000,
|
||||
vat_rate: 25,
|
||||
revenue_account: '3051',
|
||||
accrual_period_start: '2026-09-01',
|
||||
accrual_period_end: '2027-08-31',
|
||||
accrual_balance_account: '2990',
|
||||
},
|
||||
],
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as StagedResult
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
// Visible to the approver: an update that drops the deferral would show
|
||||
// bare lines here instead.
|
||||
expect(result.preview.items?.[0]).toMatchObject({
|
||||
revenue_account: '3051',
|
||||
accrual_period_start: '2026-09-01',
|
||||
accrual_period_end: '2027-08-31',
|
||||
})
|
||||
// And staged for the executor, so revenue keeps deferring over the period.
|
||||
expect(result.preview.changes?.items?.[0]).toMatchObject({
|
||||
revenue_account: '3051',
|
||||
accrual_period_start: '2026-09-01',
|
||||
accrual_period_end: '2027-08-31',
|
||||
accrual_balance_account: '2990',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,6 +56,7 @@ import { formatVoucherLabel, hasLiveJournalEntryLink } from '@/lib/transactions/
|
||||
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { getVatRules, getPermittedVatRates, getArticleVatRateAdoptionSet } from '@/lib/invoices/vat-rules'
|
||||
import { validateDeductionLines } from '@/lib/invoices/rot-rut-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
@@ -289,6 +290,7 @@ import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoic
|
||||
// ── Actor context ────────────────────────────────────────────
|
||||
|
||||
type StagedInvoiceLineInput = {
|
||||
line_type?: 'product' | 'text'
|
||||
description?: string
|
||||
quantity: number
|
||||
unit?: string
|
||||
@@ -296,6 +298,19 @@ type StagedInvoiceLineInput = {
|
||||
vat_rate?: number
|
||||
article_id?: string
|
||||
revenue_account?: string | null
|
||||
// ROT/RUT claim fields (CreateInvoiceItemSchema parity). The housing
|
||||
// columns are property identifiers, never the personnummer: the stored
|
||||
// personnummer exists only as ciphertext and never crosses the MCP surface.
|
||||
deduction_type?: 'rot' | 'rut' | null
|
||||
labor_hours?: number | null
|
||||
work_type?: string | null
|
||||
housing_designation?: string | null
|
||||
apartment_number?: string | null
|
||||
brf_org_number?: string | null
|
||||
// Periodisering (förutbetald intäkt): pass-through to the builder.
|
||||
accrual_period_start?: string | null
|
||||
accrual_period_end?: string | null
|
||||
accrual_balance_account?: string | null
|
||||
dimensions?: unknown
|
||||
}
|
||||
|
||||
@@ -333,6 +348,23 @@ function resolveInvoiceLineFromArticle(
|
||||
index: number,
|
||||
): ResolvedInvoiceLine {
|
||||
const lineNo = index + 1
|
||||
// Free-text / spacer rows (web parity, CreateInvoiceItemSchema): no
|
||||
// amounts, never book, exempt from the quantity/description/unit/price
|
||||
// gates. Normalized to the exact zeroed shape build-invoice-write stores so
|
||||
// a gnubok_get_invoice line passes back verbatim.
|
||||
if (item.line_type === 'text') {
|
||||
if (item.article_id) {
|
||||
throw new Error(`Item ${lineNo}: a text row cannot carry article_id (drop line_type or article_id)`)
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
description: item.description ?? '',
|
||||
quantity: 0,
|
||||
unit: '',
|
||||
unit_price: 0,
|
||||
vat_rate: 0,
|
||||
}
|
||||
}
|
||||
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.`)
|
||||
@@ -6157,6 +6189,188 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_get_invoice',
|
||||
title: 'Get Invoice',
|
||||
description: 'One invoice: header plus every line with article_id, revenue_account, vat_rate and dimensions. Read it before gnubok_update_invoice (items are a FULL REPLACE) so each line goes back with its article linkage. editable_draft tells whether an edit is possible.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
invoice_id: { type: 'string', description: 'UUID from gnubok_list_invoices' },
|
||||
},
|
||||
required: ['invoice_id'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
invoice_id: { type: 'string' },
|
||||
invoice_number: { type: ['string', 'null'], description: 'null until sent' },
|
||||
status: { type: 'string' },
|
||||
document_type: { type: 'string', description: 'invoice, proforma or delivery_note' },
|
||||
customer_id: { type: 'string' },
|
||||
customer_name: { type: ['string', 'null'] },
|
||||
invoice_date: { type: 'string' },
|
||||
due_date: { type: ['string', 'null'] },
|
||||
delivery_date: { type: ['string', 'null'] },
|
||||
currency: { type: 'string' },
|
||||
subtotal: { type: 'number' },
|
||||
vat_amount: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
paid_amount: { type: 'number' },
|
||||
remaining_amount: { type: ['number', 'null'] },
|
||||
your_reference: { type: ['string', 'null'] },
|
||||
our_reference: { type: ['string', 'null'] },
|
||||
notes: { type: ['string', 'null'] },
|
||||
default_dimensions: { type: 'object', additionalProperties: { type: 'string' } },
|
||||
editable_draft: { type: 'boolean', description: 'true when gnubok_update_invoice can edit it' },
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'Lines in display order; pass them back to gnubok_update_invoice verbatim: article, ROT/RUT, accrual and account fields survive only if passed back.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
invoice_item_id: { type: 'string' },
|
||||
line_type: { type: 'string', description: 'product or text' },
|
||||
description: { type: 'string' },
|
||||
quantity: { type: 'number' },
|
||||
unit: { type: 'string' },
|
||||
unit_price: { type: 'number' },
|
||||
line_total: { type: 'number' },
|
||||
vat_rate: { type: 'number' },
|
||||
vat_amount: { type: 'number' },
|
||||
article_id: { type: ['string', 'null'] },
|
||||
revenue_account: { type: ['string', 'null'], description: 'Posting-account override; null books by VAT treatment' },
|
||||
deduction_type: { type: ['string', 'null'], description: 'rot, rut or null' },
|
||||
labor_hours: { type: ['number', 'null'] },
|
||||
work_type: { type: ['string', 'null'] },
|
||||
housing_designation: { type: ['string', 'null'], description: 'Fastighetsbeteckning (ROT); property id, not personal data' },
|
||||
apartment_number: { type: ['string', 'null'] },
|
||||
brf_org_number: { type: ['string', 'null'] },
|
||||
accrual_period_start: { type: ['string', 'null'] },
|
||||
accrual_period_end: { type: ['string', 'null'] },
|
||||
accrual_balance_account: { type: ['string', 'null'] },
|
||||
dimensions: { type: 'object', additionalProperties: { type: 'string' } },
|
||||
},
|
||||
required: ['invoice_item_id', 'line_type', 'description', 'quantity', 'unit', 'unit_price', 'line_total', 'vat_rate'],
|
||||
},
|
||||
},
|
||||
item_count: { type: 'number' },
|
||||
},
|
||||
required: ['invoice_id', 'status', 'currency', 'editable_draft', 'items', 'item_count'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
// Round-trip read surface for gnubok_update_invoice (issue #1642). Kept
|
||||
// out of the default tools/list: payload-size.bench.test.ts sits at its
|
||||
// ceiling, and the update tool that needs it is search-only as well.
|
||||
catalogVisibility: 'search',
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const invoiceId = args.invoice_id as string
|
||||
if (!invoiceId) throw new Error('invoice_id is required. Use gnubok_list_invoices to find IDs.')
|
||||
|
||||
// Explicit column list on purpose: invoices carries the encrypted
|
||||
// ROT/RUT personnummer columns, which must never reach the MCP surface.
|
||||
const { data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)',
|
||||
)
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
if (!invoice) throw new Error('Invoice not found. Use gnubok_list_invoices to find valid IDs.')
|
||||
|
||||
type InvoiceLineRow = {
|
||||
id: string
|
||||
sort_order: number | null
|
||||
line_type: string | null
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
line_total: number
|
||||
vat_rate: number
|
||||
vat_amount: number | null
|
||||
article_id: string | null
|
||||
revenue_account: string | null
|
||||
deduction_type: string | null
|
||||
labor_hours: number | null
|
||||
work_type: string | null
|
||||
housing_designation: string | null
|
||||
apartment_number: string | null
|
||||
brf_org_number: string | null
|
||||
accrual_period_start: string | null
|
||||
accrual_period_end: string | null
|
||||
accrual_balance_account: string | null
|
||||
dimensions: Record<string, string> | null
|
||||
}
|
||||
// PostgREST returns embedded rows unordered: sort here, never rely on
|
||||
// insertion order.
|
||||
const rows = ((invoice.items ?? []) as InvoiceLineRow[])
|
||||
.slice()
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
const items = rows.map((row) => ({
|
||||
invoice_item_id: row.id,
|
||||
line_type: row.line_type ?? 'product',
|
||||
description: row.description,
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
unit_price: row.unit_price,
|
||||
line_total: row.line_total,
|
||||
vat_rate: row.vat_rate,
|
||||
vat_amount: row.vat_amount ?? 0,
|
||||
article_id: row.article_id ?? null,
|
||||
revenue_account: row.revenue_account ?? null,
|
||||
deduction_type: row.deduction_type ?? null,
|
||||
labor_hours: row.labor_hours ?? null,
|
||||
work_type: row.work_type ?? null,
|
||||
// Property identifiers for the ROT claim, needed for the update round
|
||||
// trip; the encrypted personnummer stays out of this surface.
|
||||
housing_designation: row.housing_designation ?? null,
|
||||
apartment_number: row.apartment_number ?? null,
|
||||
brf_org_number: row.brf_org_number ?? null,
|
||||
accrual_period_start: row.accrual_period_start ?? null,
|
||||
accrual_period_end: row.accrual_period_end ?? null,
|
||||
accrual_balance_account: row.accrual_balance_account ?? null,
|
||||
dimensions: row.dimensions ?? {},
|
||||
}))
|
||||
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoice_number ?? null,
|
||||
status: invoice.status,
|
||||
document_type: invoice.document_type ?? 'invoice',
|
||||
customer_id: invoice.customer_id,
|
||||
customer_name: (invoice.customer as { name?: string } | null)?.name ?? null,
|
||||
invoice_date: invoice.invoice_date,
|
||||
due_date: invoice.due_date ?? null,
|
||||
delivery_date: invoice.delivery_date ?? null,
|
||||
currency: invoice.currency,
|
||||
subtotal: invoice.subtotal,
|
||||
vat_amount: invoice.vat_amount,
|
||||
total: invoice.total,
|
||||
paid_amount: invoice.paid_amount ?? 0,
|
||||
remaining_amount: invoice.remaining_amount ?? null,
|
||||
your_reference: invoice.your_reference ?? null,
|
||||
our_reference: invoice.our_reference ?? null,
|
||||
notes: invoice.notes ?? null,
|
||||
default_dimensions: (invoice.default_dimensions as Record<string, string> | null) ?? {},
|
||||
editable_draft: isEditableInvoiceDraft(invoice),
|
||||
items,
|
||||
item_count: items.length,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_create_invoice',
|
||||
title: 'Create Customer Invoice',
|
||||
@@ -6181,6 +6395,8 @@ export const tools: McpTool[] = [
|
||||
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.',
|
||||
},
|
||||
line_type: { type: 'string', enum: ['product', 'text'], description: 'text = free-text row: no amounts, never books.' },
|
||||
revenue_account: { type: ['string', 'null'], description: 'BAS class 1-3 posting-account override.' },
|
||||
dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
@@ -16137,7 +16353,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_update_invoice',
|
||||
title: 'Update Draft Invoice',
|
||||
description: 'Stage an edit to a DRAFT invoice: header fields (incl. default_dimensions) and/or items (items = FULL REPLACE). Drafts only: no verifikat, not self-billed, not a credit note. Sent/paid invoices need gnubok_credit_invoice. Find invoice_id with gnubok_list_invoices.',
|
||||
description: 'Stage an edit to a DRAFT invoice: header fields (incl. default_dimensions) and/or items (FULL REPLACE: read current lines with gnubok_get_invoice first; lines accept article_id). Drafts only, no verifikat, not self-billed, not a credit note; otherwise use gnubok_credit_invoice.',
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -16160,15 +16376,37 @@ 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. Pass it back on every line that should keep its article linkage.',
|
||||
},
|
||||
line_type: {
|
||||
type: 'string',
|
||||
enum: ['product', 'text'],
|
||||
description: 'text = free-text/spacer row: no amounts, never books; the quantity/description/unit/price rules are skipped.',
|
||||
},
|
||||
revenue_account: {
|
||||
type: ['string', 'null'],
|
||||
description: 'BAS class 1-3 posting-account override; null books by VAT treatment. Pass back to keep a manual override.',
|
||||
},
|
||||
deduction_type: { type: ['string', 'null'], description: 'rot or rut; pass back or the ROT/RUT-avdrag is removed by the replace.' },
|
||||
labor_hours: { type: ['number', 'null'] },
|
||||
work_type: { type: ['string', 'null'], description: 'Skatteverket arbetstypskod for the deduction line.' },
|
||||
housing_designation: { type: ['string', 'null'], description: 'Fastighetsbeteckning; required on ROT lines.' },
|
||||
apartment_number: { type: ['string', 'null'] },
|
||||
brf_org_number: { type: ['string', 'null'] },
|
||||
accrual_period_start: { type: ['string', 'null'], description: 'YYYY-MM-DD; with accrual_period_end defers the revenue (periodisering). Pass back or the deferral is removed.' },
|
||||
accrual_period_end: { type: ['string', 'null'] },
|
||||
accrual_balance_account: { type: ['string', 'null'], description: '29xx interim account; null = default.' },
|
||||
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: 'FULL REPLACE: when provided, every existing line is deleted and this array becomes the new line set. Omit to keep the current lines.',
|
||||
description: 'FULL REPLACE: every existing line is deleted and this array becomes the new line set. Read the current lines with gnubok_get_invoice first and pass unchanged lines back verbatim (article, ROT/RUT, accrual and account fields survive only if passed back). Omit to keep the current lines.',
|
||||
},
|
||||
default_dimensions: {
|
||||
type: 'object',
|
||||
@@ -16191,66 +16429,45 @@ export const tools: McpTool[] = [
|
||||
const invoiceId = args.invoice_id as string
|
||||
if (!invoiceId) throw new Error('invoice_id is required. Use gnubok_list_invoices to find IDs.')
|
||||
|
||||
const rawItems = args.items as
|
||||
| Array<{
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate?: number
|
||||
dimensions?: unknown
|
||||
}>
|
||||
| undefined
|
||||
const rawItems = args.items as StagedInvoiceLineInput[] | undefined
|
||||
|
||||
// Cheap pre-query gates. description/unit/unit_price are checked after
|
||||
// the article prefill (resolveInvoiceLineFromArticle), since a line
|
||||
// carrying article_id legitimately omits all three.
|
||||
if (rawItems !== undefined) {
|
||||
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
||||
throw new Error('items must be a non-empty array: it fully REPLACES every existing line on the draft.')
|
||||
throw new Error(
|
||||
'items must be a non-empty array: it fully REPLACES every existing line on the draft. ' +
|
||||
'Read the current lines with gnubok_get_invoice first.',
|
||||
)
|
||||
}
|
||||
for (const [i, item] of rawItems.entries()) {
|
||||
if (!item.description?.trim()) throw new Error(`Item ${i + 1}: description is required`)
|
||||
// Text rows are stored with quantity 0 and legitimately come back
|
||||
// that way from gnubok_get_invoice (web parity: the canonical
|
||||
// CreateInvoiceItemSchema exempts them from the quantity rule).
|
||||
if (item.line_type === 'text') continue
|
||||
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`)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve-don't-select (same pass as gnubok_create_invoice): parse the
|
||||
// default bag + each item's bag, then resolve codes AND names against
|
||||
// the registry in one go (zero queries when nothing is tagged).
|
||||
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
|
||||
const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
|
||||
supabase,
|
||||
companyId,
|
||||
[
|
||||
defaultDimensions,
|
||||
...(rawItems ?? []).map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`)),
|
||||
],
|
||||
)
|
||||
const resolvedDefaultDimensions = resolvedDimBags[0]
|
||||
const stagedItems = rawItems?.map((item, i) => {
|
||||
const { dimensions: _rawDimensions, ...rest } = item
|
||||
const bag = resolvedDimBags[i + 1]
|
||||
return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest
|
||||
})
|
||||
|
||||
const changes: Record<string, unknown> = {}
|
||||
const headerChanges: Record<string, unknown> = {}
|
||||
for (const key of ['notes', 'invoice_date', 'due_date', 'delivery_date', 'your_reference', 'our_reference']) {
|
||||
if (args[key] !== undefined) changes[key] = args[key]
|
||||
if (args[key] !== undefined) headerChanges[key] = args[key]
|
||||
}
|
||||
if (stagedItems) changes.items = stagedItems
|
||||
// The bag replaces wholesale, never merges: {} clears every tag.
|
||||
if (args.default_dimensions !== undefined) changes.default_dimensions = resolvedDefaultDimensions ?? {}
|
||||
|
||||
const parsed = UpdateInvoiceParamsSchema.safeParse({ invoice_id: invoiceId, changes })
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0]
|
||||
throw new Error(`Invalid invoice update: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`)
|
||||
if (rawItems === undefined && args.default_dimensions === undefined && Object.keys(headerChanges).length === 0) {
|
||||
throw new Error('Invalid invoice update: at least one invoice field must be supplied')
|
||||
}
|
||||
|
||||
// The draft first: its customer (VAT rules for the article prefill) and
|
||||
// its currency (article price prefill) are structural and come from the
|
||||
// stored row, never from the arguments.
|
||||
// deduction_personnummer_encrypted is fetched ONLY as a presence check
|
||||
// for the ROT/RUT staging gate below; the ciphertext is never staged,
|
||||
// previewed, or returned.
|
||||
const { data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, status, document_type, journal_entry_id, is_self_billed, credited_invoice_id, total, currency, customer:customers(name)')
|
||||
.eq('id', parsed.data.invoice_id)
|
||||
.select('id, invoice_number, status, document_type, journal_entry_id, is_self_billed, credited_invoice_id, total, currency, customer_id, deduction_personnummer_encrypted, customer:customers(name)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
@@ -16269,8 +16486,183 @@ export const tools: McpTool[] = [
|
||||
)
|
||||
}
|
||||
|
||||
const currency = ((invoice.currency as string) || 'SEK') as Currency
|
||||
const customerName = (invoice.customer as { name?: string } | null)?.name
|
||||
|
||||
// Items branch (issue #1642): article prefill + default-set VAT
|
||||
// adoption exactly like gnubok_create_invoice, the permitted-set VAT
|
||||
// gate at staging so the agent sees the error here rather than at
|
||||
// approval, and a snapshot of the lines being replaced so the approver
|
||||
// can see a rebooking (revenue_account / vat_rate) in the preview.
|
||||
let items: ResolvedInvoiceLine[] | undefined
|
||||
let defaultVatRate = 0
|
||||
let subtotal = 0
|
||||
let vatAmount = 0
|
||||
let currentItems: Array<Record<string, unknown>> | undefined
|
||||
if (rawItems !== undefined) {
|
||||
// personal_number is fetched ONLY as a presence check for the ROT/RUT
|
||||
// staging gate below (commit falls back to the kundkort personnummer
|
||||
// for individuals); never decrypted, staged, or returned here.
|
||||
const { data: customer, error: custError } = await supabase
|
||||
.from('customers')
|
||||
.select('customer_type, vat_number_validated, personal_number')
|
||||
.eq('id', invoice.customer_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (custError || !customer) {
|
||||
throw new Error('Customer not found: they may have been deleted. The draft cannot be edited without its customer.')
|
||||
}
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
defaultVatRate = vatRules.rate
|
||||
const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
items = rawItems.map((item, i) =>
|
||||
resolveInvoiceLineFromArticle(
|
||||
item,
|
||||
item.article_id ? articlesById.get(item.article_id) : undefined,
|
||||
currency,
|
||||
adoptableVatRates,
|
||||
i,
|
||||
),
|
||||
)
|
||||
|
||||
// Same PERMITTED-set gate as create (taxed-where-performed supplies
|
||||
// carry Swedish VAT even to a foreign business); the default stays
|
||||
// vatRules.rate, so a Swedish rate only lands here when set on the
|
||||
// line or adopted from an article within the default set.
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
for (const item of items) {
|
||||
// Text rows carry no amounts and never book: exclude them from the
|
||||
// VAT gate and the totals, like commitCreateInvoice's billableItems.
|
||||
if (item.line_type === 'text') continue
|
||||
const itemRate = item.vat_rate ?? vatRules.rate
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
throw new Error(
|
||||
`VAT rate ${itemRate}% is not allowed for customer type "${customer.customer_type}". ` +
|
||||
`Allowed rates: ${permittedRates.map((r) => r.rate + '%').join(', ')}`
|
||||
)
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
subtotal += lineTotal
|
||||
vatAmount += roundOre(lineTotal * itemRate / 100)
|
||||
}
|
||||
|
||||
// ROT/RUT staging gate: everything buildInvoiceWriteData would refuse
|
||||
// at commit time that this staged set already determines must fail
|
||||
// HERE, where the agent can fix it, not after approval. (The staging
|
||||
// VAT gate above exists for the same reason.)
|
||||
const deductionLines = items.filter((item) => item.line_type !== 'text' && item.deduction_type)
|
||||
if (deductionLines.length > 0) {
|
||||
const claimErrors = validateDeductionLines(
|
||||
deductionLines.map((item) => ({
|
||||
unit_price: item.unit_price,
|
||||
quantity: item.quantity,
|
||||
deduction_type: item.deduction_type ?? null,
|
||||
vat_rate: item.vat_rate ?? vatRules.rate,
|
||||
labor_hours: item.labor_hours ?? null,
|
||||
work_type: item.work_type ?? null,
|
||||
})),
|
||||
)
|
||||
if (claimErrors.length > 0) {
|
||||
throw new Error(`ROT/RUT: ${claimErrors.join(' ')} Read the current lines with gnubok_get_invoice and pass the deduction fields back.`)
|
||||
}
|
||||
// Commit derives the invoice-level property info from the FIRST
|
||||
// deduction line (commitUpdateInvoice), mirrored here.
|
||||
const firstDeduction = deductionLines[0]
|
||||
const housingProvided =
|
||||
Boolean(firstDeduction.housing_designation?.trim()) ||
|
||||
(Boolean(firstDeduction.apartment_number?.trim()) && Boolean(firstDeduction.brf_org_number?.trim()))
|
||||
if (deductionLines.some((item) => item.deduction_type === 'rot') && !housingProvided) {
|
||||
throw new Error(
|
||||
'ROT lines need housing_designation (fastighetsbeteckning), or apartment_number + brf_org_number, on the first deduction line. ' +
|
||||
'gnubok_get_invoice returns them; pass them back or the update will fail at approval.',
|
||||
)
|
||||
}
|
||||
// The stored personnummer exists only as ciphertext and cannot be
|
||||
// supplied through MCP: without one on the invoice or on an
|
||||
// individual's kundkort, approval is guaranteed to fail.
|
||||
const personnummerAvailable =
|
||||
Boolean(invoice.deduction_personnummer_encrypted) ||
|
||||
(customer.customer_type === 'individual' && Boolean(customer.personal_number))
|
||||
if (!personnummerAvailable) {
|
||||
throw new Error(
|
||||
'ROT/RUT lines need a personnummer, which cannot be passed through MCP. ' +
|
||||
'Add it on the invoice in the web UI or on the customer card first, then retry.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot of the lines this replace deletes, for the approval
|
||||
// preview only (the executor re-reads at commit time). deduction_type
|
||||
// and the accrual period are shown so the approver can see a ROT/RUT
|
||||
// or periodisering removal; no personnummer column exists here and
|
||||
// the property columns are not needed for the preview.
|
||||
const { data: currentRows, error: currentError } = await supabase
|
||||
.from('invoice_items')
|
||||
.select('line_type, description, quantity, unit, unit_price, line_total, vat_rate, revenue_account, article_id, deduction_type, accrual_period_start, accrual_period_end')
|
||||
.eq('invoice_id', invoice.id)
|
||||
.order('sort_order', { ascending: true })
|
||||
if (currentError) throw new Error(`Database error: ${currentError.message}`)
|
||||
currentItems = (currentRows ?? []).map((row: Record<string, unknown>) => ({
|
||||
line_type: row.line_type ?? 'product',
|
||||
description: row.description,
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
unit_price: row.unit_price,
|
||||
line_total: row.line_total,
|
||||
vat_rate: row.vat_rate,
|
||||
revenue_account: row.revenue_account ?? null,
|
||||
article_id: row.article_id ?? null,
|
||||
deduction_type: row.deduction_type ?? null,
|
||||
accrual_period_start: row.accrual_period_start ?? null,
|
||||
accrual_period_end: row.accrual_period_end ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
// Resolve-don't-select (same pass as gnubok_create_invoice): parse the
|
||||
// default bag + each RESOLVED item's bag, then resolve codes AND names
|
||||
// against the registry in one go (zero queries when nothing is tagged).
|
||||
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
|
||||
const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
|
||||
supabase,
|
||||
companyId,
|
||||
[
|
||||
defaultDimensions,
|
||||
...(items ?? []).map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`)),
|
||||
],
|
||||
)
|
||||
const resolvedDefaultDimensions = resolvedDimBags[0]
|
||||
const stagedItems = items?.map((item, i) => {
|
||||
const { dimensions: _rawDimensions, ...rest } = item
|
||||
const bag = resolvedDimBags[i + 1]
|
||||
return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest
|
||||
})
|
||||
|
||||
const changes: Record<string, unknown> = { ...headerChanges }
|
||||
if (stagedItems) changes.items = stagedItems
|
||||
// The bag replaces wholesale, never merges: {} clears every tag.
|
||||
if (args.default_dimensions !== undefined) changes.default_dimensions = resolvedDefaultDimensions ?? {}
|
||||
|
||||
const parsed = UpdateInvoiceParamsSchema.safeParse({ invoice_id: invoiceId, changes })
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0]
|
||||
throw new Error(`Invalid invoice update: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`)
|
||||
}
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'update_invoice',
|
||||
`Uppdatera fakturautkast: ${customerName ?? invoice.invoice_number ?? invoice.id}`,
|
||||
parsed.data,
|
||||
@@ -16279,9 +16671,35 @@ export const tools: McpTool[] = [
|
||||
invoice_number: invoice.invoice_number ?? null,
|
||||
customer_name: customerName ?? null,
|
||||
status: invoice.status,
|
||||
currency,
|
||||
changes: parsed.data.changes,
|
||||
...(parsed.data.changes.items
|
||||
? { items_replace: true, item_count: parsed.data.changes.items.length }
|
||||
...(stagedItems
|
||||
? {
|
||||
items_replace: true,
|
||||
item_count: stagedItems.length,
|
||||
// Effective per-line booking, so the approver sees a revenue
|
||||
// account or VAT rate change instead of only a row count.
|
||||
// deduction_type and the accrual period ride along so a
|
||||
// ROT/RUT or periodisering change is visible too.
|
||||
items: stagedItems.map((item) => ({
|
||||
line_type: item.line_type ?? 'product',
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: roundOre(item.quantity * item.unit_price),
|
||||
vat_rate: item.line_type === 'text' ? 0 : (item.vat_rate ?? defaultVatRate),
|
||||
revenue_account: item.revenue_account ?? null,
|
||||
article_id: item.article_id ?? null,
|
||||
deduction_type: item.deduction_type ?? null,
|
||||
accrual_period_start: item.accrual_period_start ?? null,
|
||||
accrual_period_end: item.accrual_period_end ?? null,
|
||||
})),
|
||||
current_items: currentItems ?? [],
|
||||
subtotal: roundOre(subtotal),
|
||||
vat_amount: roundOre(vatAmount),
|
||||
total: roundOre(subtotal + vatAmount),
|
||||
}
|
||||
: {}),
|
||||
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
|
||||
},
|
||||
|
||||
@@ -139,6 +139,8 @@ Accounted currently creates PDF invoices and can send them by email. It does not
|
||||
- \`gnubok_credit_invoice\`: kreditfaktura (legal undo)
|
||||
- \`gnubok_convert_invoice\`: proforma → real invoice
|
||||
- \`gnubok_list_invoices\`: find existing invoices
|
||||
- \`gnubok_get_invoice\`: one invoice with its lines (article_id, revenue_account, vat_rate); read it before editing
|
||||
- \`gnubok_update_invoice\`: edit a draft; \`items\` is a FULL REPLACE, so pass every line back (with \`article_id\`) from \`gnubok_get_invoice\`
|
||||
`
|
||||
|
||||
export const invoicingRulesSkill: Skill = {
|
||||
|
||||
Reference in New Issue
Block a user