Files
accounted/extensions/general/mcp-server/__tests__/update-invoice.test.ts
T
MattssonandClaude Fable 5 f216a60bf8 feat(invoices): per-line percentage discount and separate fakturamarkning (#2084)
* feat(invoices): per-line percentage discount and separate fakturamarkning

User request: rabatt i procent per artikelrad, and a marking field
separate from Er referens.

- invoice_items.discount_percent (0-100, default 0): line_total and
  vat_amount are stored NET of the discount. Shared exact-ore math in
  lib/invoices/line-amounts.ts (gross, discount, net) used by the web
  builder, staged-operation commit, editor preview, PDF, and Peppol.
  Undiscounted lines keep the legacy unrounded qty*price byte-identical.
- ROT/RUT deduction computes on the discounted net line total.
- invoices.invoice_marking: printed on the PDF next to the references
  and mapped to Peppol BT-10 BuyerReference (marking wins over
  your_reference; either satisfies the BT-10 requirement).
- Peppol renders the discount as a BG-27 line AllowanceCharge
  (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount).
- Editor: "Lagg till rabatt" in the row menu (same reveal pattern as
  ROT/RUT), Markning row next to Er referens, forval chip, review
  dialog shows discounts and marking.
- Plumbed through v1 REST projections, MCP create/get/update invoice
  tools, pending-operations update path, and copy-invoice (discount
  copied; marking deliberately not, it is recipient-specific).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52

* fix(invoices): carry discount_percent through every deduction, credit, convert and preview path

Skeptic + CI findings on the discount/marking feature, one pass:

- generateRotRutLines and propose-send-lines now pass discount_percent
  into computeDeduction: the send/credit/cash verifikat booked 1513 on
  the GROSS line while deduction_total, the PDF and the Skatteverket
  claim carried the net, stranding the difference on 1513 and pushing
  1510 negative once the customer paid. Test pins 1513=3000/1510=7000
  for a 20%-discounted 10 000 kr ROT line.
- preview-pdf route accepts discount_percent (net totals + net-based
  deduction) and invoice_marking; the editor now sends the marking, so
  the preview equals the invoice it becomes.
- Credit notes carry discount_percent (buildCreditNoteItem, v1 credit
  route select+insert, MCP credit executor) and invoice_marking, so the
  kreditfaktura face arithmetic multiplies out and shows the Rabatt
  column (ML 17 kap 24 §).
- Proforma->invoice convert copies discount_percent + invoice_marking:
  the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH
  and lost the rebate on the next builder pass.
- Editor hides the discount menu in self-billed mode (the self-billed
  wire shape has no discount; previewed net would book gross).
- MCP staging and commitCreateInvoice reject a non-number
  discount_percent (a string coerced past the range check but was
  ignored by the totals math and still stored).
- Regenerated skills/accounted-api (apiskill:check CI failure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:34:06 +02:00

859 lines
29 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
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> = {}) {
return {
id: INVOICE_ID,
invoice_number: null,
status: 'draft',
document_type: 'invoice',
journal_entry_id: null,
is_self_billed: false,
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()
expect(tool().inputSchema.additionalProperties).toBe(false)
expect(tool().annotations.readOnlyHint).toBe(false)
expect(tool().annotations.destructiveHint).toBe(false)
expect(tool().annotations.idempotentHint).toBe(true)
expect(tool().catalogVisibility).toBe('search')
expect(TOOL_SCOPE_MAP.gnubok_update_invoice).toBe('invoices:write')
expect(OPERATION_RISK_TIERS.update_invoice).toBe('medium')
})
it('returns the staged-operation envelope (staged completion signal)', () => {
const schema = tool().outputSchema as { properties?: Record<string, unknown>; required?: string[] }
expect(schema?.properties?.staged).toBeDefined()
expect(schema?.required).toContain('staged')
})
it('keeps its description within the 280-char budget and declares staging', () => {
expect(tool().description.length).toBeLessThanOrEqual(280)
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']) {
expect(properties, `must not expose ${forbidden}`).not.toHaveProperty(forbidden)
}
})
})
describe('gnubok_update_invoice: validation and staging', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('requires invoice_id', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute({ notes: 'x' }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/invoice_id/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('requires at least one changed field', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{ invoice_id: INVOICE_ID, dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/at least one/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects an empty items array (full-replace needs at least one line)', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{ invoice_id: INVOICE_ID, items: [] },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/non-empty/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects an item without a positive quantity before querying', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{
invoice_id: INVOICE_ID,
items: [{ description: 'Konsultation', quantity: 0, unit: 'tim', unit_price: 1000 }],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/quantity/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('fails when the invoice is outside the selected company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
tool().execute(
{ invoice_id: INVOICE_ID, notes: 'Ny anteckning' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/not found/i)
})
it.each([
['sent invoice', { status: 'sent' }],
['paid invoice', { status: 'paid' }],
['draft with a posted verifikat', { journal_entry_id: 'je-1' }],
['self-billed draft', { is_self_billed: true }],
['credit-note draft', { credited_invoice_id: '33333333-3333-4333-8333-333333333333' }],
])('refuses a %s at staging time', async (_label, overrides) => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: draftInvoice(overrides) })
await expect(
tool().execute(
{ invoice_id: INVOICE_ID, notes: 'Ny anteckning' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/not an editable draft/i)
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() })
const result = (await tool().execute(
{ invoice_id: INVOICE_ID, due_date: '2026-08-31', dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; dry_run?: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
expect(result.preview).toMatchObject({
invoice_id: INVOICE_ID,
customer_name: 'Acme AB',
changes: { due_date: '2026-08-31' },
})
expect(supabase.from).toHaveBeenCalledTimes(1)
})
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' } })
const result = (await tool().execute(
{ invoice_id: INVOICE_ID, notes: 'Uppdaterad anteckning' },
'company-1',
'user-1',
supabase as never,
)) 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 effective booking and the lines being replaced', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueItemsEdit(enqueue, CUSTOMER, null)
const result = (await tool().execute(
{
invoice_id: INVOICE_ID,
items: [
{ 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)
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,
discount_percent: 0,
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',
})
})
})