Files
accounted/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts
T
Jakob WennbergandClaude Fable 5 755e0f7e47 feat(dimensions): PR7 producers — auto-tagged documents (invoices, supplier invoices, bulk-book, templates, MCP) (#868)
* feat(dimensions): PR7 producers — invoices/supplier invoices carry dims, generators propagate, BulkBook + templates + MCP bags

Source documents now carry dimension tags and every entry generator
propagates them onto journal lines (dev_docs/dimensions_implementation_plan.md PR7):

- invoices/supplier_invoices.default_dimensions + per-item dimensions
  (migration 20260702200000; jsonb DEFAULT '{}' + object CHECK)
- invoice-entries: issuance/payment/cash/credit propagate — item bags merge
  over the invoice default per revenue line (account+bag aggregation
  identity), payment vouchers re-propagate the linked invoice's bag onto
  every leg incl. FX result lines; ROT/RUT 1513 carries the item bag
- supplier-invoice-entries: registration/payment/cash/privately-paid/credit
  propagate with the same merge rules (expense buckets keyed account+bag)
- bulk_book_transactions RPC persists per-line bags + derives
  cost_center/project mirrors in SQL (migration 20260702201000; malformed
  bags rejected with BULK_BOOK_INVALID_DIMENSIONS); route merges the header
  default into template/manual lines
- counterparty templates: LinePatternEntry.dimensions learned from SIE
  voucher history (kept only when every occurrence agrees), applied to
  business lines on booking; QuickReviewDialog shows a dims badge
- categorize: staged dimensions bag tags business lines only (bank/VAT
  untagged); credit/convert/inbox copy paths carry bags forward
- propose-payment/send-lines stamp the invoice default so the editable
  payment grid books what the preview shows; mark-paid override lines
  accept dimensions
- UI: InvoiceEditor + NewSupplierInvoiceForm header KS/Projekt pair with
  per-row override; BulkBookDialog header default pair (both tabs)
- MCP: default_dimensions/items[].dimensions on create_invoice +
  create_supplier_invoice_from_inbox, dimensions on categorize_transaction,
  per-line bags on bulk_book_transactions — resolve-don't-select via the
  shared registry helpers, resolutions echoed

32 new propagation unit tests + 4 pg-real tests for the RPC migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: use roundOre in new dims rounding assertions (ratchet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: copy dimension bag per payment line, document dimensionsBagKey normalization contract (review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:01 +02:00

721 lines
25 KiB
TypeScript

/**
* Unit tests for commitCreateSupplierInvoiceFromInbox — driven through the
* public commitPendingOperation dispatcher (the executor itself is module-
* private).
*
* Covers: happy path (accrual), idempotent re-commit on already-linked inbox,
* missing inbox / supplier, duplicate invoice number, cash method skipping
* the registration JE, and items-insert rollback of the parent invoice.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase, makeJournalEntry, makeSupplierInvoice } from '@/tests/helpers'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', async () => {
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/supplier-invoice-entries')>(
'@/lib/bookkeeping/supplier-invoice-entries'
)
return {
...actual,
createSupplierInvoiceRegistrationEntry: vi.fn(),
}
})
vi.mock('@/lib/core/documents/document-service', async () => {
const actual = await vi.importActual<typeof import('@/lib/core/documents/document-service')>(
'@/lib/core/documents/document-service'
)
return {
...actual,
linkToJournalEntry: vi.fn(),
}
})
import { commitPendingOperation } from '../commit'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
function makePendingOp(overrides: Partial<PendingOperation> = {}): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'create_supplier_invoice_from_inbox',
status: 'pending',
title: 'test',
params: {
inbox_item_id: 'inbox-1',
supplier_id: 'supplier-1',
document_id: 'doc-1',
supplier_invoice_number: 'INV-100',
invoice_date: '2026-05-15',
due_date: '2026-06-14',
currency: 'SEK',
exchange_rate: null,
vat_treatment: 'standard_25',
subtotal: 1000,
vat_amount: 250,
total: 1250,
notes: null,
items: [
{
line_number: 1,
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '6530',
vat_rate: 0.25,
vat_amount: 250,
},
],
},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-05-15T00:00:00Z',
resolved_at: null,
updated_at: '2026-05-15T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: create_supplier_invoice_from_inbox', () => {
it('happy path (accrual): inserts invoice + items + JE, links document, marks inbox confirmed', async () => {
vi.mocked(createSupplierInvoiceRegistrationEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-100', voucher_number: 7, voucher_series: 'L' })
)
vi.mocked(linkToJournalEntry).mockResolvedValueOnce({
id: 'doc-1',
journal_entry_id: 'je-100',
} as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // dispatcher CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
}) // inbox fetch
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
}) // supplier fetch
enqueue({ data: 42, error: null }) // get_next_arrival_number RPC
enqueue({
data: makeSupplierInvoice({ id: 'inv-1', supplier_invoice_number: 'INV-100' }),
error: null,
}) // supplier_invoices insert
enqueue({ data: null, error: null }) // supplier_invoice_items insert
enqueue({ data: { accounting_method: 'accrual' }, error: null }) // company_settings
enqueue({ data: null, error: null }) // supplier_invoices update with JE id
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
supplier_invoice_id: 'inv-1',
inbox_item_id: 'inbox-1',
registration_journal_entry_id: 'je-100',
arrival_number: 42,
})
expect(createSupplierInvoiceRegistrationEntry).toHaveBeenCalledTimes(1)
expect(linkToJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'doc-1',
'je-100',
)
})
it('idempotency: re-fired commit on an already-converted inbox returns the existing invoice without rework', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: 'inv-existing', status: 'confirmed' },
error: null,
}) // inbox already linked
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
supplier_invoice_id: 'inv-existing',
idempotent: true,
})
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('returns 404 when the inbox item does not exist', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: { message: 'not found' } }) // inbox fetch — empty
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(404)
expect(result.error).toMatch(/Inbox item not found/)
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
it('returns 404 when the supplier no longer exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({ data: null, error: { message: 'not found' } }) // supplier fetch — empty
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(404)
expect(result.error).toMatch(/Supplier not found/)
})
it('returns 409 with Swedish message on duplicate invoice number (PG 23505)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
})
enqueue({ data: 42, error: null }) // arrival number
enqueue({
data: null,
error: { code: '23505', message: 'duplicate key value violates unique constraint' },
}) // invoice insert fails
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.error).toMatch(/finns redan registrerad/)
})
it('skips the registration JE and document link for cash-method companies', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
})
enqueue({ data: 42, error: null }) // arrival number
enqueue({
data: makeSupplierInvoice({ id: 'inv-cash', supplier_invoice_number: 'INV-100' }),
error: null,
}) // invoice insert
enqueue({ data: null, error: null }) // items insert
enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings → cash
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
supplier_invoice_id: 'inv-cash',
registration_journal_entry_id: null,
})
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('persists document_id on the supplier_invoices row (so it can carry to the payment verifikat under cash method)', async () => {
let capturedInsert: Record<string, unknown> | null = null
const { supabase, enqueue } = createQueuedMockSupabase()
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
if (table === 'supplier_invoices') {
return {
insert: (row: Record<string, unknown>) => {
capturedInsert = row
return {
select: () => ({
single: () =>
Promise.resolve({
data: makeSupplierInvoice({ id: 'inv-cash', supplier_invoice_number: 'INV-100' }),
error: null,
}),
}),
}
},
}
}
return (originalFrom as (t: string) => unknown)(table)
})
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
}) // inbox fetch
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
}) // supplier fetch
enqueue({ data: 42, error: null }) // arrival number
// supplier_invoices insert handled by the override above
enqueue({ data: null, error: null }) // items insert
enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings → cash
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('committed')
// The inbox document id from the staged params must land on the row so
// mark-paid can attach it to the kontantmetoden cash verifikat.
expect(capturedInsert).toMatchObject({ document_id: 'doc-1' })
})
it('rolls back the parent invoice when item insert fails (no orphan supplier_invoices row)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
})
enqueue({ data: 42, error: null })
enqueue({
data: makeSupplierInvoice({ id: 'inv-doomed', supplier_invoice_number: 'INV-100' }),
error: null,
})
enqueue({ data: null, error: { message: 'items constraint violation' } }) // items insert fails
enqueue({ data: null, error: null }) // rollback delete
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(500)
expect(result.error).toMatch(/items/)
// JE should never have been attempted given items failed
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
it('returns 400 when subtotal/vat_amount/total are non-finite (tampered staged params)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
params: {
inbox_item_id: 'inbox-1',
supplier_id: 'supplier-1',
supplier_invoice_number: 'INV-100',
invoice_date: '2026-05-15',
currency: 'SEK',
// String values where numbers are required — Number(x) || 0 used to
// silently produce a zero-value invoice.
subtotal: 'not a number',
vat_amount: null,
total: undefined,
items: [{ description: 'x', line_total: 100, account_number: '6530' }],
},
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/finite numbers/)
})
it('zeroes per-line VAT when vat_treatment is reverse_charge (RC invariant)', async () => {
vi.mocked(createSupplierInvoiceRegistrationEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-rc', voucher_number: 9 })
)
let capturedItems: unknown = null
const { supabase, enqueue } = createQueuedMockSupabase()
// We intercept the supplier_invoice_items insert by overriding the .from
// handler on a per-table basis.
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
if (table === 'supplier_invoice_items') {
return {
insert: (rows: unknown) => {
capturedItems = rows
return Promise.resolve({ data: null, error: null })
},
}
}
return (originalFrom as (t: string) => unknown)(table)
})
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'EU Vendor SA', supplier_type: 'eu_business' },
error: null,
})
enqueue({ data: 50, error: null }) // arrival number
enqueue({
data: makeSupplierInvoice({
id: 'inv-rc',
supplier_invoice_number: 'INV-RC-1',
vat_treatment: 'reverse_charge',
reverse_charge: true,
}),
error: null,
})
// supplier_invoice_items.insert handled by the override above
enqueue({ data: { accounting_method: 'accrual' }, error: null })
enqueue({ data: null, error: null }) // supplier_invoices update with JE id
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
params: {
inbox_item_id: 'inbox-1',
supplier_id: 'supplier-1',
document_id: null,
supplier_invoice_number: 'INV-RC-1',
invoice_date: '2026-05-15',
due_date: '2026-06-14',
currency: 'EUR',
exchange_rate: 11.5,
vat_treatment: 'reverse_charge',
subtotal: 1000,
vat_amount: 0,
total: 1000,
notes: null,
// Tampered: vat_rate and vat_amount set despite RC. Executor must
// zero these so the per-line VAT doesn't sneak into 2641.
items: [
{
line_number: 1,
description: 'Konsulttjänst EU',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '4535',
vat_rate: 0.25,
vat_amount: 250,
},
],
},
}),
)
expect(result.status).toBe('committed')
const items = capturedItems as Array<{ vat_rate: number; vat_amount: number }>
expect(items[0].vat_rate).toBe(0)
expect(items[0].vat_amount).toBe(0)
})
it('JE-failure rollback deletes items BEFORE the parent invoice (FK ordering)', async () => {
// The parent has line items at this point — the rollback must reverse
// insertion order or the FK on supplier_invoice_items blocks the parent
// delete and we're left with an orphan understating leverantörsskuld.
vi.mocked(createSupplierInvoiceRegistrationEntry).mockRejectedValueOnce(
new Error('engine error: balance check failed')
)
const deleteCalls: string[] = []
const { supabase, enqueue } = createQueuedMockSupabase()
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
const chain = (originalFrom as (t: string) => unknown)(table) as {
delete?: () => unknown
} & Record<string, unknown>
if (table === 'supplier_invoice_items' || table === 'supplier_invoices') {
// Trap delete calls so we can assert order.
return new Proxy(chain, {
get(target, prop) {
if (prop === 'delete') {
return () => {
deleteCalls.push(table)
return target.delete!()
}
}
return (target as Record<string | symbol, unknown>)[prop]
},
})
}
return chain
})
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
})
enqueue({ data: 42, error: null })
enqueue({
data: makeSupplierInvoice({ id: 'inv-rollback', supplier_invoice_number: 'INV-X' }),
error: null,
})
enqueue({ data: null, error: null }) // items insert succeeds
enqueue({ data: { accounting_method: 'accrual' }, error: null })
// JE throws — rollback path runs
enqueue({ data: null, error: null }) // items delete
enqueue({ data: null, error: null }) // parent delete
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(500)
// Critical assertion: items BEFORE the parent.
expect(deleteCalls).toEqual(['supplier_invoice_items', 'supplier_invoices'])
})
it('returns 400 when required staged params are missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
params: {
// Tampered or partial staged params — missing supplier_id + items
inbox_item_id: 'inbox-1',
supplier_invoice_number: 'INV-100',
invoice_date: '2026-05-15',
},
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
})
describe('commitPendingOperation: create_supplier_invoice_from_inbox — dimensions propagation (PR7)', () => {
/**
* Capture both the supplier_invoices parent insert and the
* supplier_invoice_items rows (cash method — no JE, shortest queue).
*/
function withInsertCapture(supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']) {
const captured: {
invoice: Record<string, unknown> | null
items: Array<Record<string, unknown>> | null
} = { invoice: null, items: null }
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
if (table === 'supplier_invoices') {
return {
insert: (row: Record<string, unknown>) => {
captured.invoice = row
return {
select: () => ({
single: () =>
Promise.resolve({
data: makeSupplierInvoice({ id: 'inv-dims', supplier_invoice_number: 'INV-100' }),
error: null,
}),
}),
}
},
}
}
if (table === 'supplier_invoice_items') {
return {
insert: (rows: Array<Record<string, unknown>>) => {
captured.items = rows
return Promise.resolve({ data: null, error: null })
},
}
}
return (originalFrom as (t: string) => unknown)(table)
})
return captured
}
/** Queue for the cash-method path with both inserts intercepted above. */
function enqueueCashFlow(enqueue: ReturnType<typeof createQueuedMockSupabase>['enqueue']) {
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
}) // inbox fetch
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
}) // supplier fetch
enqueue({ data: 42, error: null }) // arrival number RPC
enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
}
it('staged default_dimensions lands on the supplier_invoices row and item bags on the item rows', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const captured = withInsertCapture(supabase)
enqueueCashFlow(enqueue)
const op = makePendingOp()
op.params = {
...(op.params as Record<string, unknown>),
default_dimensions: { '1': 'KS01' },
items: [
{
line_number: 1,
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '6530',
vat_rate: 0.25,
vat_amount: 250,
dimensions: { '6': 'P001' },
},
{
line_number: 2,
description: 'Frakt',
quantity: 1,
unit: 'st',
unit_price: 100,
line_total: 100,
account_number: '5710',
vat_rate: 0.25,
vat_amount: 25,
// No dims staged on this row.
},
],
}
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(captured.invoice).toMatchObject({ default_dimensions: { '1': 'KS01' } })
expect(captured.items).toHaveLength(2)
expect(captured.items![0]).toMatchObject({ account_number: '6530', dimensions: { '6': 'P001' } })
// Absent bag defaults to {} on the row.
expect(captured.items![1]).toMatchObject({ account_number: '5710', dimensions: {} })
})
it('defaults to {} when absent and coerces an INVALID staged bag away (drift/tamper gate)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const captured = withInsertCapture(supabase)
enqueueCashFlow(enqueue)
const op = makePendingOp()
op.params = {
...(op.params as Record<string, unknown>),
// '0' is not a valid SIE dimension number — the whole bag is rejected.
default_dimensions: { '0': 'X' },
items: [
{
line_number: 1,
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '6530',
vat_rate: 0.25,
vat_amount: 250,
// Empty code fails the schema — the whole bag is rejected.
dimensions: { '1': '' },
},
],
}
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(captured.invoice).toMatchObject({ default_dimensions: {} })
expect(captured.items![0]).toMatchObject({ dimensions: {} })
})
})