Files
accounted/lib/expenses/__tests__/expense-claims-service.test.ts
T
Joakim Hansson 397a3b9bca feat(expenses): expense claims module (utlägg) (#2145)
Contributed by @joakimhew. Maintainer commits on top: migration re-versioned to 20260904170000 (main's 20260901210000 took the original version), payout batches booked atomically through the create_expense_payout_batch RPC, accounted-api skill regenerated, main merged. Closes #2143.
2026-09-05 13:36:51 +02:00

509 lines
19 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),
}))
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,
})
// No journal write happens outside the RPC.
expect(createJournalEntryMock).not.toHaveBeenCalled()
expect(reverseEntryMock).not.toHaveBeenCalled()
})
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' })
})
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' })
})
})