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.
This commit is contained in:
Joakim Hansson
2026-09-05 13:36:51 +02:00
committed by GitHub
parent f7f40c04e4
commit 397a3b9bca
24 changed files with 5085 additions and 5 deletions
+62
View File
@@ -292,6 +292,8 @@ export const JournalEntrySourceTypeSchema = z.enum([
'vat_settlement',
'stripe_payout',
'webshop_order',
'expense_claim',
'expense_payout',
])
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
@@ -3789,6 +3791,66 @@ export const ByraBrandUpdateSchema = z.object({
// Körjournal (mileage trips)
// ============================================================
// ============ Expense claims (utlägg) ============
const expenseCurrency = z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
export const CreateExpenseClaimSchema = z
.object({
description: z.string().trim().min(1).max(300),
expense_date: saneIsoDate,
/** Gross incl VAT, in `currency`. */
amount: z.number().positive(),
/** Deductible VAT part of `amount`, in `currency`. */
vat_amount: z.number().nonnegative().default(0),
currency: expenseCurrency.default('SEK'),
exchange_rate: z.number().positive().optional(),
expense_account: accountNumberSchema.refine((a) => /^[4-8]/.test(a), {
message: 'Kostnadskontot måste vara ett resultatkonto (klass 4-8)',
}),
employee_id: uuid.optional().nullable(),
claimant_name: z.string().trim().max(200).optional(),
document_id: uuid.optional().nullable(),
inbox_item_id: uuid.optional().nullable(),
/** Advanced booking: full verifikat lines in claim currency. Deep
* validation (balance, liability line) happens in the service. */
lines: z
.array(
z.object({
account_number: accountNumberSchema,
debit_amount: z.number().nonnegative().default(0),
credit_amount: z.number().nonnegative().default(0),
line_description: z.string().trim().max(300).optional().nullable(),
}),
)
.min(2)
.max(20)
.optional(),
})
.superRefine((data, ctx) => {
if (!data.lines && data.vat_amount >= data.amount) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Momsen måste vara mindre än totalbeloppet.',
path: ['vat_amount'],
})
}
if (!data.employee_id && !data.claimant_name?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Ange vem utlägget avser: välj anställd eller skriv ett namn.',
path: ['claimant_name'],
})
}
})
export const CreateExpensePayoutSchema = z.object({
claim_ids: z.array(uuid).min(1).max(200),
payout_date: saneIsoDate,
cash_account: z.string().regex(/^19\d{2}$/, 'Ange ett likvidkonto i 19xx-serien'),
notes: z.string().trim().max(1000).optional(),
})
const mileageVehicleType = z.enum(['own_car', 'company_car_fossil', 'company_car_electric'])
export const CreateMileageTripSchema = z
@@ -0,0 +1,508 @@
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' })
})
})
+634
View File
@@ -0,0 +1,634 @@
/**
* Expense claims (utlägg): out-of-pocket purchases booked against an
* owner/employee liability, reimbursed later in payout batches.
*
* Registering a claim posts a verifikat immediately:
*
* Debit expense account (gross − VAT)
* Debit 2641 Ingående moms (VAT, when > 0)
* Credit liability (gross) 2893 owner / 2820 employee / 2018 EF
*
* A payout batch reimburses N registered claims for ONE claimant in one bank
* transfer:
*
* Debit liability (batch total)
* Credit 19xx cash account (batch total)
*
* Amounts are converted to SEK before booking; the original currency and
* rate are stored on the claim as provenance (BFL: bokföring i redovisnings-
* valutan). All rounding through roundOre.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Currency } from '@/types'
import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/types'
import { createJournalEntry, findFiscalPeriod, reverseEntry } from '@/lib/bookkeeping/engine'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { roundOre, sumOre } from '@/lib/money'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants'
import { createLogger } from '@/lib/logger'
const log = createLogger('expenses/claims')
export const EXPENSE_LIABILITY_ACCOUNTS = ['2893', '2820', '2018', '2890'] as const
export type ExpenseLiabilityAccount = (typeof EXPENSE_LIABILITY_ACCOUNTS)[number]
export interface ExpenseClaimRow {
id: string
company_id: string
employee_id: string | null
claimant_name: string
description: string
expense_date: string
amount_sek: number
vat_sek: number
currency: string
amount_in_currency: number | null
exchange_rate: number | null
expense_account: string
liability_account: string
document_id: string | null
status: 'registered' | 'paid'
journal_entry_id: string | null
payout_batch_id: string | null
created_at: string
}
export interface RegisterExpenseClaimInput {
description: string
expense_date: string
/** Gross amount incl VAT, in `currency`. */
amount: number
/** Deductible VAT part of `amount`, in `currency`. */
vat_amount: number
currency: Currency
/** Optional explicit rate; omitted → Riksbanken (cached) for expense_date. */
exchange_rate?: number
expense_account: string
/** Defaults per claimant kind: employee → 2820, otherwise → 2893. */
employee_id?: string
/** Required when employee_id is absent (e.g. the owner's name). */
claimant_name?: string
document_id?: string
inbox_item_id?: string
/**
* Custom verifikat lines in claim currency (the advanced booking step:
* reverse charge, templates, manual rows). When present they replace the
* generated cost/VAT lines entirely. Must balance, and must contain
* exactly one credit line on the liability account equal to `amount`.
*/
lines?: ExpenseClaimLineInput[]
}
export interface ExpenseClaimLineInput {
account_number: string
debit_amount: number
credit_amount: number
line_description?: string | null
}
export type RegisterExpenseClaimResult =
| { ok: true; claim: ExpenseClaimRow }
| {
ok: false
code:
| 'EMPLOYEE_NOT_FOUND'
| 'CLAIMANT_REQUIRED'
| 'RATE_UNAVAILABLE'
| 'VAT_EXCEEDS_AMOUNT'
| 'INVALID_LINES'
| 'FISCAL_PERIOD_NOT_FOUND'
| 'CLAIM_INSERT_FAILED'
| 'COMPANY_NOT_FOUND'
| 'LINK_WRITE_FAILED'
detail?: string
}
export async function registerExpenseClaim(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: RegisterExpenseClaimInput,
): Promise<RegisterExpenseClaimResult> {
if (!input.lines && (input.vat_amount < 0 || input.vat_amount >= input.amount)) {
return { ok: false, code: 'VAT_EXCEEDS_AMOUNT' }
}
// Resolve claimant + liability account. Entity type drives the owner
// account, same resolver as the privately-paid supplier-invoice path:
// AB owners are creditors (2893), enskild firma owners make egna
// insättningar (2018); employees are 2820 regardless of entity type.
const { data: company } = await supabase
.from('companies')
.select('entity_type')
.eq('id', companyId)
.single()
if (!company?.entity_type) return { ok: false, code: 'COMPANY_NOT_FOUND' }
const ownerLiability = company.entity_type === 'enskild_firma' ? '2018' : '2893'
let claimantName = input.claimant_name?.trim() ?? ''
let employeeId: string | null = null
let liability: string = ownerLiability
if (input.employee_id) {
const { data: emp } = await supabase
.from('employees')
.select('id, first_name, last_name')
.eq('id', input.employee_id)
.eq('company_id', companyId)
.maybeSingle()
if (!emp) return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
employeeId = emp.id
// The employee row is the authority: a caller must not be able to pair
// one employee_id with another person's name, which would book 2820 for
// the employee while payout lists and descriptions name someone else.
claimantName = `${emp.first_name} ${emp.last_name}`.trim()
liability = '2820'
}
if (!claimantName) return { ok: false, code: 'CLAIMANT_REQUIRED' }
// Custom lines: validate in claim currency before any conversion.
if (input.lines) {
const lines = input.lines
if (lines.length < 2 || lines.length > 20) {
return { ok: false, code: 'INVALID_LINES', detail: 'line count' }
}
for (const line of lines) {
const debit = line.debit_amount || 0
const credit = line.credit_amount || 0
if (!ACCOUNT_NUMBER_RE.test(line.account_number)) {
return { ok: false, code: 'INVALID_LINES', detail: `account ${line.account_number}` }
}
if (debit < 0 || credit < 0 || (debit > 0) === (credit > 0)) {
return { ok: false, code: 'INVALID_LINES', detail: 'each line needs exactly one side' }
}
}
const sumDebit = sumOre(lines.map((l) => l.debit_amount || 0))
const sumCredit = sumOre(lines.map((l) => l.credit_amount || 0))
if (Math.abs(sumDebit - sumCredit) > 0.005) {
return { ok: false, code: 'INVALID_LINES', detail: 'unbalanced' }
}
// The payout flow reimburses claim.amount_sek from the liability account,
// so the verifikat must carry exactly that credit: one line, right
// account, right amount.
const liabilityLines = lines.filter((l) => l.account_number === liability && (l.credit_amount || 0) > 0)
if (liabilityLines.length !== 1 || Math.abs((liabilityLines[0].credit_amount || 0) - input.amount) > 0.005) {
return { ok: false, code: 'INVALID_LINES', detail: `liability line must credit ${liability} with the gross amount` }
}
}
// Convert to SEK. The claim total is gross; VAT converts at the same rate
// so the split stays internally consistent to the öre.
let rate = 1
if (input.currency !== 'SEK') {
if (input.exchange_rate && input.exchange_rate > 0) {
rate = input.exchange_rate
} else {
const fetched = await fetchExchangeRate(
input.currency,
new Date(input.expense_date),
supabase,
)
if (!fetched) return { ok: false, code: 'RATE_UNAVAILABLE' }
rate = fetched.rate
}
}
const amountSek = roundOre(input.amount * rate)
// With custom lines the claim's displayed VAT is the actually debited
// 2641 side (reverse-charge 2614/2645 pairs net to zero and stay out).
const vatSek = input.lines
? roundOre(
sumOre(
input.lines
.filter((l) => l.account_number.startsWith('2641'))
.map((l) => (l.debit_amount || 0) * rate),
),
)
: roundOre(input.vat_amount * rate)
const netSek = roundOre(amountSek - vatSek)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, input.expense_date)
if (!fiscalPeriodId) return { ok: false, code: 'FISCAL_PERIOD_NOT_FOUND' }
// Claim row first, then the verifikat with source_id pointing back at it;
// a failed booking removes the orphan row again.
const { data: claim, error: insertError } = await supabase
.from('expense_claims')
.insert({
company_id: companyId,
user_id: userId,
employee_id: employeeId,
claimant_name: claimantName,
description: input.description,
expense_date: input.expense_date,
amount_sek: amountSek,
vat_sek: vatSek,
currency: input.currency,
amount_in_currency: input.currency === 'SEK' ? null : roundOre(input.amount),
exchange_rate: input.currency === 'SEK' ? null : rate,
expense_account: input.expense_account,
liability_account: liability,
document_id: input.document_id ?? null,
status: 'registered',
})
.select('*')
.single()
if (insertError || !claim) {
return { ok: false, code: 'CLAIM_INSERT_FAILED', detail: insertError?.message }
}
const desc = `Utlägg: ${input.description} (${claimantName})`
let customLines: CreateJournalEntryLineInput[] | null = null
if (input.lines) {
// Convert each custom line at the claim rate; the per-line öre rounding
// can leave a residual, which lands on the largest non-liability line so
// the liability credit stays exactly amount_sek (the payout contract).
const converted = input.lines.map((l) => ({
account_number: l.account_number,
debit_amount: (l.debit_amount || 0) > 0 ? roundOre(l.debit_amount * rate) : 0,
credit_amount:
l.account_number === liability
? amountSek
: (l.credit_amount || 0) > 0
? roundOre(l.credit_amount * rate)
: 0,
line_description: l.line_description?.trim() || desc,
}))
const residual = roundOre(
sumOre(converted.map((l) => l.debit_amount)) - sumOre(converted.map((l) => l.credit_amount)),
)
if (residual !== 0) {
const target = converted
.filter((l) => l.account_number !== liability)
.sort((a, b) => (b.debit_amount + b.credit_amount) - (a.debit_amount + a.credit_amount))[0]
if (!target) return { ok: false, code: 'INVALID_LINES', detail: 'no adjustable line' }
if (target.debit_amount > 0) target.debit_amount = roundOre(target.debit_amount - residual)
else target.credit_amount = roundOre(target.credit_amount + residual)
if (target.debit_amount < 0 || target.credit_amount < 0) {
return { ok: false, code: 'INVALID_LINES', detail: 'rounding residual exceeds line' }
}
}
customLines = converted.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description,
...(input.currency !== 'SEK' && l.account_number === liability
? { currency: input.currency, amount_in_currency: roundOre(input.amount), exchange_rate: rate }
: {}),
}))
}
const lines: CreateJournalEntryLineInput[] = [
{
account_number: input.expense_account,
debit_amount: netSek,
credit_amount: 0,
line_description: desc,
...(input.currency !== 'SEK'
? {
currency: input.currency,
amount_in_currency: roundOre(input.amount - input.vat_amount),
exchange_rate: rate,
}
: {}),
},
]
if (vatSek > 0) {
lines.push({
account_number: '2641',
debit_amount: vatSek,
credit_amount: 0,
line_description: `Ingående moms, ${desc}`,
})
}
lines.push({
account_number: liability,
debit_amount: 0,
credit_amount: amountSek,
line_description: desc,
})
const entryInput: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: input.expense_date,
description: desc,
source_type: 'expense_claim',
source_id: claim.id,
lines: customLines ?? lines,
}
let journalEntryId: string
try {
const entry = await createJournalEntry(supabase, companyId, userId, entryInput)
journalEntryId = entry.id
} catch (err) {
await supabase.from('expense_claims').delete().eq('id', claim.id).eq('company_id', companyId)
throw err
}
const { error: linkError } = await supabase
.from('expense_claims')
.update({ journal_entry_id: journalEntryId })
.eq('id', claim.id)
.eq('company_id', companyId)
if (linkError) {
// The verifikat is posted and immutable; without the back-link the claim
// cannot be storno-deleted or paid out safely, so surface it loudly.
return {
ok: false,
code: 'LINK_WRITE_FAILED',
detail: `claim ${claim.id} posted as entry ${journalEntryId}: ${linkError.message}`,
}
}
// Attach the receipt to the verifikat and settle the inbox item, both
// best-effort: the booking above is the legally significant part.
if (input.document_id) {
try {
const { data: doc } = await supabase
.from('document_attachments')
.select('journal_entry_id, user_id, storage_path, file_name, file_size_bytes, mime_type, sha256_hash, uploaded_by, upload_source')
.eq('id', input.document_id)
.eq('company_id', companyId)
.maybeSingle()
const anchoredTo = (doc?.journal_entry_id as string | null) ?? null
if (doc && anchoredTo === null) {
await linkToJournalEntry(supabase, companyId, input.document_id, journalEntryId)
} else if (doc && anchoredTo !== journalEntryId) {
// Anchored to another verifikat (typically a stornoed booking that
// this claim replaces). BFL 5 kap 6 § forbids re-pointing an anchored
// document, so reference the same stored file from a new attachment
// row instead of stealing the old one.
const { data: copy, error: copyError } = await supabase
.from('document_attachments')
.insert({
company_id: companyId,
user_id: doc.user_id,
storage_path: doc.storage_path,
file_name: doc.file_name,
file_size_bytes: doc.file_size_bytes,
mime_type: doc.mime_type,
sha256_hash: doc.sha256_hash,
uploaded_by: doc.uploaded_by,
upload_source: doc.upload_source,
journal_entry_id: journalEntryId,
})
.select('id')
.single()
if (copyError || !copy) {
throw new Error(copyError?.message ?? 'attachment copy insert failed')
}
await supabase
.from('expense_claims')
.update({ document_id: copy.id })
.eq('id', claim.id)
.eq('company_id', companyId)
}
} catch (err) {
log.warn('expense claim receipt could not be attached to the verifikat', {
claim_id: claim.id,
document_id: input.document_id,
journal_entry_id: journalEntryId,
error: err instanceof Error ? err.message : String(err),
})
}
}
if (input.inbox_item_id) {
// "Processed" is derived from created_journal_entry_id; the status column
// only tracks the extraction pipeline (received/processing/error).
await supabase
.from('invoice_inbox_items')
.update({ created_journal_entry_id: journalEntryId })
.eq('id', input.inbox_item_id)
.eq('company_id', companyId)
}
return {
ok: true,
claim: { ...(claim as ExpenseClaimRow), journal_entry_id: journalEntryId },
}
}
export interface ListExpenseClaimsOptions {
status?: 'registered' | 'paid'
}
export async function listExpenseClaims(
supabase: SupabaseClient,
companyId: string,
options: ListExpenseClaimsOptions = {},
): Promise<ExpenseClaimRow[]> {
let query = supabase
.from('expense_claims')
.select('*, document:document_attachments(id, file_name), batch:expense_payout_batches(id, payout_date, journal_entry_id)')
.eq('company_id', companyId)
.order('expense_date', { ascending: false })
.order('created_at', { ascending: false })
if (options.status) query = query.eq('status', options.status)
const { data, error } = await query
if (error) throw new Error(`Failed to list expense claims: ${error.message}`)
return (data ?? []) as ExpenseClaimRow[]
}
export type DeleteExpenseClaimResult =
| { ok: true; reversal_entry_id: string | null }
| { ok: false; code: 'NOT_FOUND' | 'ALREADY_PAID' | 'UNLINKED' | 'DELETE_FAILED'; detail?: string }
/**
* Remove a registered claim. The booked verifikat is never deleted: it is
* reversed with a storno entry (BFL 5 kap 5 §), then the register row goes.
* The receipt stays linked to the original entry, so the 7-year archive is
* untouched. Paid claims are refused: undo the payout first.
*/
export async function deleteExpenseClaim(
supabase: SupabaseClient,
companyId: string,
userId: string,
claimId: string,
): Promise<DeleteExpenseClaimResult> {
const { data: claim, error } = await supabase
.from('expense_claims')
.select('id, status, journal_entry_id')
.eq('id', claimId)
.eq('company_id', companyId)
.maybeSingle()
if (error) return { ok: false, code: 'DELETE_FAILED', detail: error.message }
if (!claim) return { ok: false, code: 'NOT_FOUND' }
if (claim.status === 'paid') return { ok: false, code: 'ALREADY_PAID' }
if (!claim.journal_entry_id) {
// Registered claims always book a verifikat; a missing link means the
// back-link write failed. Hard-deleting would orphan the posted entry.
return { ok: false, code: 'UNLINKED', detail: `claim ${claimId} has no journal_entry_id` }
}
// Retry safety: if a previous attempt posted the storno but failed to
// delete the register row, the entry is already 'reversed' and
// reverseEntry would refuse it (CannotReverseNonPostedError). Reuse the
// existing reversal and finish the delete instead of dead-ending the row.
const { data: entry } = await supabase
.from('journal_entries')
.select('status, reversed_by_id')
.eq('id', claim.journal_entry_id)
.eq('company_id', companyId)
.maybeSingle()
let reversalEntryId: string | null
if (entry?.status === 'reversed') {
reversalEntryId = entry.reversed_by_id ?? null
} else {
const reversal = await reverseEntry(supabase, companyId, userId, claim.journal_entry_id)
reversalEntryId = reversal.id
}
const { error: deleteError } = await supabase
.from('expense_claims')
.delete()
.eq('id', claimId)
.eq('company_id', companyId)
if (deleteError) {
// The storno is already posted; report the register desync loudly rather
// than pretending nothing happened.
return { ok: false, code: 'DELETE_FAILED', detail: deleteError.message }
}
return { ok: true, reversal_entry_id: reversalEntryId }
}
export interface CreatePayoutBatchInput {
claim_ids: string[]
payout_date: string
cash_account: string
notes?: string
}
export type CreatePayoutBatchFailureCode =
| 'NO_CLAIMS'
| 'CLAIMS_NOT_FOUND'
| 'ALREADY_PAID'
| 'MIXED_CLAIMANTS'
| 'MIXED_LIABILITY'
| 'FISCAL_PERIOD_NOT_FOUND'
| 'PERIOD_LOCKED'
| 'ACCOUNT_NOT_IN_CHART'
| 'INVALID_CASH_ACCOUNT'
| 'FORBIDDEN'
| 'BATCH_INSERT_FAILED'
export type CreatePayoutBatchResult =
| {
ok: true
batch_id: string
journal_entry_id: string
voucher_number: number | null
total_sek: number
claim_count: number
}
| { ok: false; code: CreatePayoutBatchFailureCode; detail?: string }
const PAYOUT_RPC_CODES: ReadonlySet<string> = new Set<CreatePayoutBatchFailureCode>([
'NO_CLAIMS',
'CLAIMS_NOT_FOUND',
'ALREADY_PAID',
'MIXED_CLAIMANTS',
'MIXED_LIABILITY',
'FISCAL_PERIOD_NOT_FOUND',
'PERIOD_LOCKED',
'ACCOUNT_NOT_IN_CHART',
'INVALID_CASH_ACCOUNT',
'FORBIDDEN',
])
interface PayoutRpcRow {
ok: boolean
code?: string
details?: unknown
batch_id?: string
journal_entry_id?: string
voucher_number?: number | null
total_sek?: number | string
claim_count?: number
}
/**
* Reimburse N registered claims for one claimant in one bank transfer.
*
* Everything happens inside the create_expense_payout_batch RPC (migration
* 20260904171000): the claims are locked FOR UPDATE, the liability -> cash
* verifikat is posted through commit_journal_entry, the batch is linked and
* the claims are marked paid, all in one transaction. A concurrent or
* retried request for the same claims queues on the lock and is refused
* with ALREADY_PAID, so a double click can never book a second transfer.
* The claimant/liability/status rules are enforced by the RPC and echoed
* here as result codes.
*/
export async function createPayoutBatch(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: CreatePayoutBatchInput,
): Promise<CreatePayoutBatchResult> {
const claimIds = [...new Set(input.claim_ids)]
if (claimIds.length === 0) return { ok: false, code: 'NO_CLAIMS' }
const { data, error } = await supabase.rpc('create_expense_payout_batch', {
p_company_id: companyId,
p_claim_ids: claimIds,
p_payout_date: input.payout_date,
p_cash_account: input.cash_account,
p_notes: input.notes ?? null,
// Honored only for service-role callers (API-key / MCP paths run on the
// cookieless service client where auth.uid() is NULL); an authenticated
// caller is pinned to its own auth.uid() by the RPC.
p_user_id: userId,
})
if (error) {
// Period-lock and lock-date triggers surface here as Postgres errors;
// the message is the trigger's own text, which the route maps for the
// user. Sanitised log: code + message only.
log.error('create_expense_payout_batch RPC error', {
companyId,
code: (error as { code?: string }).code,
message: error.message,
})
return { ok: false, code: 'BATCH_INSERT_FAILED', detail: error.message }
}
const row = (data ?? null) as PayoutRpcRow | null
if (!row) return { ok: false, code: 'BATCH_INSERT_FAILED', detail: 'empty RPC response' }
if (!row.ok) {
const code = row.code && PAYOUT_RPC_CODES.has(row.code)
? (row.code as CreatePayoutBatchFailureCode)
: 'BATCH_INSERT_FAILED'
return {
ok: false,
code,
detail: row.details ? JSON.stringify(row.details) : row.code,
}
}
if (!row.batch_id || !row.journal_entry_id) {
return { ok: false, code: 'BATCH_INSERT_FAILED', detail: 'RPC returned ok without ids' }
}
return {
ok: true,
batch_id: row.batch_id,
journal_entry_id: row.journal_entry_id,
voucher_number: row.voucher_number ?? null,
total_sek: roundOre(Number(row.total_sek ?? 0)),
claim_count: row.claim_count ?? claimIds.length,
}
}
export async function listPayoutBatches(
supabase: SupabaseClient,
companyId: string,
): Promise<Record<string, unknown>[]> {
const { data, error } = await supabase
.from('expense_payout_batches')
.select('*')
.eq('company_id', companyId)
.order('payout_date', { ascending: false })
if (error) throw new Error(`Failed to list payout batches: ${error.message}`)
return data ?? []
}
+2
View File
@@ -1109,6 +1109,8 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
// Körjournal: trip log underlag for milersättning verifikat (BFL 7-year
// retention per Skatteverket's körjournal documentation requirement).
{ name: 'mileage_trips', file: 'mileage_trips.json', orderBy: 'trip_date' },
{ name: 'expense_claims', file: 'expense_claims.json', orderBy: 'expense_date' },
{ name: 'expense_payout_batches', file: 'expense_payout_batches.json', orderBy: 'payout_date' },
// Assets and accruals
{ name: 'assets', file: 'assets.json', orderBy: 'created_at' },
{ name: 'depreciation_schedules', file: 'depreciation_schedules.json', orderBy: 'created_at' },