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:
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 ?? []
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user