Files
accounted/lib/pending-operations/__tests__/update-invoice-executor.test.ts
ea4da0eb07 fix(invoices): scope-check article ids in buildInvoiceWriteData so every invoice write refuses a foreign company's article (part of #2059) (#2339)
Part of #2059 (Part 2, the hardening bug).

The FK on invoice_items.article_id proves the article exists, not that it
belongs to the writing company: FK validation ignores RLS, and the v1 routes
run on the service-role client with no RLS at all. Only the two MCP commit
executors checked tenancy; the cookie POST/PATCH, v1 POST/PATCH, webshop and
sales-order writers passed items[].article_id straight through the builder.

Move the check to the one point every writer converges on: buildInvoiceWriteData
collects the distinct article ids from product lines, runs one select scoped
on company_id, and refuses with the new INVOICE_CREATE_ARTICLE_INVALID (400,
Swedish message via the structured-error registry) on any miss. The MCP
executor checks stay as the tamper gate for staged rows.

Tests: builder unit cases (miss refused with details, dedupe + happy path,
no article ids means no query, DB error surfaces as dbError), cookie PATCH
and v1 POST refusal cases, and the existing v1 persist + MCP update tests now
answer the builder's scoped select.


Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:39:08 +02:00

426 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PendingOperation } from '@/types'
import { createQueuedMockSupabase, makeCustomer } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import { commitPendingOperation } from '../commit'
const INVOICE_ID = '22222222-2222-4222-8222-222222222222'
const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111'
const SALES_ORDER_ITEM_ID = 'd1000000-0000-4000-8000-000000000001'
function makePendingOp(params: Record<string, unknown>): PendingOperation {
return {
id: 'op-invoice-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'update_invoice',
status: 'pending',
title: 'Update invoice draft',
params,
preview_data: {},
result_data: null,
actor_type: 'api_key',
actor_id: 'key-1',
actor_label: 'Test key',
risk_level: 'medium',
agent_metadata: null,
rejection_category: null,
rejection_reason: null,
created_at: '2026-07-27T00:00:00Z',
resolved_at: null,
updated_at: '2026-07-27T00:00:00Z',
} as PendingOperation
}
function existingDraft(overrides: Record<string, unknown> = {}) {
return {
id: INVOICE_ID,
status: 'draft',
invoice_number: null,
journal_entry_id: null,
is_self_billed: false,
credited_invoice_id: null,
customer_id: CUSTOMER_ID,
document_type: 'invoice',
invoice_date: '2026-07-01',
due_date: '2026-07-31',
delivery_date: null,
currency: 'SEK',
your_reference: null,
our_reference: null,
notes: null,
payment_link_url: null,
payment_link_auto: true,
ore_rounding: null,
default_dimensions: {},
deduction_personnummer_encrypted: null,
deduction_personnummer_last4: null,
...overrides,
}
}
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()
})
describe('commitPendingOperation: update_invoice', () => {
it('replaces the items, recomputes VAT/totals, and returns qualified ids', async () => {
const { supabase, enqueue } = 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: { vat_registered: true } }) // company_settings (builder VAT gate)
enqueue({ data: [{ id: INVOICE_ID }] }) // invoices update (draft-guarded)
enqueue({ data: [] }) // invoice_items snapshot (replaceInvoiceItems, no prior rows)
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: NEW_ITEMS } }),
)
expect(result.status).toBe('committed')
// Money math: 2 x 1000 = 2000 net, 25% VAT = 500, total 2500.
expect(result.data).toMatchObject({
invoice_id: INVOICE_ID,
subtotal: 2000,
vat_amount: 500,
total: 2500,
item_count: 1,
items_replaced: true,
})
expect(supabase.from).toHaveBeenNthCalledWith(2, 'invoices')
// snapshot, delete, insert
expect(supabase.from).toHaveBeenNthCalledWith(6, 'invoice_items')
expect(supabase.from).toHaveBeenNthCalledWith(7, 'invoice_items')
expect(supabase.from).toHaveBeenNthCalledWith(8, 'invoice_items')
})
it('keeps the existing lines on a header-only edit (no full replace staged)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-invoice-1' } }) // claim
enqueue({ data: existingDraft() }) // invoices: existing draft
enqueue({ data: makeCustomer({ id: CUSTOMER_ID }) }) // customers
enqueue({
// invoice_items: current rows fed back through the builder
data: [
{
line_type: 'product',
description: 'Befintlig rad',
quantity: 1,
unit: 'st',
unit_price: 100,
vat_rate: 25,
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: {},
},
],
})
enqueue({ data: { vat_registered: true } }) // company_settings
enqueue({ data: [{ id: INVOICE_ID }] }) // invoices update
enqueue({ data: [] }) // invoice_items snapshot (replaceInvoiceItems, no order links)
enqueue({ data: null }) // invoice_items delete
enqueue({ data: null }) // invoice_items insert
enqueue({ data: null }) // final status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID, changes: { notes: 'Uppdaterad anteckning' } }),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
invoice_id: INVOICE_ID,
subtotal: 100,
vat_amount: 25,
total: 125,
item_count: 1,
items_replaced: false,
})
})
it('re-checks the editable-draft gate at commit time (sent between staging and approval)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-invoice-1' } }) // claim
enqueue({ data: existingDraft({ status: 'sent', invoice_number: '2026-0042' }) })
enqueue({ data: null }) // final status update (auto-reject)
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID, changes: { notes: 'x' } }),
)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(409)
// Only claim + invoice fetch + status update: the write never ran.
expect(supabase.from).toHaveBeenCalledTimes(3)
})
it('re-checks the gate for a draft that gained a verifikat', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-invoice-1' } })
enqueue({ data: existingDraft({ journal_entry_id: 'je-1' }) })
enqueue({ data: null })
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID, changes: { notes: 'x' } }),
)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(409)
})
it('auto-rejects when the invoice no longer exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-invoice-1' } })
enqueue({ data: null }) // invoices: gone
enqueue({ data: null }) // final status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID, changes: { notes: 'x' } }),
)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(404)
})
it('enforces the VAT rate gate on replaced items', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-invoice-1' } }) // claim
enqueue({ data: existingDraft() }) // invoices
enqueue({ data: makeCustomer({ id: CUSTOMER_ID }) }) // customers
enqueue({ data: { vat_registered: true } }) // company_settings
enqueue({ data: null }) // final status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
invoice_id: INVOICE_ID,
// 19% is not a Swedish VAT rate: the builder must refuse it.
changes: { items: [{ description: 'Rad', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 19 }] },
}),
)
expect(result.status).toBe('failed')
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: ARTICLE_ID }] }) // articles: builder company-scope check (#2059)
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('auto-rejects with INVOICE_UPDATE_DROPS_ORDER_LINK when replaced lines drop a kundorder link', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-invoice-1' } }) // claim
enqueue({ data: existingDraft() }) // invoices: existing draft (created from an order)
enqueue({ data: makeCustomer({ id: CUSTOMER_ID }) }) // customers
enqueue({ data: { vat_registered: true } }) // company_settings
enqueue({ data: [{ id: INVOICE_ID }] }) // invoices update (header)
enqueue({
// invoice_items snapshot (replaceInvoiceItems): the stored row is linked
// to an order line; the staged items carry no sales_order_item_id.
data: [{ id: 'item-old-1', invoice_id: INVOICE_ID, sales_order_item_id: SALES_ORDER_ITEM_ID, description: 'Orderrad' }],
})
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: NEW_ITEMS } }),
)
// 409 from the executor: auto-rejected with the structured code so the
// approver sees WHY, same as the cookie and v1 PATCH routes.
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(409)
expect(result.code).toBe('INVOICE_UPDATE_DROPS_ORDER_LINK')
expect(result.error).toMatch(/kundorder/)
// The guard fires before the delete: the lines are untouched.
expect(findCall('invoice_items', 'delete')).toBeUndefined()
expect(findCall('invoice_items', 'insert')).toBeUndefined()
})
it('keeps the kundorder link on a header-only edit (re-fetch carries sales_order_item_id)', async () => {
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-invoice-1' } }) // claim
enqueue({ data: existingDraft() }) // invoices
enqueue({ data: makeCustomer({ id: CUSTOMER_ID }) }) // customers
enqueue({
// invoice_items re-fetch for the header-only edit
data: [
{
line_type: 'product',
description: 'Orderrad',
quantity: 4,
unit: 'h',
unit_price: 100,
discount_percent: null,
vat_rate: 25,
article_id: null,
revenue_account: null,
sales_order_item_id: SALES_ORDER_ITEM_ID,
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: {},
},
],
})
enqueue({ data: { vat_registered: true } }) // company_settings
enqueue({ data: [{ id: INVOICE_ID }] }) // invoices update
enqueue({ data: [{ id: 'item-old-1', sales_order_item_id: SALES_ORDER_ITEM_ID }] }) // snapshot
enqueue({ data: null }) // invoice_items delete
enqueue({ data: null }) // invoice_items insert
enqueue({ data: null }) // final status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ invoice_id: INVOICE_ID, changes: { notes: 'Ny anteckning' } }),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ items_replaced: false, item_count: 1 })
// The narrow re-fetch must include the link column, or the guard would
// refuse every header-only edit of an order-created draft.
const refetchColumns = findCalls('invoice_items', 'select')[0][0] as string
expect(refetchColumns.split(',').map((c) => c.trim())).toContain('sales_order_item_id')
const inserted = findCall('invoice_items', 'insert')![0] as Array<Record<string, unknown>>
expect(inserted).toHaveLength(1)
expect(inserted[0]).toMatchObject({ invoice_id: INVOICE_ID, sales_order_item_id: SALES_ORDER_ITEM_ID })
})
it('rejects tampered staged params before reading the invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-invoice-1' } })
enqueue({ data: null }) // final status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
invoice_id: INVOICE_ID,
changes: { status: 'paid', notes: 'x' },
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/unrecognized key|invalid/i)
expect(supabase.from).toHaveBeenCalledTimes(2)
})
})