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
@@ -1298,6 +1298,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-27] New `unlinked_documents` category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-response_<ts>_pN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record<string, unknown>[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build.
|
||||
[2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change.
|
||||
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
|
||||
[2026-08-27] MCP update_invoice keeps FULL REPLACE item semantics (no preserve-on-omit) but its lines now accept article_id with the same prefill and default-set VAT adoption guard as create_invoice, plus the permitted-set VAT gate at staging; gnubok_get_invoice is the round-trip read surface (search-only: payload-size.bench.test.ts has no headroom and the update tool it serves is search-only too); the staged preview snapshots the lines being replaced and the effective vat_rate/revenue_account per new line so an approver sees a rebooking; the commit executor scope-checks staged article ids like create does (the FK proves existence only, and arg-guard never sees nested keys). Supersedes the 2026-08-17 'create_invoice only' entry (issue #1642). The web PATCH route's missing article scope check is a separate follow-up.
|
||||
[2026-08-27] MCP invoice round trip closed for non-article lines (skeptic review of #1993): gnubok_get_invoice now returns the ROT property columns (housing_designation/apartment_number/brf_org_number: property identifiers, never the personnummer ciphertext) and gnubok_update_invoice declares line_type/revenue_account/deduction/accrual fields on its item schema, exempts text rows from the quantity gates like CreateInvoiceItemSchema, and gates ROT/RUT completeness (arbetstyp/timmar, fastighetsbeteckning, personnummer availability) at staging so the failure reaches the agent, not the approver. gnubok_create_invoice only gained line_type + revenue_account: commitCreateInvoice drops deduction/accrual fields, so declaring them there would stage silent loss, and the tools/list ceiling (63.4K) has ~60 tokens of headroom left.
|
||||
[2026-08-27] WhatsApp company question (#1589): the root cause was Meta rejecting the reply-button payload synchronously with HTTP 400 #131009 "Duplicate button title" because the sender belonged to two same-named companies, one archived, not a client that refuses interactive messages; fix = archived-membership filter on every channel membership lookup (mirrors lib/supabase/middleware.ts), unique interactive titles (position suffix), a synchronous numbered-text fallback under the same M6 template id, and a drain of rows parked behind the now-dead question only when the sender resolves as 'single'. The async delivery-status fallback leg was deliberately not built (every observed failure was a synchronous 400), and the drain is not extended to default/pin resolution (those choices are still changeable, so an open question there is not dead).
|
||||
[2026-08-27] WhatsApp single-company drain (#1589) also clears the dead company question on the conversation (awaiting_company -> idle, company_options and the company pending_question deleted), guarded via updateConversation and checked against fresh state: with the question left in place every typed word became a company_retry re-offering the archived company, 'byt' was swallowed, and finalizeBurst could not ask about the drained receipts until the 48h TTL. Cleared whenever the sender resolves as 'single' and a company question exists, not only when rows were re-opened (the TTL sweep keeps company_options past the rows); other pending_question types stay untouched.
|
||||
[2026-08-27] WhatsApp unknown-sender quota RPC (check_and_increment_whatsapp_sender_quota) now fails OPEN to the throttled greeting path when the RPC errors or throws (#1599): the greeting throttle (1/h text, 10-min media burst, 3/day, itself fail-closed on read error) and the single-use link-code claim already bound outbound volume, whereas fail-closed silenced the first-touch linking moment on any transient DB hiccup. M2 (bad code) keeps its own small fail-closed throttle in that mode (badCodeThrottled: 1 per 10 min, 3/day per phone hash) since the quota no longer bounds it (review finding on #1991: withholding M2 left a bad code inside the M1 hour completely silent); when that throttle declines, the sender falls through to the throttled M1, which also says how to fetch a fresh code. Over-quota (ok:false) stays silent by design.
|
||||
|
||||
@@ -193,8 +193,66 @@ function CustomerPreview({ data }: { data: Record<string, unknown> }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One staged (or replaced) invoice line as the staging tools put it in
|
||||
// preview_data: create_invoice and update_invoice both carry the effective
|
||||
// vat_rate and any posting-account override, so a rebooking is visible to the
|
||||
// approver, not only the amount (issue #1642).
|
||||
interface PreviewInvoiceLine {
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price?: number
|
||||
line_total: number
|
||||
vat_rate?: number
|
||||
revenue_account?: string | null
|
||||
article_id?: string | null
|
||||
line_type?: string
|
||||
// ROT/RUT and periodisering markers: a full replace that drops one of
|
||||
// these must be visible to the approver, not only the amounts.
|
||||
deduction_type?: string | null
|
||||
accrual_period_start?: string | null
|
||||
accrual_period_end?: string | null
|
||||
}
|
||||
|
||||
function isPreviewInvoiceLines(value: unknown): value is PreviewInvoiceLine[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((row) => row != null && typeof row === 'object' && typeof (row as PreviewInvoiceLine).description === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function InvoiceLineRows({ items, currency }: { items: PreviewInvoiceLine[]; currency: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-xs">
|
||||
<span className="truncate mr-4">
|
||||
{item.description}
|
||||
{item.line_type === 'text' ? null : ` (${item.quantity} ${item.unit})`}
|
||||
{typeof item.vat_rate === 'number' && item.line_type !== 'text' && (
|
||||
<span className="text-muted-foreground"> · {item.vat_rate} % moms</span>
|
||||
)}
|
||||
{item.revenue_account && (
|
||||
<span className="text-muted-foreground font-mono"> · {item.revenue_account}</span>
|
||||
)}
|
||||
{item.deduction_type && (
|
||||
<span className="text-muted-foreground"> · {item.deduction_type === 'rot' ? 'ROT-avdrag' : 'RUT-avdrag'}</span>
|
||||
)}
|
||||
{item.accrual_period_start && item.accrual_period_end && (
|
||||
<span className="text-muted-foreground"> · periodiseras {item.accrual_period_start} till {item.accrual_period_end}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono tabular-nums whitespace-nowrap">
|
||||
{item.line_type === 'text' ? '' : money(item.line_total, currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InvoicePreview({ data }: { data: Record<string, unknown> }) {
|
||||
const items = (data.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate: number }>) || []
|
||||
const items = isPreviewInvoiceLines(data.items) ? data.items : []
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
@@ -207,15 +265,8 @@ function InvoicePreview({ data }: { data: Record<string, unknown> }) {
|
||||
<span>{String(data.due_date ?? '')}</span>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<div className="border-t pt-2 space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-xs">
|
||||
<span className="truncate mr-4">{item.description} ({item.quantity} {item.unit})</span>
|
||||
<span className="font-mono tabular-nums whitespace-nowrap">
|
||||
{formatCurrency(item.line_total, (data.currency as string) || 'SEK')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t pt-2">
|
||||
<InvoiceLineRows items={items} currency={(data.currency as string) || 'SEK'} />
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
@@ -230,6 +281,82 @@ function InvoicePreview({ data }: { data: Record<string, unknown> }) {
|
||||
)
|
||||
}
|
||||
|
||||
const UPDATE_INVOICE_FIELD_LABELS: Record<string, string> = {
|
||||
notes: 'Anteckningar',
|
||||
invoice_date: 'Fakturadatum',
|
||||
due_date: 'Förfallodatum',
|
||||
delivery_date: 'Leveransdatum',
|
||||
your_reference: 'Er referens',
|
||||
our_reference: 'Vår referens',
|
||||
}
|
||||
|
||||
function UpdateInvoicePreview({ data }: { data: Record<string, unknown> }) {
|
||||
const currency = (data.currency as string) || 'SEK'
|
||||
const changes = (data.changes && typeof data.changes === 'object' ? (data.changes as Record<string, unknown>) : {})
|
||||
const headerEntries = Object.entries(changes).filter(
|
||||
([key, value]) => key !== 'items' && key !== 'default_dimensions' && value !== undefined,
|
||||
)
|
||||
const hasDimensionChange = 'default_dimensions' in changes
|
||||
const dimensionBag = changes.default_dimensions as Record<string, string> | undefined
|
||||
const newItems = isPreviewInvoiceLines(data.items) ? data.items : null
|
||||
const currentItems = isPreviewInvoiceLines(data.current_items) ? data.current_items : null
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Kund</span>
|
||||
<span>{String(data.customer_name ?? '')}</span>
|
||||
<span className="text-muted-foreground">Faktura</span>
|
||||
<span>{data.invoice_number ? String(data.invoice_number) : 'utkast'}</span>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<Fragment key={key}>
|
||||
<span className="text-muted-foreground">{UPDATE_INVOICE_FIELD_LABELS[key] ?? key.replace(/_/g, ' ')}</span>
|
||||
{/* null is an explicit clear (delivery_date: null), not a missing value */}
|
||||
<span>{value === null ? 'rensas' : renderPrimitive(value)}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
{hasDimensionChange && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Dimensioner</span>
|
||||
<span className="font-mono text-xs">
|
||||
{dimensionBag && Object.keys(dimensionBag).length > 0
|
||||
? Object.entries(dimensionBag).map(([dim, code]) => `${dim}: ${code}`).join(', ')
|
||||
: 'rensas'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Full replace: show what goes away next to what comes in, so a
|
||||
quantity fix that also moves revenue off the article's account
|
||||
(3041 to 3001) or changes the VAT rate is visible before approval. */}
|
||||
{currentItems && (
|
||||
<div className="border-t pt-2 space-y-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{currentItems.length > 0 ? 'Nuvarande rader (ersätts)' : 'Nuvarande rader: inga'}
|
||||
</div>
|
||||
{currentItems.length > 0 && <InvoiceLineRows items={currentItems} currency={currency} />}
|
||||
</div>
|
||||
)}
|
||||
{newItems && (
|
||||
<div className="border-t pt-2 space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Nya rader</div>
|
||||
<InvoiceLineRows items={newItems} currency={currency} />
|
||||
</div>
|
||||
)}
|
||||
{newItems && typeof data.total === 'number' && (
|
||||
<div className="border-t pt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Netto</span>
|
||||
<span className="tabular-nums text-right">{money(data.subtotal, currency)}</span>
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span className="tabular-nums text-right">{money(data.vat_amount, currency)}</span>
|
||||
<span className="font-medium">Totalt</span>
|
||||
<span className="tabular-nums font-medium text-right">{money(data.total, currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTransactionPreview({ data }: { data: Record<string, unknown> }) {
|
||||
const currency = (data.currency as string) || 'SEK'
|
||||
|
||||
@@ -571,6 +698,8 @@ export function OperationPreview({ op }: { op: OperationPreviewInput }) {
|
||||
return <CustomerPreview data={op.preview_data} />
|
||||
case 'create_invoice':
|
||||
return <InvoicePreview data={op.preview_data} />
|
||||
case 'update_invoice':
|
||||
return <UpdateInvoicePreview data={op.preview_data} />
|
||||
case 'create_transaction':
|
||||
return <CreateTransactionPreview data={op.preview_data} />
|
||||
case 'create_voucher':
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -246,6 +246,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_update_article: 'articles:write',
|
||||
// Invoices
|
||||
gnubok_list_invoices: 'invoices:read',
|
||||
gnubok_get_invoice: 'invoices:read',
|
||||
gnubok_get_invoice_deliveries: 'invoices:read',
|
||||
gnubok_create_invoice: 'invoices:write',
|
||||
gnubok_update_invoice: 'invoices:write',
|
||||
|
||||
@@ -62,6 +62,19 @@ const NEW_ITEMS = [
|
||||
{ description: 'Konsultation', quantity: 2, unit: 'tim', unit_price: 1000, vat_rate: 25 },
|
||||
]
|
||||
|
||||
const ARTICLE_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const ARTICLE_ITEMS = [
|
||||
{
|
||||
description: 'Konsulttimme',
|
||||
quantity: 3,
|
||||
unit: 'tim',
|
||||
unit_price: 1200,
|
||||
vat_rate: 25,
|
||||
article_id: ARTICLE_ID,
|
||||
revenue_account: '3041',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
@@ -234,6 +247,65 @@ describe('commitPendingOperation: update_invoice', () => {
|
||||
expect(result.http_status).toBe(400)
|
||||
})
|
||||
|
||||
it('keeps article_id and revenue_account on replaced items (issue #1642)', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-invoice-1' } }) // claim pending -> committing
|
||||
enqueue({ data: existingDraft() }) // invoices: existing draft
|
||||
enqueue({ data: makeCustomer({ id: CUSTOMER_ID }) }) // customers
|
||||
enqueue({ data: [{ id: ARTICLE_ID }] }) // articles: company-scope gate
|
||||
enqueue({ data: { vat_registered: true } }) // company_settings (builder VAT gate)
|
||||
enqueue({ data: [{ account_number: '3041' }] }) // chart_of_accounts: override account
|
||||
enqueue({ data: [{ id: INVOICE_ID }] }) // invoices update (draft-guarded)
|
||||
enqueue({ data: [] }) // invoice_items snapshot (replaceInvoiceItems)
|
||||
enqueue({ data: null }) // invoice_items delete
|
||||
enqueue({ data: null }) // invoice_items insert
|
||||
enqueue({ data: null }) // pending_operations final status update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp({ invoice_id: INVOICE_ID, changes: { items: ARTICLE_ITEMS } }),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ subtotal: 3600, vat_amount: 900, total: 4500, items_replaced: true })
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(4, 'articles')
|
||||
// The rewritten line keeps its article linkage and the 3041 override:
|
||||
// the quantity fix must not rebook revenue to the VAT-derived default.
|
||||
const inserted = findCall('invoice_items', 'insert')?.[0] as Array<Record<string, unknown>>
|
||||
expect(inserted).toHaveLength(1)
|
||||
expect(inserted[0]).toMatchObject({
|
||||
invoice_id: INVOICE_ID,
|
||||
article_id: ARTICLE_ID,
|
||||
revenue_account: '3041',
|
||||
quantity: 3,
|
||||
vat_rate: 25,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a staged article outside the company before writing anything', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-invoice-1' } }) // claim
|
||||
enqueue({ data: existingDraft() }) // invoices
|
||||
enqueue({ data: makeCustomer({ id: CUSTOMER_ID }) }) // customers
|
||||
enqueue({ data: [] }) // articles: no company-scoped hit
|
||||
enqueue({ data: null }) // final status update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp({ invoice_id: INVOICE_ID, changes: { items: ARTICLE_ITEMS } }),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/finns inte i företaget/)
|
||||
expect(findCall('invoices', 'update')).toBeUndefined()
|
||||
expect(findCall('invoice_items', 'insert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects tampered staged params before reading the invoice', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-invoice-1' } })
|
||||
|
||||
@@ -1590,10 +1590,10 @@ async function commitCreateInvoice(
|
||||
// (resolveDimensionBags in the MCP tool); coerce is the drift/tamper gate.
|
||||
const defaultDimensions = coerceDimensionsBag(params.default_dimensions)
|
||||
|
||||
// Free-text rows carry no amounts and never book. The MCP staging tool does
|
||||
// not accept line_type today, but the totals math must stay identical to
|
||||
// app/api/invoices/route.ts, which excludes text rows from subtotal, VAT,
|
||||
// and the mixed-rate detection.
|
||||
// Free-text rows carry no amounts and never book. The MCP staging tool
|
||||
// accepts line_type 'text' (normalized to zeroed amounts at staging), and
|
||||
// the totals math must stay identical to app/api/invoices/route.ts, which
|
||||
// excludes text rows from subtotal, VAT, and the mixed-rate detection.
|
||||
const billableItems = items.filter((item) => item.line_type !== 'text')
|
||||
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
@@ -1922,6 +1922,29 @@ async function commitUpdateInvoice(
|
||||
return { error: 'Customer not found: they may have been deleted.', status: 404 }
|
||||
}
|
||||
|
||||
// Drift/tamper gate for staged article references, same as
|
||||
// commitCreateInvoice: the FK on invoice_items.article_id proves the article
|
||||
// exists, not that it belongs to THIS company, and the top-level arg guard
|
||||
// never sees a nested items[].article_id.
|
||||
if (changes.items) {
|
||||
const stagedArticleIds = Array.from(
|
||||
new Set(changes.items.map((item) => item.article_id).filter((a): a is string => !!a)),
|
||||
)
|
||||
if (stagedArticleIds.length > 0) {
|
||||
const { data: articleRows, error: articleError } = await supabase
|
||||
.from('articles')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', stagedArticleIds)
|
||||
if (articleError) return { error: articleError.message, status: 500 }
|
||||
const foundArticleIds = new Set((articleRows ?? []).map((a: { id: string }) => a.id))
|
||||
const missingArticleId = stagedArticleIds.find((a) => !foundArticleIds.has(a))
|
||||
if (missingArticleId) {
|
||||
return { error: `Artikel ${missingArticleId} finns inte i företaget`, status: 400 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Effective line set: FULL REPLACE when staged, otherwise the current rows
|
||||
// fed back through the builder unchanged.
|
||||
let itemsInput: InvoiceWriteItemInput[]
|
||||
|
||||
Reference in New Issue
Block a user