Files
accounted/lib/expenses/__tests__/expense-claims-service.test.ts
T
4fce2d7b94 feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

- expense_reimbursement line type: kostnadsersättning outside gross, tax,
  avgifter and the AGI. The engine adds tax-free reimbursements (utlägg,
  skattefritt traktamente, skattefri milersättning) to the net payout only.
- booking debits the claim's liability account (2820) on top of gross,
  never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K
  instead of being treated as a nollkörning
- salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade,
  one payslip line per claim); settle_expense_claims_via_salary_run marks
  the claims paid with an expense_payout_batches row pointing at the
  salary verifikat, no second verifikat, idempotent on retry; wired into
  bookLoadedRun and the v1 book route with a pre-check before posting
- create_expense_payout_batch refuses claims scheduled on a payslip
  (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft
- "Lägg till utlägg" on the employee row of a draft run; the payslip page
  labels and removes the lines
- pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case

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

* fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line

- salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited
  in the unmerged 20260906210300): the database refuses to delete a claim a
  payslip line still references, whichever path issues the DELETE
- deleteExpenseClaim removes the draft line first (before the storno) and
  keeps refusing with ON_PAYSLIP once the run has left draft
- pg-real: delete refused with 23503 on a booked and on a draft run; the
  app order (line, then claim) succeeds
- unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for
  an expense_reimbursement line (FK011 derives from sre.gross_salary; only
  benefit_* types are read from line items)

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

---------

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

694 lines
26 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const createJournalEntryMock = vi.fn()
const findFiscalPeriodMock = vi.fn()
const reverseEntryMock = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => createJournalEntryMock(...args),
findFiscalPeriod: (...args: unknown[]) => findFiscalPeriodMock(...args),
reverseEntry: (...args: unknown[]) => reverseEntryMock(...args),
}))
const linkToJournalEntryMock = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => linkToJournalEntryMock(...args),
}))
const fetchExchangeRateMock = vi.fn()
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => fetchExchangeRateMock(...args),
}))
// Utlägg on a payslip (#2331): the delete guard's lookup is mocked so the
// queued Supabase mock keeps its existing call order.
const findPayslipLineForClaimMock = vi.fn()
vi.mock('@/lib/salary/expense-claim-lines', () => ({
findPayslipLineForClaim: (...args: unknown[]) => findPayslipLineForClaimMock(...args),
}))
import { registerExpenseClaim, createPayoutBatch, deleteExpenseClaim } from '../expense-claims-service'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
// The queued mock is structurally sufficient for the service; the cast keeps
// the test honest about not being a real client.
const sb = supabase as unknown as import('@supabase/supabase-js').SupabaseClient
const COMPANY = 'company-1'
const USER = 'user-1'
describe('registerExpenseClaim', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
findFiscalPeriodMock.mockResolvedValue('period-1')
createJournalEntryMock.mockResolvedValue({ id: 'je-1' })
})
it('books an enskild firma owner claim on 2018 (egen insättning)', async () => {
enqueue({ data: { entity_type: 'enskild_firma' } }) // companies entity_type
enqueue({ data: { id: 'claim-ef', amount_sek: 500, vat_sek: 100 } }) // insert
enqueue({ data: null }) // journal_entry_id update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'USB-hubb',
expense_date: '2026-09-01',
amount: 500,
vat_amount: 100,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Joakim Hansson',
})
expect(result.ok).toBe(true)
const input = createJournalEntryMock.mock.calls[0][3]
expect(input.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '2018', credit_amount: 500 }),
]),
)
const insertCall = findCall('expense_claims', 'insert')
expect(insertCall?.[0]).toEqual(
expect.objectContaining({ liability_account: '2018' }),
)
})
it('books an SEK owner claim: cost + VAT debit, liability credit', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'claim-1', amount_sek: 500, vat_sek: 100 } }) // insert
enqueue({ data: null }) // journal_entry_id update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'USB-hubb',
expense_date: '2026-09-01',
amount: 500,
vat_amount: 100,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Joakim Hansson',
})
expect(result.ok).toBe(true)
const input = createJournalEntryMock.mock.calls[0][3]
expect(input.source_type).toBe('expense_claim')
expect(input.lines).toEqual([
expect.objectContaining({ account_number: '5410', debit_amount: 400 }),
expect.objectContaining({ account_number: '2641', debit_amount: 100 }),
expect.objectContaining({ account_number: '2893', credit_amount: 500 }),
])
})
it('defaults an employee claim to liability 2820', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'emp-1', first_name: 'Sofie', last_name: 'Persson' } }) // employee lookup
enqueue({ data: { id: 'claim-1' } }) // insert
enqueue({ data: null }) // update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'Tågbiljett',
expense_date: '2026-09-01',
amount: 250,
vat_amount: 15,
currency: 'SEK',
expense_account: '5810',
employee_id: 'emp-1',
})
expect(result.ok).toBe(true)
const liabilityLine = createJournalEntryMock.mock.calls[0][3].lines.at(-1)
expect(liabilityLine.account_number).toBe('2820')
const insert = findCall('expense_claims', 'insert')
expect(insert?.[0]).toMatchObject({ claimant_name: 'Sofie Persson', liability_account: '2820' })
})
it('takes the claimant name from the employee row, ignoring a mismatched one', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'emp-1', first_name: 'Sofie', last_name: 'Persson' } }) // employee
enqueue({ data: { id: 'claim-x', amount_sek: 500, vat_sek: 100 } }) // insert
enqueue({ data: null }) // journal_entry_id update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'USB-hubb',
expense_date: '2026-09-01',
amount: 500,
vat_amount: 100,
currency: 'SEK',
expense_account: '5410',
employee_id: 'emp-1',
claimant_name: 'Någon Annan',
})
expect(result.ok).toBe(true)
const insert = findCall('expense_claims', 'insert')
expect(insert?.[0]).toMatchObject({
claimant_name: 'Sofie Persson',
liability_account: '2820',
})
})
it('converts foreign currency at the explicit rate, VAT included', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'claim-1' } }) // insert
enqueue({ data: null }) // update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'Plaud Note Pro',
expense_date: '2026-08-21',
amount: 189.99,
vat_amount: 38,
currency: 'EUR',
exchange_rate: 11.0625,
expense_account: '5410',
claimant_name: 'Joakim Hansson',
})
expect(result.ok).toBe(true)
const lines = createJournalEntryMock.mock.calls[0][3].lines
expect(lines[0]).toMatchObject({ account_number: '5410', debit_amount: 1681.38 })
expect(lines[1]).toMatchObject({ account_number: '2641', debit_amount: 420.38 })
expect(lines[2]).toMatchObject({ account_number: '2893', credit_amount: 2101.76 })
expect(fetchExchangeRateMock).not.toHaveBeenCalled()
})
it('fails with RATE_UNAVAILABLE when Riksbanken has no rate', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
fetchExchangeRateMock.mockResolvedValue(null)
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'SaaS',
expense_date: '2026-09-01',
amount: 20,
vat_amount: 0,
currency: 'USD',
expense_account: '6540',
claimant_name: 'Joakim',
})
expect(result).toEqual({ ok: false, code: 'RATE_UNAVAILABLE' })
expect(createJournalEntryMock).not.toHaveBeenCalled()
})
it('rejects VAT >= amount before touching the database', async () => {
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'x',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 100,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Joakim',
})
expect(result).toEqual({ ok: false, code: 'VAT_EXCEEDS_AMOUNT' })
expect(supabase.from).not.toHaveBeenCalled()
})
it('requires a claimant when no employee is given', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'x',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 0,
currency: 'SEK',
expense_account: '5410',
})
expect(result).toEqual({ ok: false, code: 'CLAIMANT_REQUIRED' })
})
it('returns EMPLOYEE_NOT_FOUND for an employee outside the company', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: null }) // employee lookup
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'x',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 0,
currency: 'SEK',
expense_account: '5410',
employee_id: 'emp-x',
})
expect(result).toEqual({ ok: false, code: 'EMPLOYEE_NOT_FOUND' })
})
it('links an unanchored receipt document to the new verifikat', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'claim-1' } }) // insert
enqueue({ data: null }) // journal_entry_id update
enqueue({ data: { journal_entry_id: null, user_id: 'user-1', storage_path: 'p', file_name: 'kvitto.pdf', file_size_bytes: 1, mime_type: 'application/pdf', sha256_hash: 'x', uploaded_by: 'user-1', upload_source: 'file_upload' } }) // document lookup
enqueue({ data: null }) // inbox item update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'Kvitto',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 0,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Joakim',
document_id: 'doc-1',
inbox_item_id: 'inbox-1',
})
expect(result.ok).toBe(true)
expect(linkToJournalEntryMock).toHaveBeenCalledWith(sb, COMPANY, 'doc-1', 'je-1')
})
it('copies an already-anchored receipt instead of re-pointing it (BFL immutability)', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'claim-1' } }) // insert
enqueue({ data: null }) // journal_entry_id update
enqueue({ data: { journal_entry_id: 'je-old', user_id: 'user-1', storage_path: 'receipts/plaud.pdf', file_name: 'plaud.pdf', file_size_bytes: 42, mime_type: 'application/pdf', sha256_hash: 'abc', uploaded_by: 'user-1', upload_source: 'file_upload' } }) // document lookup
enqueue({ data: { id: 'doc-copy' } }) // attachment copy insert
enqueue({ data: null }) // claim document_id update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'Kvitto',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 0,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Joakim',
document_id: 'doc-1',
})
expect(result.ok).toBe(true)
expect(linkToJournalEntryMock).not.toHaveBeenCalled()
const copy = findCall('document_attachments', 'insert')
expect(copy?.[0]).toMatchObject({
storage_path: 'receipts/plaud.pdf',
sha256_hash: 'abc',
journal_entry_id: 'je-1',
})
})
it('books custom lines (reverse charge) converted at the claim rate', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'claim-1' } }) // insert
enqueue({ data: null }) // update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'Plaud Annual',
expense_date: '2026-09-01',
amount: 299.99,
vat_amount: 0,
currency: 'USD',
exchange_rate: 10,
expense_account: '4531',
claimant_name: 'Joakim',
lines: [
{ account_number: '4531', debit_amount: 239.99, credit_amount: 0 },
{ account_number: '6992', debit_amount: 60, credit_amount: 0 },
{ account_number: '2645', debit_amount: 60, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 60 },
{ account_number: '2893', debit_amount: 0, credit_amount: 299.99 },
],
})
expect(result.ok).toBe(true)
const input = createJournalEntryMock.mock.calls[0][3]
const byAccount = Object.fromEntries(input.lines.map((l: { account_number: string }) => [l.account_number, l]))
expect(byAccount['2893'].credit_amount).toBe(2999.9)
expect(byAccount['4531'].debit_amount).toBeCloseTo(2399.9, 1)
expect(byAccount['2614'].credit_amount).toBe(600)
// Displayed VAT: no 2641 line, so the claim carries zero deductible VAT.
const insert = findCall('expense_claims', 'insert')
expect(insert?.[0]).toMatchObject({ vat_sek: 0 })
})
it('rejects unbalanced custom lines before touching the ledger', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'x',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 0,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Joakim',
lines: [
{ account_number: '5410', debit_amount: 90, credit_amount: 0 },
{ account_number: '2893', debit_amount: 0, credit_amount: 100 },
],
})
expect(result).toMatchObject({ ok: false, code: 'INVALID_LINES' })
expect(createJournalEntryMock).not.toHaveBeenCalled()
})
it('rejects custom lines whose liability credit does not match the gross', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'x',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 0,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Joakim',
lines: [
{ account_number: '5410', debit_amount: 90, credit_amount: 0 },
{ account_number: '2893', debit_amount: 0, credit_amount: 90 },
],
})
expect(result).toMatchObject({ ok: false, code: 'INVALID_LINES' })
})
it('removes the claim row again when the booking throws', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'claim-1' } }) // insert
enqueue({ data: null }) // delete (cleanup)
createJournalEntryMock.mockRejectedValue(new Error('period locked'))
await expect(
registerExpenseClaim(sb, COMPANY, USER, {
description: 'x',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 0,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Joakim',
}),
).rejects.toThrow('period locked')
expect(findCall('expense_claims', 'delete')).toBeTruthy()
})
})
describe('createPayoutBatch', () => {
const rpcCalls = () => (sb.rpc as unknown as { mock: { calls: unknown[][] } }).mock.calls
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('returns NO_CLAIMS without calling the RPC', async () => {
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: [],
payout_date: '2026-09-05',
cash_account: '1935',
})
expect(result).toEqual({ ok: false, code: 'NO_CLAIMS' })
expect(rpcCalls()).toHaveLength(0)
})
it('books the payout through the atomic RPC with deduplicated claim ids', async () => {
enqueue({
data: {
ok: true,
batch_id: 'batch-1',
journal_entry_id: 'je-2',
voucher_number: 7,
total_sek: '2101.77',
claim_count: 2,
},
})
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c1', 'c2', 'c1'],
payout_date: '2026-09-05',
cash_account: '1935',
notes: 'Septemberutlägg',
})
expect(result).toEqual({
ok: true,
batch_id: 'batch-1',
journal_entry_id: 'je-2',
voucher_number: 7,
total_sek: 2101.77,
claim_count: 2,
})
expect(rpcCalls()).toHaveLength(1)
expect(rpcCalls()[0][0]).toBe('create_expense_payout_batch')
expect(rpcCalls()[0][1]).toEqual({
p_company_id: COMPANY,
p_claim_ids: ['c1', 'c2'],
p_payout_date: '2026-09-05',
p_cash_account: '1935',
p_notes: 'Septemberutlägg',
p_user_id: USER,
p_transaction_id: null,
})
// No journal write happens outside the RPC.
expect(createJournalEntryMock).not.toHaveBeenCalled()
expect(reverseEntryMock).not.toHaveBeenCalled()
})
it('forwards the bank transaction so the RPC links it in the same transaction', async () => {
enqueue({
data: { ok: true, batch_id: 'batch-2', journal_entry_id: 'je-3', voucher_number: 8, total_sek: 1596, claim_count: 2 },
})
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c2', 'c3'],
payout_date: '2026-09-10',
cash_account: '1930',
transaction_id: 'tx-1',
})
expect(result).toMatchObject({ ok: true, batch_id: 'batch-2', journal_entry_id: 'je-3' })
expect(rpcCalls()[0][1]).toMatchObject({ p_transaction_id: 'tx-1', p_payout_date: '2026-09-10' })
})
it('echoes the bank-line refusals (amount mismatch, already booked) as typed codes', async () => {
enqueue({ data: { ok: false, code: 'TX_AMOUNT_MISMATCH', details: { transaction_amount: -1500, claims_total: 1596 } } })
const mismatch = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c2'],
payout_date: '2026-09-10',
cash_account: '1930',
transaction_id: 'tx-1',
})
expect(mismatch).toMatchObject({ ok: false, code: 'TX_AMOUNT_MISMATCH' })
enqueue({ data: { ok: false, code: 'TX_ALREADY_BOOKED' } })
const booked = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c2'],
payout_date: '2026-09-10',
cash_account: '1930',
transaction_id: 'tx-1',
})
expect(booked).toMatchObject({ ok: false, code: 'TX_ALREADY_BOOKED' })
})
it('echoes a refusal code from the RPC (claims already paid by a concurrent request)', async () => {
enqueue({ data: { ok: false, code: 'ALREADY_PAID', details: { claim_id: 'c1' } } })
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c1'],
payout_date: '2026-09-05',
cash_account: '1935',
})
expect(result).toEqual({ ok: false, code: 'ALREADY_PAID', detail: '{"claim_id":"c1"}' })
})
it('maps an unknown refusal code to BATCH_INSERT_FAILED', async () => {
enqueue({ data: { ok: false, code: 'SOMETHING_NEW' } })
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c1'],
payout_date: '2026-09-05',
cash_account: '1935',
})
expect(result).toMatchObject({ ok: false, code: 'BATCH_INSERT_FAILED', detail: 'SOMETHING_NEW' })
})
it('reports a database error (period lock trigger) as BATCH_INSERT_FAILED with the message', async () => {
enqueue({ data: null, error: { message: 'Perioden är låst', code: 'P0001' } })
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c1'],
payout_date: '2026-09-05',
cash_account: '1935',
})
expect(result).toEqual({ ok: false, code: 'BATCH_INSERT_FAILED', detail: 'Perioden är låst' })
})
})
describe('deleteExpenseClaim', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
reverseEntryMock.mockResolvedValue({ id: 'je-storno' })
findPayslipLineForClaimMock.mockResolvedValue(null)
})
it('refuses a claim scheduled on a payslip that has left draft, before any storno', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
findPayslipLineForClaimMock.mockResolvedValue({
line_id: 'li-1',
salary_run_id: 'run-1',
run_status: 'review',
period_year: 2026,
period_month: 6,
})
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toMatchObject({ ok: false, code: 'ON_PAYSLIP' })
expect(reverseEntryMock).not.toHaveBeenCalled()
expect(findCall('expense_claims', 'delete')).toBeUndefined()
})
it('on a draft payslip removes the line first (the FK is RESTRICT), then the storno, then the claim', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
enqueue({ data: null }) // salary_line_items delete
enqueue({ data: { status: 'posted', reversed_by_id: null } })
enqueue({ data: null }) // expense_claims delete
findPayslipLineForClaimMock.mockResolvedValue({
line_id: 'li-1',
salary_run_id: 'run-1',
run_status: 'draft',
period_year: 2026,
period_month: 6,
})
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toEqual({ ok: true, reversal_entry_id: 'je-storno' })
expect(findCall('salary_line_items', 'delete')).toBeTruthy()
expect(findCall('salary_line_items', 'eq')).toEqual(['id', 'li-1'])
expect(findCall('expense_claims', 'delete')).toBeTruthy()
expect(reverseEntryMock).toHaveBeenCalledWith(sb, COMPANY, USER, 'je-1')
})
it('stops before the storno when the draft line cannot be removed', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
enqueue({ data: null, error: { message: 'permission denied' } }) // salary_line_items delete
findPayslipLineForClaimMock.mockResolvedValue({
line_id: 'li-1',
salary_run_id: 'run-1',
run_status: 'draft',
period_year: 2026,
period_month: 6,
})
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toEqual({ ok: false, code: 'DELETE_FAILED', detail: 'permission denied' })
expect(reverseEntryMock).not.toHaveBeenCalled()
expect(findCall('expense_claims', 'delete')).toBeUndefined()
})
it('reverses the verifikat and removes the row', async () => {
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
enqueue({ data: { status: 'posted', reversed_by_id: null } }) // entry status
enqueue({ data: null }) // delete
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toEqual({ ok: true, reversal_entry_id: 'je-storno' })
expect(reverseEntryMock).toHaveBeenCalledWith(sb, COMPANY, USER, 'je-1')
expect(findCall('expense_claims', 'delete')).toBeTruthy()
})
it('refuses a paid claim', async () => {
enqueue({ data: { id: 'c1', status: 'paid', journal_entry_id: 'je-1' } })
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toEqual({ ok: false, code: 'ALREADY_PAID' })
expect(reverseEntryMock).not.toHaveBeenCalled()
})
it('reuses an existing storno when a previous delete already reversed the entry', async () => {
// Retry after a delete that failed with the storno already posted: the
// entry is 'reversed', so reverseEntry would refuse it.
enqueue({ data: { id: 'c1', status: 'registered', journal_entry_id: 'je-1' } })
enqueue({ data: { status: 'reversed', reversed_by_id: 'je-storno-1' } })
enqueue({ data: null }) // delete
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c1')
expect(result).toEqual({ ok: true, reversal_entry_id: 'je-storno-1' })
expect(reverseEntryMock).not.toHaveBeenCalled()
})
it('answers NOT_FOUND for an unknown claim', async () => {
enqueue({ data: null })
const result = await deleteExpenseClaim(sb, COMPANY, USER, 'c-x')
expect(result).toEqual({ ok: false, code: 'NOT_FOUND' })
})
})
describe('createPayoutBatch: claim scheduled on a payslip (#2331)', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('echoes ON_PAYSLIP as a typed refusal instead of BATCH_INSERT_FAILED', async () => {
enqueue({
data: { ok: false, code: 'ON_PAYSLIP', details: { claim_id: 'c1', salary_run_id: 'run-1', period: '2026-06' } },
})
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c1'],
payout_date: '2026-06-30',
cash_account: '1930',
})
expect(result).toEqual({
ok: false,
code: 'ON_PAYSLIP',
detail: '{"claim_id":"c1","salary_run_id":"run-1","period":"2026-06"}',
})
})
})
describe('registerExpenseClaim: custom lines from a supplier invoice', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
findFiscalPeriodMock.mockResolvedValue('period-1')
createJournalEntryMock.mockResolvedValue({ id: 'je-1' })
})
it('carries each line dimension bag onto the posted verifikat (dimensions PR7)', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'claim-1' } }) // insert
enqueue({ data: null }) // update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'Faktura LF-001, Pressbyrån (ankomstnr 1)',
expense_date: '2026-09-01',
amount: 500,
vat_amount: 100,
currency: 'SEK',
expense_account: '6110',
claimant_name: 'Ägare',
lines: [
{ account_number: '6110', debit_amount: 400, credit_amount: 0, dimensions: { '1': 'KS01', '6': 'P001' } },
{ account_number: '2641', debit_amount: 100, credit_amount: 0, dimensions: { '1': 'KS01' } },
{ account_number: '2893', debit_amount: 0, credit_amount: 500, dimensions: { '1': 'KS01' } },
],
})
expect(result.ok).toBe(true)
const input = createJournalEntryMock.mock.calls[0][3]
const byAccount = Object.fromEntries(
input.lines.map((l: { account_number: string }) => [l.account_number, l]),
)
expect(byAccount['6110'].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(byAccount['2641'].dimensions).toEqual({ '1': 'KS01' })
expect(byAccount['2893'].dimensions).toEqual({ '1': 'KS01' })
// A line without a bag posts without the key, not with dimensions: undefined.
expect(byAccount['2893'].credit_amount).toBe(500)
})
it('a line without a bag posts without a dimensions key', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: { id: 'claim-1' } })
enqueue({ data: null })
await registerExpenseClaim(sb, COMPANY, USER, {
description: 'Kvitto',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 0,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Ägare',
lines: [
{ account_number: '5410', debit_amount: 100, credit_amount: 0 },
{ account_number: '2893', debit_amount: 0, credit_amount: 100 },
],
})
const input = createJournalEntryMock.mock.calls[0][3]
for (const line of input.lines) expect(line).not.toHaveProperty('dimensions')
})
})