Files
accounted/lib/pending-operations/__tests__/executors.test.ts
T
Mattsson 0040cadacc feat(invoicing): opt-in invoice email from the company's own sending domain (#1802)
* feat(invoicing): opt-in invoice email from the company's own sending domain

Companies holding the custom_sender_domain capability grant can register
their own domain (Resend sending-only profile), publish DKIM/SPF, and once
verified every invoice email (send, reminders, recurring, payment
confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>"
instead of the platform sender. Reply-To is unchanged.

- New table company_sending_domains (RLS: members read, owner/admin write;
  audit trigger), types, archive-export classification.
- New capability key custom_sender_domain: manually granted per company,
  deliberately outside PAID_CAPABILITIES (never trial-seeded, never written
  by the Stripe sync). Without the grant the settings section is hidden and
  nothing changes.
- Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify),
  Resend domain lifecycle without orphan adoption, domain.updated handling
  on the delivery webhook, explicit From support in the Resend adapter.
- Core resolveInvoiceSender(): verified + enabled + entitled, else the
  platform sender; never throws.
- Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en).
- Unit tests for the resolver, domain helpers, routes, From header; pg-real
  test for RLS and constraints.

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

* fix(invoicing): harden sending-domain writes, sender fallback, review findings

Skeptic refutations:
- Tenant JWTs could insert/update company_sending_domains with status =
  'verified' and an arbitrary domain through PostgREST (RLS only checked
  membership), then send invoice mail as that domain. New migration
  20260822130000 adds a BEFORE trigger: tenants may only open a pending
  claim and edit sender_local_part/sender_name/enabled; domain and
  verification state are service-role only. claim/verify helpers now take
  a service-role writer for those columns; the route's RLS client still
  does the insert.
- A company domain Resend later rejects made every invoice send fail: the
  Resend adapter retries once as the platform sender when an explicit
  company From is rejected (nothing was sent, so no double send).

Review findings:
- domain.updated webhook: discriminated outcome; DB errors answer 500 so
  Svix retries, unknown domains are acknowledged.
- Display names are RFC 5322-quoted only when they carry specials.
- Sender local part is a strict dot-atom (no trailing/consecutive dots),
  in code and in the CHECK constraint; resend_domain_id index is UNIQUE.
- IME composition guard on the claim input; event bus reset in tests;
  settings section skips its request for non-admins.

Deferred (needs a product call): persisting the effective From address in
the invoice delivery log touches the hardened evidence triggers; recorded
in DECISIONS.md.

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

* fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test

Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a
tenant could delete and re-insert its pending row under the same id with a
reserved domain, and the service-role writer updated by id alone. Now:
- the claim's verification-state write filters on (id, company_id, domain,
  resend_domain_id IS NULL) and rolls back on zero rows;
- verify and the domain.updated webhook compare Resend's domain name with
  the row before writing verified;
- resolveInvoiceSender refuses reserved platform domains and non-hostnames
  at send time (reserved-domain logic moved to lib/email/domain-name.ts and
  shared with the claim validator).

pg-real: the case-insensitive uniqueness assertion now expects the
domain_shape CHECK (lowercase enforced) for an uppercase variant and the
unique index for a same-case duplicate.

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

---------

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

1730 lines
69 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: 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 }) // 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 } = 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 }) // 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
// 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 }) // 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 }) // 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 }) // 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()
})
})