Three MCP feedback reports (seq 265062, 288474, 288577; three companies) hit the same hole: link_document_to_voucher attaches the document to the verifikat but never stamps the inbox item it came from, and both inbox read surfaces derive "handled" from the inbox row's own link columns, never from document_attachments.journal_entry_id. So an attached document stayed "unprocessed" forever: agents re-saw it as missing underlag (one reporter paged 750 rows to find the ~15 real ones), and one user read the five leftover rows as duplicates and was about to delete the only copies of underlag sitting on posted verifikat. - commitLinkDocumentToVoucher and the bulk twin now stamp invoice_inbox_items.created_journal_entry_id, keyed on document_id, CAS on both link columns, 23505 tolerated (samlingsverifikat). Same shape as the create_voucher + inbox_item_id stamp; best-effort so inbox bookkeeping never rolls back a committed link. - gnubok_list_unmatched_documents returns file_name (same embed list_inbox_items uses) and the extraction's page coverage, so an agent can tell "no total on this document" from "we read 3 of 38 pages" and does not have to fetch each document to learn what it is (seq 265062, 288574). - gnubok_list_transactions_without_documents no longer echoes the column default "uncategorized" on rows that are booked by construction: list_uncategorized_transactions uses the same word for "no journal entry yet", and an agent read the label and tried to re-book an already-booked share-capital deposit (seq 288574). No tools/list payload change: the unmatched-documents item schema is untyped and the category field already allowed null. Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1882 lines
75 KiB
TypeScript
1882 lines
75 KiB
TypeScript
/**
|
|
* Unit tests for the executors added to bring every declared op type up to a
|
|
* callable state through `commitPendingOperation`. Tests run through the
|
|
* public dispatcher (executors are not exported individually) so the wiring
|
|
* is exercised alongside executor logic.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import {
|
|
createQueuedMockSupabase,
|
|
makeCustomer,
|
|
makeInvoice,
|
|
makeFiscalPeriod,
|
|
makeSupplierInvoice,
|
|
} from '@/tests/helpers'
|
|
import type { PendingOperation } from '@/types'
|
|
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
|
|
|
|
vi.mock('@/lib/core/bookkeeping/period-service', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/core/bookkeeping/period-service')>(
|
|
'@/lib/core/bookkeeping/period-service'
|
|
)
|
|
return {
|
|
...actual,
|
|
unlockPeriod: vi.fn(),
|
|
}
|
|
})
|
|
|
|
// create_transaction and create_invoice now resolve a Riksbanken rate for any
|
|
// non-SEK row (and refuse when there is none), so the foreign-currency cases
|
|
// below must not reach the real API. FX behaviour itself is covered in
|
|
// staged-fx-rates.test.ts.
|
|
vi.mock('@/lib/currency/riksbanken', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/currency/riksbanken')>(
|
|
'@/lib/currency/riksbanken'
|
|
)
|
|
return {
|
|
...actual,
|
|
fetchExchangeRate: vi.fn(async (currency: string) => ({
|
|
currency,
|
|
rate: 10.5,
|
|
date: '2026-05-01',
|
|
})),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/lib/import/sie-parser', () => ({
|
|
parseSIEFile: vi.fn(),
|
|
calculateFileHash: vi.fn(async () => 'mock-hash'),
|
|
}))
|
|
|
|
vi.mock('@/lib/import/sie-import', () => ({
|
|
executeSIEImport: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/bokslut/assets/depreciation-engine', () => ({
|
|
commitAnnualPostings: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import('@/lib/bookkeeping/invoice-entries')>(
|
|
'@/lib/bookkeeping/invoice-entries'
|
|
)
|
|
return {
|
|
...actual,
|
|
createCreditNoteJournalEntry: vi.fn(),
|
|
}
|
|
})
|
|
|
|
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,
|
|
createSupplierCreditNoteEntry: vi.fn(),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/lib/transactions/categorize-core', async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import('@/lib/transactions/categorize-core')>(
|
|
'@/lib/transactions/categorize-core'
|
|
)
|
|
return {
|
|
...actual,
|
|
categorizeMatchedTransaction: vi.fn(),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
|
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
|
|
})
|
|
|
|
vi.mock('@/lib/email/invoice-sender', () => ({
|
|
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
vi.mock('@/lib/email/service', () => ({
|
|
getEmailService: () => ({
|
|
isConfigured: () => true,
|
|
sendEmail: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
|
|
ensureInvoiceNumber: vi.fn(),
|
|
}))
|
|
|
|
const mockRecordManualInvoiceDelivery = vi.fn().mockResolvedValue({ id: 'delivery-1' })
|
|
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
|
|
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
|
|
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
|
|
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
|
|
sendTrackedInvoiceEmail: vi.fn(),
|
|
}))
|
|
|
|
import { commitPendingOperation } from '../commit'
|
|
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
|
|
import { parseSIEFile } from '@/lib/import/sie-parser'
|
|
import { executeSIEImport } from '@/lib/import/sie-import'
|
|
import { commitAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
|
|
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
|
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
|
import { categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
|
|
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
|
|
|
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
|
|
return {
|
|
id: 'op-1',
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'create_customer',
|
|
status: 'pending',
|
|
title: 'test',
|
|
params: {},
|
|
preview_data: {},
|
|
result_data: null,
|
|
actor_type: 'user',
|
|
actor_id: null,
|
|
actor_label: null,
|
|
risk_level: 'high',
|
|
created_at: '2026-05-03T00:00:00Z',
|
|
resolved_at: null,
|
|
updated_at: '2026-05-03T00:00:00Z',
|
|
...overrides,
|
|
} as PendingOperation
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
// ─── unlock_period ──────────────────────────────────────────────────
|
|
|
|
describe('commitPendingOperation: unlock_period', () => {
|
|
it('happy path: clears locked_at and returns committed', async () => {
|
|
const period = makeFiscalPeriod({ id: 'fp-1', locked_at: null })
|
|
vi.mocked(unlockPeriod).mockResolvedValueOnce(period)
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's pending_operations update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'unlock_period',
|
|
params: { fiscal_period_id: 'fp-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ period_id: 'fp-1', locked_at: null })
|
|
expect(unlockPeriod).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'fp-1')
|
|
})
|
|
|
|
it('rejects when fiscal_period_id is missing', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
const op = makePendingOp({ operation_type: 'unlock_period', params: {} })
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(unlockPeriod).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('surfaces underlying service errors', async () => {
|
|
vi.mocked(unlockPeriod).mockRejectedValueOnce(new Error('Period is not locked'))
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update on throw
|
|
const op = makePendingOp({
|
|
operation_type: 'unlock_period',
|
|
params: { fiscal_period_id: 'fp-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.error).toMatch(/not locked/)
|
|
})
|
|
})
|
|
|
|
// ─── create_customer ────────────────────────────────────────────────
|
|
|
|
describe('commitPendingOperation: create_customer', () => {
|
|
it('inserts the staged customer_number', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // company_settings read (payment-terms default)
|
|
enqueue({
|
|
data: makeCustomer({ id: 'cust-1', customer_number: 'K-1001' }),
|
|
error: null,
|
|
}) // customers insert
|
|
enqueue({ data: null, error: null }) // dispatcher's pending_operations update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_customer',
|
|
params: {
|
|
name: 'Kund AB',
|
|
customer_type: 'swedish_business',
|
|
customer_number: 'K-1001',
|
|
email: 'faktura@example.test',
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(findCall('customers', 'insert')?.[0]).toMatchObject({
|
|
company_id: 'company-1',
|
|
name: 'Kund AB',
|
|
customer_number: 'K-1001',
|
|
email: 'faktura@example.test',
|
|
})
|
|
})
|
|
|
|
it('rejects a staged customer_number longer than 32 characters without inserting', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_customer',
|
|
params: {
|
|
name: 'Kund AB',
|
|
customer_type: 'swedish_business',
|
|
customer_number: 'X'.repeat(33),
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(result.error).toMatch(/32/)
|
|
expect(findCall('customers', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('inserts customer_number as null when not staged', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // company_settings read (payment-terms default)
|
|
enqueue({ data: makeCustomer({ id: 'cust-1' }), error: null }) // customers insert
|
|
enqueue({ data: null, error: null }) // dispatcher's pending_operations update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_customer',
|
|
params: { name: 'Kund AB', customer_type: 'swedish_business' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(findCall('customers', 'insert')?.[0]).toMatchObject({ customer_number: null })
|
|
})
|
|
|
|
// Synthetic personnummer, never a real one. Ciphertext shape enforced by
|
|
// customers_personal_number_check (20260726110000).
|
|
const PERSONAL_NUMBER = '19900101-1234'
|
|
const CIPHERTEXT_SHAPE = /^[0-9a-f]{76,255}$/
|
|
|
|
it('stores the staged personal_number_encrypted as the customer personal_number, with the staged payment terms', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
// payment_terms were resolved at staging, so no company_settings read here
|
|
enqueue({ data: makeCustomer({ id: 'cust-1', customer_type: 'individual' }), error: null }) // customers insert
|
|
enqueue({ data: null, error: null }) // dispatcher's pending_operations update
|
|
|
|
const encrypted = encryptPersonnummer(PERSONAL_NUMBER)
|
|
const op = makePendingOp({
|
|
operation_type: 'create_customer',
|
|
params: {
|
|
name: 'Anna Andersson',
|
|
customer_type: 'individual',
|
|
payment_terms: 10,
|
|
personal_number_encrypted: encrypted,
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(findCall('customers', 'insert')?.[0]).toMatchObject({
|
|
personal_number: encrypted,
|
|
org_number: null,
|
|
default_payment_terms: 10,
|
|
})
|
|
})
|
|
|
|
it('moves a personnummer staged as org_number on an individual into personal_number, encrypted', async () => {
|
|
// An operation staged before gnubok_create_customer had a personal_number
|
|
// input, committed after this deploy.
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // company_settings read (payment-terms default)
|
|
enqueue({ data: makeCustomer({ id: 'cust-1', customer_type: 'individual' }), error: null }) // customers insert
|
|
enqueue({ data: null, error: null }) // dispatcher's pending_operations update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_customer',
|
|
params: { name: 'Bertil Bengtsson', customer_type: 'individual', org_number: PERSONAL_NUMBER },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
const inserted = findCall('customers', 'insert')?.[0] as { org_number: string | null; personal_number: string | null }
|
|
expect(inserted.org_number).toBeNull()
|
|
expect(inserted.personal_number).toMatch(CIPHERTEXT_SHAPE)
|
|
expect(decryptPersonnummer(inserted.personal_number!)).toBe(PERSONAL_NUMBER)
|
|
expect(JSON.stringify(inserted)).not.toContain(PERSONAL_NUMBER)
|
|
})
|
|
|
|
it('still refuses a personnummer-shaped org_number on a business customer', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_customer',
|
|
params: { name: 'Enskild Firma X', customer_type: 'swedish_business', org_number: PERSONAL_NUMBER },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(findCall('customers', 'insert')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('commitPendingOperation: credit-note issuance guard', () => {
|
|
it('rejects mark_invoice_sent before the ordinary invoice executor can book it', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: makeInvoice({
|
|
id: 'credit-1',
|
|
status: 'draft',
|
|
credited_invoice_id: 'invoice-1',
|
|
}),
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // dispatcher's rejected update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'mark_invoice_sent',
|
|
params: { invoice_id: 'credit-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
op,
|
|
)
|
|
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
expect(result.error).toContain('Credit notes must be issued')
|
|
})
|
|
|
|
it('records delivery history when a regular invoice is marked as sent', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: makeInvoice({
|
|
id: 'invoice-1',
|
|
status: 'draft',
|
|
invoice_number: 'F-2026001',
|
|
credited_invoice_id: null,
|
|
}),
|
|
error: null,
|
|
})
|
|
enqueue({
|
|
data: { accounting_method: 'cash', entity_type: 'enskild_firma', bankgiro: '123-4567' },
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // status update
|
|
enqueue({ data: null, error: null }) // dispatcher update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'mark_invoice_sent',
|
|
params: { invoice_id: 'invoice-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
op,
|
|
)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
|
|
supabase,
|
|
companyId: 'company-1',
|
|
userId: 'user-1',
|
|
invoiceId: 'invoice-1',
|
|
})
|
|
})
|
|
|
|
it.each(['SEK', 'EUR'] as const)(
|
|
'rejects a %s invoice without a payment account before number allocation',
|
|
async (currency) => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: makeInvoice({
|
|
id: 'invoice-1',
|
|
status: 'draft',
|
|
invoice_number: null,
|
|
credited_invoice_id: null,
|
|
currency,
|
|
}),
|
|
error: null,
|
|
})
|
|
enqueue({ data: { invoice_payment_accounts: {} }, error: null })
|
|
enqueue({ data: null, error: null }) // dispatcher rejected update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'mark_invoice_sent',
|
|
params: { invoice_id: 'invoice-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
op,
|
|
)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
|
|
expect(mockRecordManualInvoiceDelivery).not.toHaveBeenCalled()
|
|
},
|
|
)
|
|
})
|
|
|
|
describe('commitPendingOperation: invoice send payment account guard', () => {
|
|
it.each(['SEK', 'EUR'] as const)(
|
|
'rejects a %s invoice before delivery reservation and number allocation',
|
|
async (currency) => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: makeInvoice({
|
|
id: 'invoice-1',
|
|
status: 'draft',
|
|
invoice_number: null,
|
|
currency,
|
|
customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }),
|
|
items: [],
|
|
}),
|
|
error: null,
|
|
})
|
|
enqueue({
|
|
data: {
|
|
company_name: 'Test AB',
|
|
invoice_payment_accounts: {},
|
|
},
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // dispatcher's rejected update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'send_invoice',
|
|
params: { invoice_id: 'invoice-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
op,
|
|
)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
|
|
expect(supabase.from).not.toHaveBeenCalledWith('invoice_deliveries')
|
|
},
|
|
)
|
|
})
|
|
|
|
describe('commitPendingOperation: seller VAT number guard', () => {
|
|
it('rejects mark_invoice_sent for a registered company without VAT number', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: makeInvoice({
|
|
id: 'invoice-1',
|
|
status: 'draft',
|
|
invoice_number: null,
|
|
credited_invoice_id: null,
|
|
}),
|
|
error: null,
|
|
})
|
|
enqueue({
|
|
data: { bankgiro: '123-4567', vat_registered: true, vat_number: null },
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // dispatcher rejected update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'mark_invoice_sent',
|
|
params: { invoice_id: 'invoice-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
|
|
expect(mockRecordManualInvoiceDelivery).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects send_invoice for a registered company without VAT number', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: makeInvoice({
|
|
id: 'invoice-1',
|
|
status: 'draft',
|
|
invoice_number: null,
|
|
customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }),
|
|
items: [],
|
|
}),
|
|
error: null,
|
|
})
|
|
enqueue({
|
|
data: {
|
|
company_name: 'Test AB',
|
|
bankgiro: '123-4567',
|
|
vat_registered: true,
|
|
vat_number: null,
|
|
},
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // dispatcher's rejected update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'send_invoice',
|
|
params: { invoice_id: 'invoice-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
|
|
expect(supabase.from).not.toHaveBeenCalledWith('invoice_deliveries')
|
|
})
|
|
})
|
|
|
|
describe('commitPendingOperation: invoice send recipient limit', () => {
|
|
it('rejects an oversized configured recipient set before reservation and allocation', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: makeInvoice({
|
|
id: 'invoice-1',
|
|
status: 'draft',
|
|
invoice_number: null,
|
|
customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }),
|
|
items: [],
|
|
}),
|
|
error: null,
|
|
})
|
|
enqueue({
|
|
data: {
|
|
company_name: 'Test AB',
|
|
bankgiro: '123-4567',
|
|
invoice_email_cc_addresses: Array.from(
|
|
{ length: 20 },
|
|
(_, index) => `fixed-${index}@example.test`,
|
|
),
|
|
invoice_email_bcc_addresses: [],
|
|
},
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // dispatcher's rejected update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp({
|
|
operation_type: 'send_invoice',
|
|
params: { invoice_id: 'invoice-1' },
|
|
}),
|
|
)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
|
|
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ─── post_annual_depreciation ───────────────────────────────────────
|
|
|
|
describe('commitPendingOperation: post_annual_depreciation', () => {
|
|
it('happy path: routes to commitAnnualPostings and returns the posted entries', async () => {
|
|
vi.mocked(commitAnnualPostings).mockResolvedValueOnce({
|
|
posted: [
|
|
{ assetId: 'asset-1', entry: { id: 'je-1', voucher_number: 7 } as never, scheduleId: 'sch-1' },
|
|
],
|
|
skipped: [],
|
|
})
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'post_annual_depreciation',
|
|
params: { fiscal_period_id: 'fp-1', asset_ids: ['asset-1'] },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({
|
|
posted_count: 1,
|
|
skipped_count: 0,
|
|
posted: [{ asset_id: 'asset-1', journal_entry_id: 'je-1', voucher_number: 7, schedule_id: 'sch-1' }],
|
|
})
|
|
expect(commitAnnualPostings).toHaveBeenCalledWith(
|
|
expect.anything(), 'company-1', 'user-1', 'fp-1', { assetIds: ['asset-1'] }
|
|
)
|
|
})
|
|
|
|
it('rejects with 400 when fiscal_period_id is missing', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // reject update
|
|
const op = makePendingOp({ operation_type: 'post_annual_depreciation', params: {} })
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(commitAnnualPostings).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('surfaces engine errors (e.g. locked period) as a failed commit', async () => {
|
|
vi.mocked(commitAnnualPostings).mockRejectedValueOnce(new Error('Period is locked or closed'))
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // reject update
|
|
const op = makePendingOp({
|
|
operation_type: 'post_annual_depreciation',
|
|
params: { fiscal_period_id: 'fp-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.error).toMatch(/locked or closed/)
|
|
})
|
|
})
|
|
|
|
// ─── create_transaction ─────────────────────────────────────────────
|
|
|
|
describe('commitPendingOperation: create_transaction', () => {
|
|
it('happy path: inserts a transactions row with import_source=mcp and returns the id', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-42' }, error: null }) // executor insert
|
|
enqueue({ data: null, error: null }) // dispatcher's update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_transaction',
|
|
params: {
|
|
date: '2026-05-01',
|
|
amount: -129.5,
|
|
description: 'AWS subscription',
|
|
currency: 'USD',
|
|
external_id: 'recAirtable123',
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ transaction_id: 'tx-42' })
|
|
})
|
|
|
|
it('rejects with 400 when required fields are missing', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_transaction',
|
|
params: { date: '2026-05-01' }, // missing amount + description
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
})
|
|
|
|
it('returns 409 when external_id collides with an existing row', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } as never }) // executor insert
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_transaction',
|
|
params: {
|
|
date: '2026-05-01',
|
|
amount: 100,
|
|
description: 'test',
|
|
external_id: 'recAirtable123',
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
// 409 collisions are treated as auto-rejected by the dispatcher.
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.auto_rejected).toBe(true)
|
|
expect(result.http_status).toBe(409)
|
|
expect(result.error).toMatch(/already exists/)
|
|
})
|
|
|
|
it('binds cash_account_id when ledger_account is given, creating the manual account', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // ensureManualCashAccount lookup miss
|
|
enqueue({ data: { id: 'ca-1935' }, error: null }) // ensureManualCashAccount insert
|
|
enqueue({ data: { id: 'tx-9' }, error: null }) // executor transactions insert
|
|
enqueue({ data: null, error: null }) // dispatcher's update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_transaction',
|
|
params: {
|
|
date: '2026-05-01',
|
|
amount: -200,
|
|
description: 'WISE KORT ST',
|
|
currency: 'SEK',
|
|
ledger_account: '1935',
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ transaction_id: 'tx-9' })
|
|
})
|
|
|
|
it('rejects a non-19xx ledger_account with 400', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'create_transaction',
|
|
params: {
|
|
date: '2026-05-01',
|
|
amount: -200,
|
|
description: 'WISE KORT ST',
|
|
ledger_account: '3001',
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(result.error).toMatch(/19xx cash account/)
|
|
})
|
|
})
|
|
|
|
// ─── import_sie ─────────────────────────────────────────────────────
|
|
|
|
describe('commitPendingOperation: import_sie', () => {
|
|
it('happy path: parses, imports, returns committed with summary', async () => {
|
|
vi.mocked(parseSIEFile).mockReturnValueOnce({} as never)
|
|
vi.mocked(executeSIEImport).mockResolvedValueOnce({
|
|
success: true,
|
|
importId: 'imp-1',
|
|
fiscalPeriodId: 'fp-1',
|
|
openingBalanceEntryId: 'ob-1',
|
|
journalEntriesCreated: 5,
|
|
journalEntryIds: ['je-1', 'je-2', 'je-3', 'je-4', 'je-5'],
|
|
errors: [],
|
|
warnings: ['minor warning'],
|
|
})
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'import_sie',
|
|
params: {
|
|
file_content: '#FLAGGA 0\n',
|
|
filename: 'test.sie',
|
|
mappings: [],
|
|
create_fiscal_period: true,
|
|
import_opening_balances: true,
|
|
import_transactions: true,
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({
|
|
import_id: 'imp-1',
|
|
journal_entries_created: 5,
|
|
warnings: ['minor warning'],
|
|
})
|
|
expect(parseSIEFile).toHaveBeenCalledWith('#FLAGGA 0\n')
|
|
// Operations staged before update_account_names existed (params without
|
|
// the key) must default to true: Boolean(undefined) would flip it off.
|
|
expect(executeSIEImport).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
'user-1',
|
|
expect.anything(),
|
|
[],
|
|
expect.objectContaining({ updateAccountNames: true })
|
|
)
|
|
})
|
|
|
|
it('passes update_account_names: false through to executeSIEImport', async () => {
|
|
vi.mocked(parseSIEFile).mockReturnValueOnce({} as never)
|
|
vi.mocked(executeSIEImport).mockResolvedValueOnce({
|
|
success: true,
|
|
importId: 'imp-2',
|
|
fiscalPeriodId: 'fp-1',
|
|
openingBalanceEntryId: null,
|
|
journalEntriesCreated: 1,
|
|
journalEntryIds: ['je-1'],
|
|
errors: [],
|
|
warnings: [],
|
|
})
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'import_sie',
|
|
params: {
|
|
file_content: '#FLAGGA 0\n',
|
|
filename: 'test.sie',
|
|
mappings: [],
|
|
create_fiscal_period: true,
|
|
import_opening_balances: true,
|
|
import_transactions: true,
|
|
update_account_names: false,
|
|
},
|
|
})
|
|
|
|
await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(executeSIEImport).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
'user-1',
|
|
expect.anything(),
|
|
[],
|
|
expect.objectContaining({ updateAccountNames: false })
|
|
)
|
|
})
|
|
|
|
it('rejects when required params are missing', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
const op = makePendingOp({ operation_type: 'import_sie', params: { filename: 'x.sie' } })
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(parseSIEFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns the executeSIEImport errors when success=false', async () => {
|
|
vi.mocked(parseSIEFile).mockReturnValueOnce({} as never)
|
|
vi.mocked(executeSIEImport).mockResolvedValueOnce({
|
|
success: false,
|
|
importId: null,
|
|
fiscalPeriodId: null,
|
|
openingBalanceEntryId: null,
|
|
journalEntriesCreated: 0,
|
|
journalEntryIds: [],
|
|
errors: ['duplicate import'],
|
|
warnings: [],
|
|
})
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
const op = makePendingOp({
|
|
operation_type: 'import_sie',
|
|
params: {
|
|
file_content: '#FLAGGA 0\n',
|
|
filename: 'test.sie',
|
|
mappings: [],
|
|
create_fiscal_period: true,
|
|
import_opening_balances: false,
|
|
import_transactions: true,
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.error).toMatch(/duplicate import/)
|
|
})
|
|
})
|
|
|
|
// ─── credit_invoice ─────────────────────────────────────────────────
|
|
|
|
describe('commitPendingOperation: credit_invoice', () => {
|
|
it('happy path (accrual): inserts negated credit note and books JE', async () => {
|
|
const original = makeInvoice({
|
|
id: 'inv-1',
|
|
invoice_number: 'F-2024001',
|
|
status: 'sent',
|
|
document_type: 'invoice',
|
|
subtotal: 1000,
|
|
vat_amount: 250,
|
|
total: 1250,
|
|
})
|
|
const originalWithItems = {
|
|
...original,
|
|
items: [
|
|
{ sort_order: 0, description: 'Service', quantity: 1, unit: 'st', unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 },
|
|
],
|
|
}
|
|
|
|
const creditNoteRow = { ...original, id: 'cn-1', invoice_number: 'KR-F-2024001' }
|
|
const completeCreditNote = { ...creditNoteRow, customer: { name: 'Acme AB' }, items: [] }
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
// 0: CAS claim
|
|
enqueue({ data: { id: 'op-1' }, error: null })
|
|
// 1: fetch original with items
|
|
enqueue({ data: originalWithItems, error: null })
|
|
// 2: insert credit note
|
|
enqueue({ data: creditNoteRow, error: null })
|
|
// 3: insert items (await thenable)
|
|
enqueue({ data: null, error: null })
|
|
// 4: update original status='credited'
|
|
enqueue({ data: null, error: null })
|
|
// 5: re-fetch complete credit note with customer + items
|
|
enqueue({ data: completeCreditNote, error: null })
|
|
// 6: company_settings
|
|
enqueue({ data: { entity_type: 'aktiebolag', accounting_method: 'accrual' }, error: null })
|
|
// 7: update invoice with journal_entry_id
|
|
enqueue({ data: null, error: null })
|
|
// 8: dispatcher's pending_operations update
|
|
enqueue({ data: null, error: null })
|
|
|
|
vi.mocked(createCreditNoteJournalEntry).mockResolvedValueOnce({ id: 'je-1' } as never)
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'credit_invoice',
|
|
params: { invoice_id: 'inv-1', reason: 'Wrong amount' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ credit_note_id: 'cn-1', journal_entry_id: 'je-1' })
|
|
expect(createCreditNoteJournalEntry).toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips JE on cash accounting', async () => {
|
|
const original = makeInvoice({ id: 'inv-1', status: 'paid', document_type: 'invoice' })
|
|
const originalWithItems = { ...original, items: [] }
|
|
const creditNoteRow = { ...original, id: 'cn-2', invoice_number: 'KR-F-2024001' }
|
|
const completeCreditNote = { ...creditNoteRow, customer: null, items: [] }
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: originalWithItems, error: null })
|
|
enqueue({ data: creditNoteRow, error: null })
|
|
enqueue({ data: null, error: null })
|
|
enqueue({ data: null, error: null })
|
|
enqueue({ data: completeCreditNote, error: null })
|
|
enqueue({ data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null })
|
|
// no JE update; go straight to dispatcher update
|
|
enqueue({ data: null, error: null })
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'credit_invoice',
|
|
params: { invoice_id: 'inv-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ credit_note_id: 'cn-2', journal_entry_id: null })
|
|
expect(createCreditNoteJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('auto-rejects when invoice is already credited (409)', async () => {
|
|
const original = makeInvoice({ id: 'inv-1', status: 'credited', document_type: 'invoice' })
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { ...original, items: [] }, error: null })
|
|
// dispatcher auto-reject path also does an update
|
|
enqueue({ data: null, error: null })
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'credit_invoice',
|
|
params: { invoice_id: 'inv-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.auto_rejected).toBe(true)
|
|
expect(result.http_status).toBe(409)
|
|
})
|
|
|
|
it('rejects when invoice_id is missing', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
const op = makePendingOp({ operation_type: 'credit_invoice', params: {} })
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
})
|
|
|
|
it('rejects invoices with status outside sent/paid/overdue', async () => {
|
|
const original = makeInvoice({ id: 'inv-1', status: 'draft', document_type: 'invoice' })
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { ...original, items: [] }, error: null })
|
|
enqueue({ data: null, error: null }) // dispatcher's reject update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'credit_invoice',
|
|
params: { invoice_id: 'inv-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
})
|
|
})
|
|
|
|
// ─── credit_supplier_invoice ────────────────────────────────────────
|
|
|
|
describe('commitPendingOperation: credit_supplier_invoice', () => {
|
|
it('normalizes copied storage and reverses with the untouched original items', async () => {
|
|
const originalItems = [
|
|
{
|
|
sort_order: 0,
|
|
description: 'Office supplies',
|
|
quantity: 1,
|
|
unit: 'st',
|
|
unit_price: 1000,
|
|
line_total: 1000,
|
|
account_number: '5410',
|
|
vat_code: null,
|
|
vat_rate: 25,
|
|
vat_amount: 250,
|
|
dimensions: {},
|
|
},
|
|
]
|
|
const original = {
|
|
...makeSupplierInvoice({ id: 'supplier-invoice-1', status: 'registered' }),
|
|
supplier: { name: 'Office Depot AB', supplier_type: 'swedish_business' },
|
|
items: originalItems,
|
|
}
|
|
const creditNote = makeSupplierInvoice({
|
|
id: 'supplier-credit-1',
|
|
is_credit_note: true,
|
|
credited_invoice_id: original.id,
|
|
})
|
|
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: { id: 'op-1' }, error: null },
|
|
{ data: original, error: null },
|
|
{ data: 2, error: null },
|
|
{ data: creditNote, error: null },
|
|
{ data: null, error: null },
|
|
{ data: { accounting_method: 'accrual' }, error: null },
|
|
{ data: null, error: null },
|
|
{ data: null, error: null },
|
|
{ data: null, error: null },
|
|
])
|
|
vi.mocked(createSupplierCreditNoteEntry).mockResolvedValueOnce({ id: 'je-1' } as never)
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'credit_supplier_invoice',
|
|
params: { supplier_invoice_id: original.id },
|
|
})
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
op,
|
|
)
|
|
|
|
expect(result.status).toBe('committed')
|
|
const insertArgs = findCall('supplier_invoice_items', 'insert')
|
|
const insertedItems = insertArgs?.[0] as Array<{ vat_rate: number }>
|
|
expect(insertedItems[0]?.vat_rate).toBe(0.25)
|
|
expect(createSupplierCreditNoteEntry).toHaveBeenCalledWith(
|
|
supabase,
|
|
'company-1',
|
|
'user-1',
|
|
creditNote,
|
|
originalItems,
|
|
'swedish_business',
|
|
'Office Depot AB',
|
|
)
|
|
})
|
|
})
|
|
|
|
// ─── attach_document_to_transaction ─────────────────────────────────
|
|
|
|
describe('commitPendingOperation: attach_document_to_transaction', () => {
|
|
const baseOp: Partial<PendingOperation> = {
|
|
operation_type: 'attach_document_to_transaction',
|
|
params: { transaction_id: 'tx-1', document_id: 'doc-1' },
|
|
}
|
|
|
|
it('auto-rejects 404 when transaction is not in the company', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // tx fetch: not found
|
|
enqueue({ data: null, error: null }) // dispatcher reject update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(404)
|
|
})
|
|
|
|
it('auto-rejects 409 when existing pinned doc is räkenskapsinformation', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: 'doc-old', journal_entry_id: null }, error: null })
|
|
enqueue({ data: { journal_entry_id: 'je-99' }, error: null }) // existing doc fetch: locked
|
|
enqueue({ data: null, error: null }) // dispatcher reject update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
})
|
|
|
|
it('translates BFL_DOCUMENT_IMMUTABILITY trigger error into auto-reject 409', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
|
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
|
enqueue({
|
|
data: null,
|
|
error: {
|
|
code: 'P0001',
|
|
message: 'BFL_DOCUMENT_IMMUTABILITY: cannot detach or swap document …',
|
|
},
|
|
})
|
|
enqueue({ data: null, error: null }) // dispatcher reject update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
})
|
|
|
|
it('happy path uncategorized: attaches without propagation', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
|
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: null }, error: null }) // UPDATE returning
|
|
enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link
|
|
enqueue({ data: [], error: null }) // voucher-link resolution: not bulk-booked either
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
})
|
|
|
|
it('propagates to journal entry when tx was categorized between staging and commit', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
|
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: 'je-7' }, error: null }) // UPDATE returning post-state
|
|
enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link
|
|
enqueue({ data: null, error: null }) // doc propagation update
|
|
enqueue({ data: { document_id: null }, error: null }) // completion: tx pin lookup
|
|
enqueue({ data: [], error: null }) // completion: matched inbox items (none)
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
})
|
|
|
|
it('completes matched inbox items when the tx is anchored via a bulk-book samlingsverifikat', async () => {
|
|
// A bulk-booked tx keeps transactions.journal_entry_id null: the verifikat
|
|
// hangs off transaction_voucher_links. Attaching a hunted receipt to it
|
|
// must still resolve the matched inbox item, or it strands as "linked"
|
|
// forever (the 2026-08-12 report).
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
|
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: null }, error: null }) // UPDATE returning (still null)
|
|
enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link
|
|
enqueue({ data: [{ transaction_id: 'tx-1', journal_entry_id: 'je-9' }], error: null }) // voucher links
|
|
enqueue({ data: { document_id: null }, error: null }) // propagation: tx pin lookup
|
|
enqueue({ data: [{ id: 'inbox-9', document_id: null }], error: null }) // matched inbox items
|
|
enqueue({ data: null, error: null }) // created_journal_entry_id stamp
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
|
|
{ created_journal_entry_id: 'je-9' },
|
|
])
|
|
})
|
|
|
|
it('still commits when the inbox-link best-effort update errors', async () => {
|
|
// Inbox sync is best-effort: a failure to mark the inbox row as matched
|
|
// must not roll back the (compliant) doc→tx attach. Mirrors the REST
|
|
// route's swallow-and-log behaviour. The Supabase client resolves with
|
|
// { error } rather than rejecting, so we both confirm the op commits AND
|
|
// that the error was actually inspected and logged (not silently dropped).
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
|
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: null }, error: null }) // tx UPDATE returning
|
|
enqueue({ data: null, error: { message: 'inbox row missing or RLS-blocked' } }) // inbox link: errors
|
|
enqueue({ data: [], error: null }) // voucher-link resolution: not bulk-booked either
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
expect(spy).toHaveBeenCalledWith(
|
|
'[commitAttach] Failed to link inbox item:',
|
|
expect.objectContaining({ message: 'inbox row missing or RLS-blocked' }),
|
|
)
|
|
spy.mockRestore()
|
|
})
|
|
|
|
it('touches the invoice_inbox_items table to sync matched_transaction_id', async () => {
|
|
// Argument-level check that the executor actually reaches into
|
|
// invoice_inbox_items to keep the inbox UI in sync with the staged-write
|
|
// path. Otherwise the inbox row stays in "Behöver åtgärd" forever.
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
|
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: null }, error: null }) // tx UPDATE returning
|
|
enqueue({ data: null, error: null }) // invoice_inbox_items link
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
const tablesTouched = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map(c => c[0])
|
|
expect(tablesTouched).toContain('invoice_inbox_items')
|
|
})
|
|
|
|
it('auto-rejects 409 when the document already belongs to a different verifikation', async () => {
|
|
// Without this guard the attach would pin a consumed doc (undetachable
|
|
// per the transactions immutability trigger) and the later propagation
|
|
// would corrupt or be blocked by the doc-metadata immutability trigger.
|
|
// Mirrors the REST route's guard.
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: 'je-1' }, error: null })
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
|
|
enqueue({ data: null, error: null }) // dispatcher reject update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
})
|
|
|
|
it('skips the propagation write on an idempotent re-attach (doc already on the same verifikation)', async () => {
|
|
// The period-lock trigger raises on ANY journal_entry_id write: even a
|
|
// same-value rewrite: so an unconditional re-run would fail an otherwise
|
|
// idempotent re-attach once the period locks.
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: 'doc-1', journal_entry_id: 'je-1' }, error: null })
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) // doc fetch: same JE
|
|
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // tx UPDATE returning
|
|
enqueue({ data: null, error: null }) // invoice_inbox_items link
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
// document_attachments touched once (the doc fetch): no propagation write.
|
|
const tablesTouched = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map(c => c[0])
|
|
expect(tablesTouched.filter((t) => t === 'document_attachments')).toHaveLength(1)
|
|
})
|
|
|
|
it('maps a period-lock propagation failure to auto-reject 409', async () => {
|
|
// A retry could never succeed until the period is unlocked, so the
|
|
// generic 500 "försök igen" would be a false promise. Mirrors the REST
|
|
// route's mapping.
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: 'je-7' }, error: null }) // tx UPDATE returning: booked meanwhile
|
|
enqueue({ data: null, error: null }) // invoice_inbox_items link
|
|
enqueue({
|
|
data: null,
|
|
error: { message: 'cannot link document in a locked/closed fiscal period' },
|
|
}) // propagation blocked by enforce_period_lock
|
|
enqueue({ data: null, error: null }) // dispatcher reject update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never,
|
|
'user-1',
|
|
'company-1',
|
|
makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
})
|
|
})
|
|
|
|
// ─── link_document_to_voucher ─────────────────────────────────
|
|
|
|
describe('commitPendingOperation: link_document_to_voucher', () => {
|
|
const baseOp: Partial<PendingOperation> = {
|
|
operation_type: 'link_document_to_voucher',
|
|
params: { document_id: 'doc-1', journal_entry_id: 'je-1' },
|
|
}
|
|
|
|
it('auto-rejects 404 when document is not in the company', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // doc fetch: not found
|
|
enqueue({ data: null, error: null }) // dispatcher reject update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(404)
|
|
})
|
|
|
|
it('auto-rejects 409 when doc is already linked to a different posted JE (WORM guard)', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
|
|
enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status
|
|
enqueue({ data: null, error: null }) // dispatcher reject update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
})
|
|
|
|
it('allows re-linking when existing linked JE is not yet posted (draft)', async () => {
|
|
// A doc linked to a draft (uncommitted) JE can be moved: only posted
|
|
// verifikationer trigger the WORM guard.
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-DRAFT' }, error: null }) // doc fetch
|
|
enqueue({ data: { status: 'draft' }, error: null }) // WORM: existing JE: not posted
|
|
enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership
|
|
enqueue({
|
|
data: { id: 'doc-1', file_name: 'kvitto.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null },
|
|
error: null,
|
|
}) // linkToJournalEntry: doc update
|
|
enqueue({ data: null, error: null }) // inbox stamp (best-effort)
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
})
|
|
|
|
it('happy path: links doc to verifikation with no prior journal_entry_id', async () => {
|
|
const { supabase, enqueue, calls, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership
|
|
enqueue({
|
|
data: { id: 'doc-1', file_name: 'faktura.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null },
|
|
error: null,
|
|
}) // linkToJournalEntry: doc update
|
|
enqueue({ data: null, error: null }) // inbox stamp (best-effort)
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({
|
|
document_id: 'doc-1',
|
|
journal_entry_id: 'je-1',
|
|
})
|
|
// The inbox item the document came from is stamped as handled, keyed on
|
|
// document_id and CAS-guarded on both link columns: otherwise
|
|
// list_inbox_items / list_unmatched_documents keep listing an attached
|
|
// document as unprocessed forever.
|
|
expect(findCall('invoice_inbox_items', 'update')).toEqual([{ created_journal_entry_id: 'je-1' }])
|
|
const inboxFilters = calls
|
|
.filter((c) => c.table === 'invoice_inbox_items' && (c.method === 'eq' || c.method === 'is'))
|
|
.map((c) => c.args)
|
|
expect(inboxFilters).toEqual([
|
|
['document_id', 'doc-1'],
|
|
['company_id', 'company-1'],
|
|
['created_journal_entry_id', null],
|
|
['created_supplier_invoice_id', null],
|
|
])
|
|
})
|
|
|
|
it('inbox stamp is best-effort: a failed stamp never fails the committed link', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership
|
|
enqueue({
|
|
data: { id: 'doc-1', file_name: 'faktura.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null },
|
|
error: null,
|
|
}) // linkToJournalEntry: doc update
|
|
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value' } }) // inbox stamp: samlingsverifikat already claimed
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ document_id: 'doc-1', journal_entry_id: 'je-1' })
|
|
})
|
|
|
|
it('auto-rejects 409 when linkToJournalEntry throws a period-lock error', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership
|
|
enqueue({
|
|
data: null,
|
|
error: { message: 'cannot link document in a locked/closed fiscal period' },
|
|
}) // linkToJournalEntry: doc update: period locked
|
|
enqueue({ data: null, error: null }) // dispatcher reject update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
})
|
|
})
|
|
|
|
// ─── link_documents_to_vouchers (bulk) ─────────────────────────────
|
|
|
|
describe('commitPendingOperation: link_documents_to_vouchers', () => {
|
|
const baseOp: Partial<PendingOperation> = {
|
|
operation_type: 'link_documents_to_vouchers',
|
|
params: {
|
|
links: [
|
|
{ document_id: 'doc-1', journal_entry_id: 'je-1', journal_entry_line_id: null },
|
|
{ document_id: 'doc-2', journal_entry_id: 'je-2', journal_entry_line_id: null },
|
|
],
|
|
},
|
|
}
|
|
|
|
it('happy path: links every row and reports zero skipped', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
// row 1: doc-1 -> je-1
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership
|
|
enqueue({
|
|
data: { id: 'doc-1', file_name: 'kvitto1.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null },
|
|
error: null,
|
|
}) // linkToJournalEntry: doc update
|
|
enqueue({ data: null, error: null }) // inbox stamp (best-effort)
|
|
// row 2: doc-2 -> je-2
|
|
enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership
|
|
enqueue({
|
|
data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', journal_entry_line_id: null },
|
|
error: null,
|
|
}) // linkToJournalEntry: doc update
|
|
enqueue({ data: null, error: null }) // inbox stamp (best-effort)
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({
|
|
linked_count: 2,
|
|
skipped_count: 0,
|
|
})
|
|
})
|
|
|
|
it('mixed batch: a missing document is skipped without blocking the other rows', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
// row 1: doc-1 not found in this company
|
|
enqueue({ data: null, error: null }) // doc fetch: not found
|
|
// row 2: doc-2 -> je-2 succeeds
|
|
enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership
|
|
enqueue({
|
|
data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', journal_entry_line_id: null },
|
|
error: null,
|
|
}) // linkToJournalEntry: doc update
|
|
enqueue({ data: null, error: null }) // inbox stamp (best-effort)
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
// Non-transactional bulk op: overall status is 'committed' even with
|
|
// partial skips (mirrors bulk_book_inbox_items's booked/skipped split),
|
|
// since no irreversible side-effect was posted for the skipped row.
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ linked_count: 1, skipped_count: 1 })
|
|
expect((result.data as { skipped: Array<{ document_id: string; reason: string }> }).skipped[0]).toMatchObject({
|
|
document_id: 'doc-1',
|
|
reason: 'Bilagan hittades inte.',
|
|
})
|
|
})
|
|
|
|
it('WORM guard skips a row whose doc is already linked to a different posted JE', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
// row 1: doc-1 already linked to je-OTHER (posted) -> skipped
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
|
|
enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status
|
|
// row 2: doc-2 -> je-2 succeeds
|
|
enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership
|
|
enqueue({
|
|
data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', journal_entry_line_id: null },
|
|
error: null,
|
|
}) // linkToJournalEntry: doc update
|
|
enqueue({ data: null, error: null }) // inbox stamp (best-effort)
|
|
enqueue({ data: null, error: null }) // dispatcher commit update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ linked_count: 1, skipped_count: 1 })
|
|
})
|
|
|
|
it('auto-rejects 409 when every row is skipped instead of recording a committed no-op', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
// row 1: doc-1 already linked to a posted JE -> WORM skip
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
|
|
enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status
|
|
// row 2: doc-2 not found -> skip
|
|
enqueue({ data: null, error: null }) // doc fetch: not found
|
|
enqueue({ data: null, error: null }) // dispatcher reject update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
|
|
)
|
|
// Partial skips stay 'committed' (see the mixed-batch case), but a batch
|
|
// that linked nothing must not leave an audit record asserting a run that
|
|
// changed nothing: the single-document executor returns 409 for the same
|
|
// conditions, and the bulk path must not be the weaker one.
|
|
expect(result.status).toBe('rejected')
|
|
expect(result.http_status).toBe(409)
|
|
})
|
|
|
|
it('fails 400 when the staged params carry an empty links array', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher failure update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1',
|
|
makePendingOp({ ...baseOp, params: { links: [] } }),
|
|
)
|
|
// 400 is not auto-reject territory (only 404 and 409 are), so the operation
|
|
// is recorded as failed rather than rejected.
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
})
|
|
|
|
it('fails 400 when links is missing entirely', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher failure update
|
|
|
|
const result = await commitPendingOperation(
|
|
supabase as never, 'user-1', 'company-1',
|
|
makePendingOp({ ...baseOp, params: {} }),
|
|
)
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
})
|
|
})
|
|
|
|
// ─── categorize_transaction: dimensions propagation (PR7) ──────────
|
|
|
|
describe('commitPendingOperation: categorize_transaction: dimensions propagation (PR7)', () => {
|
|
it('threads the staged dimensions bag into categorizeMatchedTransaction opts', async () => {
|
|
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
|
|
data: { journal_entry_id: 'je-1' },
|
|
})
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's commit update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'categorize_transaction',
|
|
params: {
|
|
transaction_id: 'tx-1',
|
|
category: 'office_supplies',
|
|
dimensions: { '1': 'KS01', '6': 'P001' },
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(categorizeMatchedTransaction).toHaveBeenCalledTimes(1)
|
|
const call = vi.mocked(categorizeMatchedTransaction).mock.calls[0]
|
|
expect(call[1]).toBe('user-1')
|
|
expect(call[2]).toBe('company-1')
|
|
expect(call[3]).toBe('tx-1')
|
|
expect(call[4].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
|
|
})
|
|
|
|
it('coerces an INVALID staged bag to undefined (drift/tamper gate)', async () => {
|
|
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
|
|
data: { journal_entry_id: 'je-1' },
|
|
})
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's commit update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'categorize_transaction',
|
|
params: {
|
|
transaction_id: 'tx-1',
|
|
category: 'office_supplies',
|
|
// '0' is not a valid SIE dimension number: the whole bag is rejected
|
|
// and booking proceeds without dimensions.
|
|
dimensions: { '0': 'X' },
|
|
},
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
|
|
expect(opts.dimensions).toBeUndefined()
|
|
})
|
|
|
|
it('passes no dimensions when nothing was staged', async () => {
|
|
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
|
|
data: { journal_entry_id: 'je-1' },
|
|
})
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's commit update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'categorize_transaction',
|
|
params: { transaction_id: 'tx-1', category: 'office_supplies' },
|
|
})
|
|
|
|
await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
|
|
expect(opts.dimensions).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// ─── categorize_transaction: account_override tamper gate ───────────────────
|
|
|
|
describe('commitPendingOperation: categorize_transaction account_override', () => {
|
|
it('rejects loudly when a stored override is present but malformed (tamper/drift)', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's rejected update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'categorize_transaction',
|
|
params: { transaction_id: 'tx-1', category: 'expense_other', account_override: '40a0' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
// Never degrade to the category default: the approver approved a preview
|
|
// showing the override account. 400 lands as 'failed' (the dispatcher
|
|
// reserves auto-reject for 404/409); the point is that the core is never
|
|
// called and the error names the override.
|
|
expect(result.status).toBe('failed')
|
|
expect(result.http_status).toBe(400)
|
|
expect(result.error).toContain('account_override')
|
|
expect(categorizeMatchedTransaction).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('threads a valid stored override into the core opts', async () => {
|
|
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
|
|
data: { journal_entry_id: 'je-1' },
|
|
})
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's commit update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'categorize_transaction',
|
|
params: { transaction_id: 'tx-1', category: 'expense_other', account_override: '4020' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
|
|
expect(opts.accountOverride).toBe('4020')
|
|
})
|
|
|
|
it('passes undefined when the staged params carry account_override: null (no override)', async () => {
|
|
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
|
|
data: { journal_entry_id: 'je-1' },
|
|
})
|
|
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({ data: null, error: null }) // dispatcher's commit update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'categorize_transaction',
|
|
params: { transaction_id: 'tx-1', category: 'expense_other', account_override: null },
|
|
})
|
|
|
|
await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
|
|
expect(opts.accountOverride).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('commitPendingOperation: mark_invoice_sent honours defer_invoice_booking (#967)', () => {
|
|
it('marks the invoice sent WITHOUT booking when the company defers invoice booking', async () => {
|
|
const invoiceEntries = await import('@/lib/bookkeeping/invoice-entries')
|
|
const bookSpy = vi.spyOn(invoiceEntries, 'createInvoiceJournalEntry')
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
|
enqueue({
|
|
data: makeInvoice({
|
|
id: 'invoice-1',
|
|
status: 'draft',
|
|
invoice_number: 'F-2026001',
|
|
credited_invoice_id: null,
|
|
}),
|
|
error: null,
|
|
})
|
|
enqueue({
|
|
data: {
|
|
accounting_method: 'accrual',
|
|
defer_invoice_booking: true,
|
|
entity_type: 'enskild_firma',
|
|
bankgiro: '123-4567',
|
|
},
|
|
error: null,
|
|
})
|
|
enqueue({ data: null, error: null }) // status update
|
|
enqueue({ data: null, error: null }) // dispatcher update
|
|
|
|
const op = makePendingOp({
|
|
operation_type: 'mark_invoice_sent',
|
|
params: { invoice_id: 'invoice-1' },
|
|
})
|
|
|
|
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
|
|
|
expect(result.status).toBe('committed')
|
|
expect(result.data).toMatchObject({ status: 'sent', journal_entry_id: null })
|
|
// Same gate as the dashboard: deferred companies book via the explicit
|
|
// Bokför step, never at mark-sent.
|
|
expect(bookSpy).not.toHaveBeenCalled()
|
|
bookSpy.mockRestore()
|
|
})
|
|
})
|