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:
@@ -1500,6 +1500,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-02] parties phase 0, golden set stays out of git: the labelling sample is prod voucher text with person names (salary, expense claims) and the repo is public, so the draw SQL is versioned but the rows and labels live in gitignored dev_docs/parties/golden/.
|
||||
[2026-09-02] parties substrate: customers and suppliers keep their tables and gain party_id; parties dedupe on normalised org number only, never on name at insert time. A name merge is a recorded human decision because the July measurement showed a majority of generic keys still map to one real vendor, so an automatic name merge would fuse unrelated suppliers.
|
||||
[2026-09-02] MCP eager-auth flag (`auth=required`) on the claude.ai connector links instead of reverting lazy auth: claude.ai's two-step Add-custom-connector dialog probes the URL without credentials and pre-fills Authentication "None" when the lazy handshake answers 200, which blocks the sign-in later; per Anthropic's docs a 401 is the only answer it reads as OAuth. The flag lives in the URL, so the links we control (Settings, onboarding checklist, both docs pages, website) get OAuth detected while the bare URL keeps lazy auth for Claude Code, the plugin, Cursor and ChatGPT, and existing connector records stay untouched. Rejected: keying eager auth off `client=claude-connector` (documented as telemetry-only) and sniffing the probe's user agent (fragile, undocumented).
|
||||
[2026-09-01] Balance badge in the expense booking dialog uses non-breaking spaces instead of whitespace-nowrap: the nowrap-in-dialog guard forbids the class inside DialogContent, and the label is a single semantic token, so NBSP fixes the wrap without touching the guard allowlist.
|
||||
[2026-09-05] Expense payout batches book through the create_expense_payout_batch RPC (claims locked FOR UPDATE, verifikat via commit_journal_entry, claims marked paid, one transaction) instead of the application-side select/insert/post/link/mark sequence with storno compensation: three concurrent identical payout requests each booked a transfer for the same claims in the local end-to-end run, and compensation cannot prevent a race that a row lock prevents by construction.
|
||||
[2026-09-02] Recurring gross deductions keep is_vacation_basis=false on the deduction row (the semester base is NOT reduced): matches the common loneväxling agreement where vacation pay stays on the pre-exchange salary, and diverges deliberately from the manual-line default which follows the row flags as entered. Flagged by review on #2044; change requires a per-line toggle, not a different default.
|
||||
[2026-09-02] Recurring 'other' additions removed from #2042/#2044 scope: calculateSalary only treats ADDITION_TYPES as additions, so a recurring taxable addition would render on the payslip without entering gross/tax/AGA or AGI. Re-add only together with engine support and engine tests.
|
||||
[2026-09-02] Offert detail actions: Acceptera + Skapa faktura in the header, Avboj in the overflow menu: convention 9 (one obvious next step, alternatives behind the caret); declining is the rarer branch.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Auth-wiring + contract tests for DELETE /api/expense-claims/:id. The
|
||||
* service is mocked; these tests pin the 401, the result-code -> status
|
||||
* mapping (404 / 409 / 500) and the happy-path payload.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const deleteMock = vi.fn()
|
||||
vi.mock('@/lib/expenses/expense-claims-service', () => ({
|
||||
deleteExpenseClaim: (...args: unknown[]) => deleteMock(...args),
|
||||
}))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
|
||||
function del(id = 'claim-1') {
|
||||
return DELETE(
|
||||
createMockRequest(`/api/expense-claims/${id}`, { method: 'DELETE' }),
|
||||
{ params: Promise.resolve({ id }) } as never,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DELETE /api/expense-claims/:id', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
deleteMock.mockResolvedValue({ ok: true, reversal_entry_id: 'je-storno' })
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const response = await del()
|
||||
expect(response.status).toBe(401)
|
||||
expect(deleteMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps NOT_FOUND to 404', async () => {
|
||||
deleteMock.mockResolvedValue({ ok: false, code: 'NOT_FOUND' })
|
||||
const response = await del('missing')
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('maps ALREADY_PAID and UNLINKED to 409', async () => {
|
||||
deleteMock.mockResolvedValue({ ok: false, code: 'ALREADY_PAID' })
|
||||
expect((await del()).status).toBe(409)
|
||||
deleteMock.mockResolvedValue({ ok: false, code: 'UNLINKED' })
|
||||
expect((await del()).status).toBe(409)
|
||||
})
|
||||
|
||||
it('maps DELETE_FAILED to 500', async () => {
|
||||
deleteMock.mockResolvedValue({ ok: false, code: 'DELETE_FAILED', detail: 'db down' })
|
||||
expect((await del()).status).toBe(500)
|
||||
})
|
||||
|
||||
it('returns the reversal entry id on success', async () => {
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { id: string; deleted: boolean; reversal_entry_id: string }
|
||||
}>(await del('claim-1'))
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ id: 'claim-1', deleted: true, reversal_entry_id: 'je-storno' })
|
||||
expect(deleteMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'claim-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { deleteExpenseClaim } from '@/lib/expenses/expense-claims-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
const DELETE_ERROR_MESSAGES: Record<string, { message: string; status: number }> = {
|
||||
NOT_FOUND: { message: 'Utlägget hittades inte', status: 404 },
|
||||
ALREADY_PAID: {
|
||||
message: 'Utlägget är redan utbetalt och kan inte tas bort.',
|
||||
status: 409,
|
||||
},
|
||||
UNLINKED: {
|
||||
message: 'Utlägget saknar koppling till sitt verifikat och kan inte tas bort automatiskt.',
|
||||
status: 409,
|
||||
},
|
||||
DELETE_FAILED: { message: 'Utlägget kunde inte tas bort.', status: 500 },
|
||||
}
|
||||
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'expense_claims.delete',
|
||||
async (_request, { supabase, companyId, user, log }, { params }) => {
|
||||
const { id } = await params
|
||||
try {
|
||||
const result = await deleteExpenseClaim(supabase, companyId, user.id, id)
|
||||
if (!result.ok) {
|
||||
const mapped = DELETE_ERROR_MESSAGES[result.code] ?? {
|
||||
message: 'Utlägget kunde inte tas bort.',
|
||||
status: 500,
|
||||
}
|
||||
if (mapped.status >= 500) {
|
||||
log.error('expense claim delete failed', new Error(result.detail ?? result.code))
|
||||
}
|
||||
return NextResponse.json({ error: mapped.message, code: result.code }, { status: mapped.status })
|
||||
}
|
||||
return NextResponse.json({
|
||||
data: { id, deleted: true, reversal_entry_id: result.reversal_entry_id },
|
||||
})
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
if (typed) return typed
|
||||
log.error('failed to delete expense claim', err as Error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(err, { context: 'journal_entry' }) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Auth-wiring + contract tests for /api/expense-claims (GET list, POST
|
||||
* register). The service is mocked; these tests pin the route's 401/403,
|
||||
* validation 400s, the result-code → status mapping, and the 201 shape.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const registerMock = vi.fn()
|
||||
const listMock = vi.fn()
|
||||
vi.mock('@/lib/expenses/expense-claims-service', () => ({
|
||||
registerExpenseClaim: (...args: unknown[]) => registerMock(...args),
|
||||
listExpenseClaims: (...args: unknown[]) => listMock(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
function post(body: unknown) {
|
||||
return createMockRequest('/api/expense-claims', { method: 'POST', body })
|
||||
}
|
||||
|
||||
const validClaim = {
|
||||
description: 'USB-hubb',
|
||||
expense_date: '2026-09-01',
|
||||
amount: 500,
|
||||
vat_amount: 100,
|
||||
expense_account: '5410',
|
||||
claimant_name: 'Joakim Hansson',
|
||||
}
|
||||
|
||||
describe('/api/expense-claims', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
listMock.mockResolvedValue([])
|
||||
registerMock.mockResolvedValue({ ok: true, claim: { id: 'claim-1' } })
|
||||
})
|
||||
|
||||
it('GET returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const response = await GET(createMockRequest('/api/expense-claims'), {} as never)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET lists claims and passes a valid status filter', async () => {
|
||||
listMock.mockResolvedValue([{ id: 'claim-1' }])
|
||||
const response = await GET(
|
||||
createMockRequest('/api/expense-claims?status=registered'),
|
||||
{} as never,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string }[] }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(listMock).toHaveBeenCalledWith(supabase, 'company-1', { status: 'registered' })
|
||||
})
|
||||
|
||||
it('POST returns 403 for a viewer', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
const response = await POST(post(validClaim), {} as never)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('POST registers a claim (201)', async () => {
|
||||
const response = await POST(post(validClaim), {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
expect(status).toBe(201)
|
||||
expect(body.data.id).toBe('claim-1')
|
||||
})
|
||||
|
||||
it('POST rejects VAT >= amount with a field-level 400', async () => {
|
||||
const response = await POST(post({ ...validClaim, vat_amount: 500 }), {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ errors: { field: string }[] }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.errors).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ field: 'vat_amount' })]),
|
||||
)
|
||||
expect(registerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('POST rejects a claim without employee or claimant name', async () => {
|
||||
const { claimant_name: _omitted, ...rest } = validClaim
|
||||
const response = await POST(post(rest), {} as never)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['EMPLOYEE_NOT_FOUND', 404],
|
||||
['RATE_UNAVAILABLE', 400],
|
||||
['FISCAL_PERIOD_NOT_FOUND', 400],
|
||||
['CLAIM_INSERT_FAILED', 500],
|
||||
] as const)('POST maps service code %s to %d', async (code, expected) => {
|
||||
registerMock.mockResolvedValue({ ok: false, code })
|
||||
const response = await POST(post(validClaim), {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ code: string }>(response)
|
||||
expect(status).toBe(expected)
|
||||
expect(body.code).toBe(code)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Contract tests for /api/expense-claims/payouts (GET list, POST create).
|
||||
* The service is mocked; pins auth, validation and code → status mapping.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const createPayoutMock = vi.fn()
|
||||
const listPayoutsMock = vi.fn()
|
||||
vi.mock('@/lib/expenses/expense-claims-service', () => ({
|
||||
createPayoutBatch: (...args: unknown[]) => createPayoutMock(...args),
|
||||
listPayoutBatches: (...args: unknown[]) => listPayoutsMock(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
function post(body: unknown) {
|
||||
return createMockRequest('/api/expense-claims/payouts', { method: 'POST', body })
|
||||
}
|
||||
|
||||
const validPayout = {
|
||||
claim_ids: ['5a0a4c86-0000-4000-8000-000000000001'],
|
||||
payout_date: '2026-09-05',
|
||||
cash_account: '1935',
|
||||
}
|
||||
|
||||
describe('/api/expense-claims/payouts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
listPayoutsMock.mockResolvedValue([])
|
||||
createPayoutMock.mockResolvedValue({
|
||||
ok: true,
|
||||
batch_id: 'batch-1',
|
||||
journal_entry_id: 'je-1',
|
||||
total_sek: 500,
|
||||
claim_count: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('POST returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const response = await POST(post(validPayout), {} as never)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('POST creates a payout (201)', async () => {
|
||||
const response = await POST(post(validPayout), {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ data: { batch_id: string } }>(response)
|
||||
expect(status).toBe(201)
|
||||
expect(body.data.batch_id).toBe('batch-1')
|
||||
})
|
||||
|
||||
it('POST rejects a non-19xx cash account', async () => {
|
||||
const response = await POST(post({ ...validPayout, cash_account: '2893' }), {} as never)
|
||||
expect(response.status).toBe(400)
|
||||
expect(createPayoutMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['MIXED_CLAIMANTS', 400],
|
||||
['ALREADY_PAID', 409],
|
||||
['CLAIMS_NOT_FOUND', 404],
|
||||
['BATCH_INSERT_FAILED', 500],
|
||||
] as const)('POST maps service code %s to %d', async (code, expected) => {
|
||||
createPayoutMock.mockResolvedValue({ ok: false, code })
|
||||
const response = await POST(post(validPayout), {} as never)
|
||||
expect(response.status).toBe(expected)
|
||||
})
|
||||
|
||||
it('GET lists payout batches', async () => {
|
||||
listPayoutsMock.mockResolvedValue([{ id: 'batch-1' }])
|
||||
const response = await GET(createMockRequest('/api/expense-claims/payouts'), {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateExpensePayoutSchema } from '@/lib/api/schemas'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
createPayoutBatch,
|
||||
listPayoutBatches,
|
||||
} from '@/lib/expenses/expense-claims-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
const PAYOUT_ERROR_MESSAGES: Record<string, { message: string; status: number }> = {
|
||||
NO_CLAIMS: { message: 'Välj minst ett utlägg att betala ut.', status: 400 },
|
||||
CLAIMS_NOT_FOUND: { message: 'Något av utläggen hittades inte.', status: 404 },
|
||||
ALREADY_PAID: { message: 'Något av utläggen är redan utbetalt.', status: 409 },
|
||||
MIXED_CLAIMANTS: {
|
||||
message: 'En utbetalning kan bara avse en person. Dela upp per person.',
|
||||
status: 400,
|
||||
},
|
||||
MIXED_LIABILITY: {
|
||||
message: 'Utläggen har olika skuldkonton och kan inte betalas ut tillsammans.',
|
||||
status: 400,
|
||||
},
|
||||
FISCAL_PERIOD_NOT_FOUND: {
|
||||
message: 'Inget räkenskapsår täcker utbetalningsdatumet.',
|
||||
status: 400,
|
||||
},
|
||||
BATCH_INSERT_FAILED: { message: 'Utbetalningen kunde inte sparas.', status: 500 },
|
||||
PERIOD_LOCKED: { message: 'Perioden är låst. Lås upp den innan du bokför utbetalningen.', status: 409 },
|
||||
ACCOUNT_NOT_IN_CHART: { message: 'Kontot finns inte i kontoplanen.', status: 400 },
|
||||
INVALID_CASH_ACCOUNT: { message: 'Ange ett likvidkonto i 19xx-serien.', status: 400 },
|
||||
FORBIDDEN: { message: 'Du saknar behörighet att bokföra utbetalningar i det här företaget.', status: 403 },
|
||||
}
|
||||
|
||||
export const GET = withRouteContext('expense_claims.payouts.list', async (_request, { supabase, companyId }) => {
|
||||
const batches = await listPayoutBatches(supabase, companyId)
|
||||
return NextResponse.json({ data: batches })
|
||||
})
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'expense_claims.payouts.create',
|
||||
async (request, { supabase, companyId, user, log }) => {
|
||||
const validation = await validateBody(request, CreateExpensePayoutSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
try {
|
||||
const result = await createPayoutBatch(supabase, companyId, user.id, validation.data)
|
||||
if (!result.ok) {
|
||||
const mapped = PAYOUT_ERROR_MESSAGES[result.code] ?? {
|
||||
message: 'Utbetalningen kunde inte skapas.',
|
||||
status: 500,
|
||||
}
|
||||
if (mapped.status >= 500) {
|
||||
log.error('expense payout failed', new Error(result.detail ?? result.code))
|
||||
}
|
||||
return NextResponse.json({ error: mapped.message, code: result.code }, { status: mapped.status })
|
||||
}
|
||||
return NextResponse.json({ data: result }, { status: 201 })
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
if (typed) return typed
|
||||
log.error('failed to create expense payout', err as Error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(err, { context: 'journal_entry' }) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateExpenseClaimSchema } from '@/lib/api/schemas'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
listExpenseClaims,
|
||||
registerExpenseClaim,
|
||||
} from '@/lib/expenses/expense-claims-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
const REGISTER_ERROR_MESSAGES: Record<string, { message: string; status: number }> = {
|
||||
INVALID_LINES: {
|
||||
message: 'Verifikatraderna är ogiltiga: kontrollera att raderna balanserar och att skuldraden matchar beloppet.',
|
||||
status: 400,
|
||||
},
|
||||
EMPLOYEE_NOT_FOUND: { message: 'Anställd hittades inte', status: 404 },
|
||||
CLAIMANT_REQUIRED: {
|
||||
message: 'Ange vem utlägget avser: välj anställd eller skriv ett namn.',
|
||||
status: 400,
|
||||
},
|
||||
RATE_UNAVAILABLE: {
|
||||
message:
|
||||
'Ingen växelkurs kunde hämtas för datumet. Ange kursen manuellt och försök igen.',
|
||||
status: 400,
|
||||
},
|
||||
VAT_EXCEEDS_AMOUNT: { message: 'Momsen måste vara mindre än totalbeloppet.', status: 400 },
|
||||
FISCAL_PERIOD_NOT_FOUND: {
|
||||
message: 'Inget räkenskapsår täcker utläggsdatumet.',
|
||||
status: 400,
|
||||
},
|
||||
CLAIM_INSERT_FAILED: { message: 'Utlägget kunde inte sparas.', status: 500 },
|
||||
}
|
||||
|
||||
export const GET = withRouteContext('expense_claims.list', async (request, { supabase, companyId }) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
const claims = await listExpenseClaims(supabase, companyId, {
|
||||
status: status === 'registered' || status === 'paid' ? status : undefined,
|
||||
})
|
||||
return NextResponse.json({ data: claims })
|
||||
})
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'expense_claims.create',
|
||||
async (request, { supabase, companyId, user, log }) => {
|
||||
const validation = await validateBody(request, CreateExpenseClaimSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
try {
|
||||
const result = await registerExpenseClaim(supabase, companyId, user.id, {
|
||||
...validation.data,
|
||||
employee_id: validation.data.employee_id ?? undefined,
|
||||
document_id: validation.data.document_id ?? undefined,
|
||||
inbox_item_id: validation.data.inbox_item_id ?? undefined,
|
||||
})
|
||||
if (!result.ok) {
|
||||
const mapped = REGISTER_ERROR_MESSAGES[result.code] ?? {
|
||||
message: 'Utlägget kunde inte registreras.',
|
||||
status: 500,
|
||||
}
|
||||
if (mapped.status >= 500) {
|
||||
log.error('expense claim registration failed', new Error(result.detail ?? result.code))
|
||||
}
|
||||
return NextResponse.json({ error: mapped.message, code: result.code }, { status: mapped.status })
|
||||
}
|
||||
return NextResponse.json({ data: result.claim }, { status: 201 })
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
if (typed) return typed
|
||||
log.error('failed to register expense claim', err as Error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(err, { context: 'journal_entry' }) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Auth-wiring + contract tests for /api/expense-claims/suggest-template.
|
||||
* The AI service is mocked; these tests pin the 401, validation 400s, the
|
||||
* graceful empty answers (AI unavailable, AI error, unknown ids) and the
|
||||
* happy path where returned ids are filtered against the candidate list.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const requireCapabilityMock = vi.fn()
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
requireCapability: (...args: unknown[]) => requireCapabilityMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/entitlements/keys', () => ({ CAPABILITY: { ai: 'ai' } }))
|
||||
|
||||
const generateStructuredMock = vi.fn()
|
||||
const getAiStatusMock = vi.fn()
|
||||
vi.mock('@/lib/ai', () => ({
|
||||
getAiService: () => ({ generateStructured: generateStructuredMock }),
|
||||
getAiStatus: () => getAiStatusMock(),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
function post(body: unknown) {
|
||||
return createMockRequest('/api/expense-claims/suggest-template', { method: 'POST', body })
|
||||
}
|
||||
|
||||
const validBody = {
|
||||
description: 'Supabase Pte. Ltd. subscription',
|
||||
amount: 250,
|
||||
candidates: [
|
||||
{ id: 'static:software_saas', name: 'Programvara / SaaS', hint: 'Molntjänster' },
|
||||
{ id: 'static:it_services', name: 'IT-tjänster' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('/api/expense-claims/suggest-template', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
requireCapabilityMock.mockResolvedValue(null)
|
||||
getAiStatusMock.mockReturnValue({ configured: true })
|
||||
generateStructuredMock.mockResolvedValue({ value: { template_ids: ['static:software_saas'] } })
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const response = await POST(post(validBody), {} as never)
|
||||
expect(response.status).toBe(401)
|
||||
expect(generateStructuredMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when the description is missing', async () => {
|
||||
const response = await POST(post({ candidates: validBody.candidates }), {} as never)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when candidates are empty', async () => {
|
||||
const response = await POST(post({ description: 'Supabase', candidates: [] }), {} as never)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns the capability response when the AI capability is blocked', async () => {
|
||||
requireCapabilityMock.mockResolvedValue(
|
||||
NextResponse.json({ error: 'AI-funktioner ingår inte i din plan.' }, { status: 402 }),
|
||||
)
|
||||
const response = await POST(post(validBody), {} as never)
|
||||
expect(response.status).toBe(402)
|
||||
expect(generateStructuredMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty ids without calling the AI when it is unavailable', async () => {
|
||||
getAiStatusMock.mockReturnValue({ configured: false })
|
||||
const response = await POST(post(validBody), {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ data: { template_ids: string[] } }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.template_ids).toEqual([])
|
||||
expect(generateStructuredMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the suggested ids, filtered to known candidates', async () => {
|
||||
generateStructuredMock.mockResolvedValue({
|
||||
value: { template_ids: ['static:software_saas', 'static:not-a-candidate'] },
|
||||
})
|
||||
const response = await POST(post(validBody), {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ data: { template_ids: string[] } }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.template_ids).toEqual(['static:software_saas'])
|
||||
expect(generateStructuredMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tier: 'extraction' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades to empty ids when the AI call throws', async () => {
|
||||
generateStructuredMock.mockRejectedValue(new Error('model timeout'))
|
||||
const response = await POST(post(validBody), {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ data: { template_ids: string[] } }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.template_ids).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { getAiService, getAiStatus } from '@/lib/ai'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* AI fallback for the expense template chooser: the keyword matcher covers
|
||||
* known merchants, this ranks the caller-supplied candidate templates for
|
||||
* descriptions the keyword lists have never seen. The client only calls it
|
||||
* when the local matcher returns nothing, and an empty result is a valid
|
||||
* answer, never an error.
|
||||
*/
|
||||
const SuggestTemplateSchema = z.object({
|
||||
description: z.string().trim().min(2).max(300),
|
||||
amount: z.number().nonnegative().optional(),
|
||||
candidates: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().trim().min(1).max(80),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
hint: z.string().trim().max(240).optional().nullable(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(80),
|
||||
})
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'expense_claims.suggest_template',
|
||||
async (request, { supabase, companyId, log }) => {
|
||||
const validation = await validateBody(request, SuggestTemplateSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai)
|
||||
if (capBlocked) return capBlocked
|
||||
|
||||
const { description, amount, candidates } = validation.data
|
||||
if (!getAiStatus().configured) {
|
||||
return NextResponse.json({ data: { template_ids: [] } })
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = candidates
|
||||
.map((c) => `${c.id} | ${c.name}${c.hint ? ` | ${c.hint}` : ''}`)
|
||||
.join('\n')
|
||||
const result = await getAiService().generateStructured({
|
||||
tier: 'extraction',
|
||||
system:
|
||||
'You classify Swedish business expenses onto booking templates. ' +
|
||||
'Pick the best matching template ids for the expense, most likely first. ' +
|
||||
'Only return ids from the provided catalog. Return at most 3; return none if nothing fits.',
|
||||
prompt: `Expense description: ${description}\nAmount (SEK-equivalent): ${amount ?? 'unknown'}\n\nTemplate catalog (id | name | hint):\n${catalog}`,
|
||||
maxTokens: 300,
|
||||
schema: {
|
||||
name: 'template_suggestions',
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['template_ids'],
|
||||
properties: {
|
||||
template_ids: { type: 'array', maxItems: 3, items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const parsed = result.value as { template_ids?: unknown } | null
|
||||
const known = new Set(candidates.map((c) => c.id))
|
||||
const ids = Array.isArray(parsed?.template_ids)
|
||||
? parsed.template_ids.filter((id): id is string => typeof id === 'string' && known.has(id)).slice(0, 3)
|
||||
: []
|
||||
return NextResponse.json({ data: { template_ids: ids } })
|
||||
} catch (err) {
|
||||
// A suggestion is decoration: degrade to none instead of failing the UI.
|
||||
log.warn('template suggestion failed', { error: err instanceof Error ? err.message : String(err) })
|
||||
return NextResponse.json({ data: { template_ids: [] } })
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Home,
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
Users,
|
||||
ArrowLeftRight,
|
||||
@@ -137,6 +138,7 @@ type NavLabelKey =
|
||||
| 'reports'
|
||||
| 'import'
|
||||
| 'salary'
|
||||
| 'expenses'
|
||||
| 'mileage'
|
||||
| 'employees'
|
||||
| 'vat_declaration'
|
||||
@@ -244,6 +246,10 @@ const navItems: NavItem[] = [
|
||||
// must still reach its already-imported orders (accounting underlag).
|
||||
{ href: '/orders', labelKey: 'webshop_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true, betaBadge: true },
|
||||
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
|
||||
// Utlägg: out-of-pocket purchases and their reimbursement batches. The
|
||||
// /expenses route previously redirected to supplier invoices; the nav key
|
||||
// has existed in the nav namespace since then.
|
||||
{ href: '/expenses', labelKey: 'expenses', icon: Receipt, group: 'arbeta' },
|
||||
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true },
|
||||
// Körjournal: hidden by default (most companies have no car); shows when
|
||||
// the settings toggle is on or trips already exist (hybrid gate, same
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -430,6 +431,7 @@ const WorkspaceSkeleton = InvoiceInboxSkeleton
|
||||
|
||||
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('inbox_workspace')
|
||||
const tStart = useTranslations('start_cards')
|
||||
const dismissKeyCompanyId = useCompanyOptional()?.company?.id ?? null
|
||||
@@ -2153,6 +2155,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
onDelete={() => handleDelete(selected.id)}
|
||||
onBookDirect={() => setBookDirectOpen(true)}
|
||||
onCreateSupplierInvoice={() => setCreateSupplierInvoiceOpen(true)}
|
||||
onRegisterExpense={() => router.push(`/expenses?new=1&inbox_item=${selected.id}`)}
|
||||
onMatchTransaction={() => setMatchPickerOpen(true)}
|
||||
onUnmatchTransaction={async () => {
|
||||
const targetId = selected.id
|
||||
@@ -3059,6 +3062,7 @@ function FieldsRail({
|
||||
onDelete,
|
||||
onBookDirect,
|
||||
onCreateSupplierInvoice,
|
||||
onRegisterExpense,
|
||||
onMatchTransaction,
|
||||
onUnmatchTransaction,
|
||||
onAskAssistant,
|
||||
@@ -3073,6 +3077,7 @@ function FieldsRail({
|
||||
onDelete: () => void
|
||||
onBookDirect: () => void
|
||||
onCreateSupplierInvoice: () => void
|
||||
onRegisterExpense: () => void
|
||||
onMatchTransaction: () => void
|
||||
onUnmatchTransaction: () => Promise<void>
|
||||
onAskAssistant?: (transactionId: string) => void
|
||||
@@ -3623,6 +3628,15 @@ function FieldsRail({
|
||||
För leverantörsskulder du vill följa (periodisering).
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onRegisterExpense}
|
||||
className="flex flex-col items-start gap-1"
|
||||
>
|
||||
<span>Registrera som utlägg</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
För köp du eller en anställd betalat privat.
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onBookDirect}
|
||||
className="flex flex-col items-start gap-1"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -8061,6 +8061,122 @@
|
||||
"payment_shortfall_label": "Missing on the account",
|
||||
"pgnote": "Synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically, and deviations are flagged here. Kronofogden: {amount}."
|
||||
},
|
||||
"expense_claims": {
|
||||
"title": "Expense claims",
|
||||
"description": "Purchases you or an employee paid privately. Registering books the cost against a liability account; the payout clears the liability.",
|
||||
"new_claim": "New expense claim",
|
||||
"new_claim_help": "The claim is booked immediately as a verifikat: cost and VAT against the liability account (2893 for owners, 2820 for employees).",
|
||||
"viewer_disabled_tooltip": "You have read-only access in this company",
|
||||
"outstanding_to": "Owed to {name}:",
|
||||
"filter_all": "All statuses",
|
||||
"status_registered": "Registered",
|
||||
"status_paid": "Paid out",
|
||||
"empty_title": "No expense claims yet",
|
||||
"empty_description": "Register receipts that you or employees paid privately, and reimburse them in batches whenever it suits.",
|
||||
"th_date": "Date",
|
||||
"th_description": "Description",
|
||||
"th_claimant": "Person",
|
||||
"th_receipt": "Receipt",
|
||||
"th_status": "Status",
|
||||
"th_amount": "Amount",
|
||||
"select_claim": "Select claim for payout",
|
||||
"select_all_claims": "Select all",
|
||||
"outstanding_select_hint": "Click to select this person's claims",
|
||||
"payouts_heading": "Booked payouts",
|
||||
"payout_cash_account": "Cash account",
|
||||
"payout_view_entry": "Entry",
|
||||
"receipt_attached": "Attached",
|
||||
"receipt_missing": "Missing",
|
||||
"created": "Expense claim registered and booked",
|
||||
"create_failed": "Could not register the expense claim",
|
||||
"payout_created": "Payout booked",
|
||||
"payout_failed": "Could not create the payout",
|
||||
"payout_selection_invalid": "A payout can only cover one person with the same liability account",
|
||||
"payout_action": "Pay out {count, plural, one {# claim} other {# claims}} · {total}",
|
||||
"payout_title": "Pay out expense claims",
|
||||
"payout_help": "{count, plural, =1 {One claim} other {# claims}} to {name}, {total} in total. One verifikat is booked: the liability account is debited and the cash account credited. Make the actual bank transfer for the same amount.",
|
||||
"payout_date": "Payout date",
|
||||
"payout_account": "Cash account",
|
||||
"payout_confirm": "Book payout",
|
||||
"form_description": "Description",
|
||||
"form_description_placeholder": "e.g. USB hub, Kjell & Company",
|
||||
"form_date": "Expense date",
|
||||
"form_claimant": "Person",
|
||||
"form_claimant_owner": "Owner ({account})",
|
||||
"form_owner_name": "Name",
|
||||
"owner_fallback_name": "Owner",
|
||||
"form_amount": "Amount incl. VAT",
|
||||
"form_vat": "Of which VAT",
|
||||
"form_currency": "Currency",
|
||||
"form_fx_hint": "Amounts are entered in the selected currency and converted to SEK at the Riksbank rate for the expense date.",
|
||||
"form_expense_account": "Expense account",
|
||||
"form_receipt": "Receipt from the inbox",
|
||||
"form_pick_from_inbox": "Pick from inbox",
|
||||
"form_receipt_upload": "Drop or pick a receipt",
|
||||
"form_receipt_none": "No receipt",
|
||||
"form_receipt_warning": "A receipt is required as accounting records (BFL). Register now, but attach the receipt as soon as possible.",
|
||||
"form_cancel": "Cancel",
|
||||
"form_register": "Register & book",
|
||||
"step_details": "Step 1 · Receipt & details",
|
||||
"step_booking": "Step 2 · Booking",
|
||||
"step_booking_help": "Review the full verifikat before it is booked. Search the expense account by number or name.",
|
||||
"dropzone_hint": "Drag and drop or click",
|
||||
"dropzone_ai_hint": "AI reads the receipt",
|
||||
"upload_uploading": "Uploading…",
|
||||
"upload_extracting": "AI is reading the receipt…",
|
||||
"upload_extracted": "AI-extracted, fields prefilled",
|
||||
"upload_no_extraction": "Uploaded (no extraction)",
|
||||
"upload_remove": "Remove the receipt",
|
||||
"upload_failed": "The upload failed",
|
||||
"form_back": "Back",
|
||||
"form_next": "Next: review the verifikat",
|
||||
"preview_account": "Account",
|
||||
"form_seller_country": "Seller's country",
|
||||
"template_search_placeholder": "Search templates …",
|
||||
"book_manually": "Book manually",
|
||||
"book_manually_help": "Pick the account and country yourself, or edit the rows freely.",
|
||||
"no_templates_found": "No templates match the search.",
|
||||
"show_receipt": "Show receipt",
|
||||
"hide_receipt": "Hide receipt",
|
||||
"templates_recommended": "Recommended",
|
||||
"templates_ai_badge": "AI suggestions",
|
||||
"templates_ai_loading": "AI is suggesting templates from the description …",
|
||||
"templates_recent": "Recently used",
|
||||
"templates_all": "All templates",
|
||||
"seller_country_se": "Sweden",
|
||||
"seller_country_eu": "From an EU country",
|
||||
"seller_country_noneu": "From outside the EU",
|
||||
"seller_country_abroad": "Abroad",
|
||||
"rc_region_label": "Inside or outside the EU?",
|
||||
"rc_region_placeholder": "Select region",
|
||||
"rc_region_required": "Choose whether the seller is inside or outside the EU: the choice decides whether the basis lands in box 21 or box 22.",
|
||||
"seller_country_eu_hint": "Reverse charge: the cost stays on the cost account, the box 21 base is booked as a 4535/4598 pair and calculated VAT as 2614/2645. The VAT field from step 1 is not used.",
|
||||
"seller_country_noneu_hint": "Reverse charge: the cost stays on the cost account, the box 22 base is booked as a 4531/4598 pair and calculated VAT as 2614/2645. The VAT field from step 1 is not used.",
|
||||
"seller_country_gross_hint": "Foreign VAT is not deductible: the full amount including local VAT is booked as cost. Applies to e.g. hotels, restaurants and taxis, taxed where performed.",
|
||||
"rc_toggle": "Reverse charge (goods and main-rule services, e.g. SaaS)",
|
||||
"load_failed": "The expense claims could not be loaded.",
|
||||
"load_retry": "Try again",
|
||||
"template_applied": "Template: {name}",
|
||||
"clear_template": "Remove template",
|
||||
"edit_rows": "Edit rows",
|
||||
"hide_row_editor": "Hide row editor",
|
||||
"save_as_template": "Save as template",
|
||||
"add_row": "Add row",
|
||||
"remove_row": "Remove row",
|
||||
"balanced": "Debit = Credit",
|
||||
"unbalanced": "Unbalanced",
|
||||
"preview_debit": "Debit",
|
||||
"preview_credit": "Credit",
|
||||
"preview_summary": "Booked {date} as an expense claim for {name}. The receipt is attached to the verifikat.",
|
||||
"preview_fx_note": "Amounts are converted to SEK at the Riksbank rate for the expense date when booked.",
|
||||
"payout_account_placeholder": "Pick an account",
|
||||
"row_delete": "Remove",
|
||||
"delete_title": "Remove expense claim",
|
||||
"delete_help": "\"{description}\" ({total}) is removed from the list and its verifikat is cancelled with a reversal entry (storno). The receipt stays in the archive.",
|
||||
"delete_confirm": "Remove & reverse",
|
||||
"deleted": "Expense claim removed and the verifikat reversed",
|
||||
"delete_failed": "Could not remove the expense claim"
|
||||
},
|
||||
"mileage": {
|
||||
"title": "Driving log",
|
||||
"new_trip": "New trip",
|
||||
|
||||
@@ -8061,6 +8061,122 @@
|
||||
"payment_shortfall_label": "Saknas på kontot",
|
||||
"pgnote": "Synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt, och avvikelser flaggas här. Kronofogden: {amount}."
|
||||
},
|
||||
"expense_claims": {
|
||||
"title": "Utlägg",
|
||||
"description": "Köp du eller en anställd betalat privat. Registrering bokför kostnaden mot ett skuldkonto; utbetalningen nollar skulden.",
|
||||
"new_claim": "Nytt utlägg",
|
||||
"new_claim_help": "Utlägget bokförs direkt som verifikat: kostnad och moms mot skuldkontot (2893 för ägare, 2820 för anställda).",
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"outstanding_to": "Att betala till {name}:",
|
||||
"filter_all": "Alla statusar",
|
||||
"status_registered": "Registrerat",
|
||||
"status_paid": "Utbetalt",
|
||||
"empty_title": "Inga utlägg ännu",
|
||||
"empty_description": "Registrera kvitton som du eller anställda betalat privat, och betala ut dem i klump när det passar.",
|
||||
"th_date": "Datum",
|
||||
"th_description": "Beskrivning",
|
||||
"th_claimant": "Person",
|
||||
"th_receipt": "Underlag",
|
||||
"th_status": "Status",
|
||||
"th_amount": "Belopp",
|
||||
"select_claim": "Välj utlägg för utbetalning",
|
||||
"select_all_claims": "Markera alla",
|
||||
"outstanding_select_hint": "Klicka för att markera personens utlägg",
|
||||
"payouts_heading": "Bokförda utbetalningar",
|
||||
"payout_cash_account": "Likvidkonto",
|
||||
"payout_view_entry": "Verifikat",
|
||||
"receipt_attached": "Bifogat",
|
||||
"receipt_missing": "Saknas",
|
||||
"created": "Utlägg registrerat och bokfört",
|
||||
"create_failed": "Kunde inte registrera utlägget",
|
||||
"payout_created": "Utbetalning bokförd",
|
||||
"payout_failed": "Kunde inte skapa utbetalningen",
|
||||
"payout_selection_invalid": "En utbetalning kan bara avse en person med samma skuldkonto",
|
||||
"payout_action": "Betala ut {count} utlägg · {total}",
|
||||
"payout_title": "Betala ut utlägg",
|
||||
"payout_help": "{count, plural, =1 {Ett utlägg} other {# utlägg}} till {name}, totalt {total}. Ett verifikat bokförs: skuldkontot debiteras och likvidkontot krediteras. Gör själva banköverföringen med samma belopp.",
|
||||
"payout_date": "Utbetalningsdatum",
|
||||
"payout_account": "Likvidkonto",
|
||||
"payout_confirm": "Bokför utbetalning",
|
||||
"form_description": "Beskrivning",
|
||||
"form_description_placeholder": "t.ex. USB-hubb, Kjell & Company",
|
||||
"form_date": "Utläggsdatum",
|
||||
"form_claimant": "Person",
|
||||
"form_claimant_owner": "Ägare ({account})",
|
||||
"form_owner_name": "Namn",
|
||||
"owner_fallback_name": "Ägare",
|
||||
"form_amount": "Belopp inkl. moms",
|
||||
"form_vat": "Varav moms",
|
||||
"form_currency": "Valuta",
|
||||
"form_fx_hint": "Beloppen anges i vald valuta och räknas om till SEK med Riksbankens kurs för utläggsdatumet.",
|
||||
"form_expense_account": "Kostnadskonto",
|
||||
"form_receipt": "Underlag från inkorgen",
|
||||
"form_pick_from_inbox": "Välj från inkorgen",
|
||||
"form_receipt_upload": "Släpp eller välj kvitto",
|
||||
"form_receipt_none": "Inget underlag",
|
||||
"form_receipt_warning": "Kvitto krävs som räkenskapsinformation (BFL). Registrera gärna, men komplettera med underlag så snart som möjligt.",
|
||||
"form_cancel": "Avbryt",
|
||||
"form_register": "Registrera & bokför",
|
||||
"step_details": "Steg 1 · Kvitto & detaljer",
|
||||
"step_booking": "Steg 2 · Bokföring",
|
||||
"step_booking_help": "Granska hela verifikatet innan det bokförs. Sök kostnadskonto på nummer eller namn.",
|
||||
"dropzone_hint": "Dra och släpp eller klicka",
|
||||
"dropzone_ai_hint": "AI läser kvittot",
|
||||
"upload_uploading": "Laddar upp…",
|
||||
"upload_extracting": "AI tolkar kvittot…",
|
||||
"upload_extracted": "AI-tolkat, fälten ifyllda",
|
||||
"upload_no_extraction": "Uppladdat (ingen tolkning)",
|
||||
"upload_remove": "Ta bort kvittot",
|
||||
"upload_failed": "Uppladdningen misslyckades",
|
||||
"form_back": "Tillbaka",
|
||||
"form_next": "Nästa: granska verifikatet",
|
||||
"preview_account": "Konto",
|
||||
"form_seller_country": "Säljarens land",
|
||||
"template_search_placeholder": "Sök mall …",
|
||||
"book_manually": "Bokför manuellt",
|
||||
"book_manually_help": "Välj konto och land själv, eller redigera raderna fritt.",
|
||||
"no_templates_found": "Inga mallar matchar sökningen.",
|
||||
"show_receipt": "Visa kvitto",
|
||||
"hide_receipt": "Dölj kvitto",
|
||||
"templates_recommended": "Rekommenderade",
|
||||
"templates_ai_badge": "AI-förslag",
|
||||
"templates_ai_loading": "AI föreslår mallar utifrån beskrivningen …",
|
||||
"templates_recent": "Senast använda",
|
||||
"templates_all": "Alla mallar",
|
||||
"seller_country_se": "Sverige",
|
||||
"seller_country_eu": "Från EU-land",
|
||||
"seller_country_noneu": "Från icke EU-land",
|
||||
"seller_country_abroad": "Utomlands",
|
||||
"rc_region_label": "Inom eller utanför EU?",
|
||||
"rc_region_placeholder": "Välj region",
|
||||
"rc_region_required": "Välj om säljaren finns inom eller utanför EU: valet avgör om underlaget hamnar i ruta 21 eller ruta 22.",
|
||||
"seller_country_eu_hint": "Omvänd moms: kostnaden ligger kvar på kostnadskontot, ruta 21-underlaget bokförs som 4535/4598-par och beräknad moms som 2614/2645. Momsfältet från steg 1 används inte.",
|
||||
"seller_country_noneu_hint": "Omvänd moms: kostnaden ligger kvar på kostnadskontot, ruta 22-underlaget bokförs som 4531/4598-par och beräknad moms som 2614/2645. Momsfältet från steg 1 används inte.",
|
||||
"seller_country_gross_hint": "Utländsk moms är inte avdragsgill: hela beloppet inklusive lokal moms bokförs som kostnad. Gäller t.ex. hotell, restaurang och taxi som beskattas där de utförs.",
|
||||
"rc_toggle": "Omvänd skattskyldighet (varor och tjänster enligt huvudregeln, t.ex. SaaS)",
|
||||
"load_failed": "Utläggen kunde inte hämtas.",
|
||||
"load_retry": "Försök igen",
|
||||
"template_applied": "Mall: {name}",
|
||||
"clear_template": "Ta bort mallen",
|
||||
"edit_rows": "Redigera rader",
|
||||
"hide_row_editor": "Dölj radredigering",
|
||||
"save_as_template": "Spara som mall",
|
||||
"add_row": "Lägg till rad",
|
||||
"remove_row": "Ta bort rad",
|
||||
"balanced": "Debet = Kredit",
|
||||
"unbalanced": "Obalanserad",
|
||||
"preview_debit": "Debet",
|
||||
"preview_credit": "Kredit",
|
||||
"preview_summary": "Bokförs {date} som utlägg för {name}. Kvittot kopplas till verifikatet.",
|
||||
"preview_fx_note": "Beloppen räknas om till SEK med Riksbankens kurs för utläggsdatumet vid bokföring.",
|
||||
"payout_account_placeholder": "Välj konto",
|
||||
"row_delete": "Ta bort",
|
||||
"delete_title": "Ta bort utlägg",
|
||||
"delete_help": "\"{description}\" ({total}) tas bort ur listan och verifikatet annulleras med en ändringsverifikation (storno). Kvittot ligger kvar i arkivet.",
|
||||
"delete_confirm": "Ta bort & annullera",
|
||||
"deleted": "Utlägget borttaget och verifikatet annullerat",
|
||||
"delete_failed": "Kunde inte ta bort utlägget"
|
||||
},
|
||||
"mileage": {
|
||||
"title": "Körjournal",
|
||||
"new_trip": "Ny resa",
|
||||
|
||||
@@ -96,7 +96,7 @@ Request body:
|
||||
fiscal_period_id: string,
|
||||
entry_date: string,
|
||||
description: string,
|
||||
source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order",
|
||||
source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order" | "expense_claim" | "expense_payout",
|
||||
source_id?: string,
|
||||
voucher_series?: string,
|
||||
notes?: string,
|
||||
@@ -504,7 +504,7 @@ Bulk-create endpoint mirroring /invoices/bulk-create and /suppliers/bulk-create.
|
||||
Request body:
|
||||
```ts
|
||||
{
|
||||
journal_entries: { fiscal_period_id: string, entry_date: string, description: string, source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order", source_id?: string, voucher_series?: string, notes?: string, lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record<string, string>, cost_center?: string, project?: string }[] }[],
|
||||
journal_entries: { fiscal_period_id: string, entry_date: string, description: string, source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order" | "expense_claim" | "expense_payout", source_id?: string, voucher_series?: string, notes?: string, lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record<string, string>, cost_center?: string, project?: string }[] }[],
|
||||
all_or_nothing?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
-- Expense claims (utlägg): first-class module for out-of-pocket purchases.
|
||||
--
|
||||
-- An expense claim is a receipt someone paid privately: registering it books
|
||||
-- cost + ingående moms against an owner/employee liability account (2893
|
||||
-- skulder till närstående for owners, 2820 kortfristiga skulder till
|
||||
-- anställda for employees, 2018 egen insättning for enskild firma). A payout
|
||||
-- batch reimburses N registered claims in one bank transfer and books
|
||||
-- liability against the cash account.
|
||||
--
|
||||
-- Claims are registered directly as posted verifikat (status 'registered'):
|
||||
-- there is no draft state here, unbooked receipts live in the document inbox
|
||||
-- until they are registered.
|
||||
|
||||
-- Tenant-scoped uniqueness on employees so the expense tables can bind
|
||||
-- employee_id to the row's company (parties_substrate uses the same shape
|
||||
-- for parties). Added idempotently: it does not exist on main, and #2044
|
||||
-- adds the same key, so whichever merges second must not collide.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'employees_id_company_id_key'
|
||||
AND conrelid = 'public.employees'::regclass
|
||||
) THEN
|
||||
ALTER TABLE public.employees ADD CONSTRAINT employees_id_company_id_key UNIQUE (id, company_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE public.expense_payout_batches (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
|
||||
|
||||
-- Who is reimbursed. employee_id may be null for the owner; claimant_name
|
||||
-- is denormalized so history stays readable if the employee row goes away.
|
||||
-- The composite FK below binds it to this row's company (SET NULL on the
|
||||
-- employee column only: company_id is NOT NULL and must survive a delete).
|
||||
employee_id uuid,
|
||||
claimant_name text NOT NULL,
|
||||
|
||||
payout_date date NOT NULL,
|
||||
cash_account text NOT NULL CHECK (cash_account ~ '^19[0-9]{2}$'),
|
||||
liability_account text NOT NULL CHECK (liability_account IN ('2893', '2820', '2018', '2890')),
|
||||
total_sek numeric(15,2) NOT NULL CHECK (total_sek > 0),
|
||||
journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL,
|
||||
notes text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
FOREIGN KEY (employee_id, company_id)
|
||||
REFERENCES public.employees(id, company_id) ON DELETE SET NULL (employee_id)
|
||||
);
|
||||
|
||||
-- Target for the tenant-scoped FK from expense_claims.payout_batch_id.
|
||||
ALTER TABLE public.expense_payout_batches
|
||||
ADD CONSTRAINT expense_payout_batches_id_company_id_key UNIQUE (id, company_id);
|
||||
|
||||
COMMENT ON TABLE public.expense_payout_batches IS
|
||||
'One reimbursement transfer covering N registered expense claims; books liability -> cash.';
|
||||
|
||||
CREATE TABLE public.expense_claims (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
|
||||
|
||||
-- employee_id is company-scoped via the composite FK below.
|
||||
employee_id uuid,
|
||||
claimant_name text NOT NULL,
|
||||
|
||||
description text NOT NULL,
|
||||
expense_date date NOT NULL,
|
||||
|
||||
-- SEK is the booking truth; the original currency is provenance. The claim
|
||||
-- total is gross (incl VAT): vat_sek is the deductible part booked on 2641.
|
||||
amount_sek numeric(15,2) NOT NULL CHECK (amount_sek > 0),
|
||||
vat_sek numeric(15,2) NOT NULL DEFAULT 0
|
||||
CHECK (vat_sek >= 0 AND vat_sek < amount_sek),
|
||||
currency text NOT NULL DEFAULT 'SEK',
|
||||
amount_in_currency numeric(15,2),
|
||||
exchange_rate numeric(14,6),
|
||||
|
||||
expense_account text NOT NULL CHECK (expense_account ~ '^[0-9]{4}$'),
|
||||
liability_account text NOT NULL DEFAULT '2893'
|
||||
CHECK (liability_account IN ('2893', '2820', '2018', '2890')),
|
||||
|
||||
document_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL,
|
||||
status text NOT NULL DEFAULT 'registered' CHECK (status IN ('registered', 'paid')),
|
||||
journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL,
|
||||
-- Same-company by construction: the composite FK below binds the batch to
|
||||
-- this row's company, so a member of two companies cannot mark a claim in
|
||||
-- one as paid by a batch belonging to the other.
|
||||
payout_batch_id uuid,
|
||||
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- A paid claim must reference the batch that paid it.
|
||||
CHECK (status <> 'paid' OR payout_batch_id IS NOT NULL),
|
||||
|
||||
FOREIGN KEY (payout_batch_id, company_id)
|
||||
REFERENCES public.expense_payout_batches(id, company_id) ON DELETE SET NULL,
|
||||
|
||||
FOREIGN KEY (employee_id, company_id)
|
||||
REFERENCES public.employees(id, company_id) ON DELETE SET NULL (employee_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.expense_claims IS
|
||||
'Out-of-pocket purchase (utlägg): booked as cost + moms against an owner/employee liability on registration.';
|
||||
|
||||
ALTER TABLE public.expense_claims ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.expense_payout_batches ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "expense_claims_select" ON public.expense_claims
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "expense_claims_insert" ON public.expense_claims
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "expense_claims_update" ON public.expense_claims
|
||||
FOR UPDATE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "expense_claims_delete" ON public.expense_claims
|
||||
FOR DELETE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "expense_payout_batches_select" ON public.expense_payout_batches
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "expense_payout_batches_insert" ON public.expense_payout_batches
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "expense_payout_batches_update" ON public.expense_payout_batches
|
||||
FOR UPDATE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "expense_payout_batches_delete" ON public.expense_payout_batches
|
||||
FOR DELETE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_expense_claims_company ON public.expense_claims (company_id, expense_date DESC);
|
||||
CREATE INDEX idx_expense_claims_employee ON public.expense_claims (employee_id);
|
||||
CREATE INDEX idx_expense_claims_status ON public.expense_claims (company_id, status);
|
||||
CREATE INDEX idx_expense_claims_batch ON public.expense_claims (payout_batch_id)
|
||||
WHERE payout_batch_id IS NOT NULL;
|
||||
CREATE INDEX idx_expense_payout_batches_company
|
||||
ON public.expense_payout_batches (company_id, payout_date DESC);
|
||||
|
||||
CREATE TRIGGER set_updated_at_expense_claims
|
||||
BEFORE UPDATE ON public.expense_claims
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
CREATE TRIGGER set_updated_at_expense_payout_batches
|
||||
BEFORE UPDATE ON public.expense_payout_batches
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER audit_expense_claims
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.expense_claims
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
CREATE TRIGGER audit_expense_payout_batches
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.expense_payout_batches
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
|
||||
-- New journal entry source types so utlägg verifikat are traceable to their
|
||||
-- module rows. Rule (see 20260811073416): DB allowlist + the TS union +
|
||||
-- JournalEntrySourceTypeSchema change together.
|
||||
ALTER TABLE public.journal_entries
|
||||
DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
|
||||
|
||||
ALTER TABLE public.journal_entries
|
||||
ADD CONSTRAINT journal_entries_source_type_check
|
||||
CHECK (source_type IN (
|
||||
'manual', 'bank_transaction', 'invoice_created',
|
||||
'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment',
|
||||
'opening_balance', 'year_end',
|
||||
'storno', 'correction', 'import', 'system',
|
||||
'inbox_item',
|
||||
'supplier_invoice_registered', 'supplier_invoice_paid',
|
||||
'supplier_invoice_cash_payment', 'supplier_credit_note',
|
||||
'currency_revaluation',
|
||||
'supplier_invoice_privately_paid',
|
||||
'reminder_fee',
|
||||
'accrual',
|
||||
'result_appropriation',
|
||||
'rot_rut_payout',
|
||||
'vat_settlement',
|
||||
'stripe_payout',
|
||||
'webshop_order',
|
||||
'expense_claim',
|
||||
'expense_payout'
|
||||
)) NOT VALID;
|
||||
|
||||
ALTER TABLE public.journal_entries
|
||||
VALIDATE CONSTRAINT journal_entries_source_type_check;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,230 @@
|
||||
-- Atomic payout of expense claims (utlägg).
|
||||
--
|
||||
-- One RPC locks the selected claims, books the liability -> cash verifikat
|
||||
-- through commit_journal_entry and marks the claims paid, all in a single
|
||||
-- transaction. It replaces the application-side sequence (select claims,
|
||||
-- insert batch, post entry, link, mark paid) whose only guard was a storno
|
||||
-- compensation on the last step. That sequence had no row lock and no status
|
||||
-- predicate on the final update, so two concurrent submits (a double click,
|
||||
-- a retried request) each booked a payout verifikat for the same claims:
|
||||
-- three identical concurrent requests produced three posted transfers for
|
||||
-- one set of claims in the 2026-09-05 end-to-end run.
|
||||
--
|
||||
-- Rules mirrored from the service: one batch = one claimant (employee_id or
|
||||
-- the normalized free-text name) against one liability account; every claim
|
||||
-- must still be 'registered'; the payout date must fall in an open, unlocked
|
||||
-- fiscal year; both accounts must exist in the chart (the picker only offers
|
||||
-- accounts that do). Period locks and the company lock date are additionally
|
||||
-- enforced by the journal_entries triggers, which roll the whole call back.
|
||||
--
|
||||
-- Actor resolution mirrors bulk_book_transactions (20260824170000): p_user_id
|
||||
-- is honored only for service_role callers (API-key / MCP paths run on the
|
||||
-- cookieless service client where auth.uid() is NULL); every other caller is
|
||||
-- pinned to auth.uid() and must be an owner/admin/member of the company.
|
||||
--
|
||||
-- pg-test: covered-by tests/pg/expense-payout-batch-rpc.pg.test.ts
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_expense_payout_batch(
|
||||
p_company_id uuid,
|
||||
p_claim_ids uuid[],
|
||||
p_payout_date date,
|
||||
p_cash_account text,
|
||||
p_notes text DEFAULT NULL,
|
||||
p_user_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_caller uuid;
|
||||
v_ids uuid[];
|
||||
v_claim record;
|
||||
v_count integer := 0;
|
||||
v_first boolean := true;
|
||||
v_employee_id uuid;
|
||||
v_claimant_name text;
|
||||
v_claimant_key text;
|
||||
v_liability text;
|
||||
v_total numeric(15,2) := 0;
|
||||
v_period_id uuid;
|
||||
v_period_locked_at timestamptz;
|
||||
v_series text := 'A';
|
||||
v_series_raw text;
|
||||
v_batch_id uuid := gen_random_uuid();
|
||||
v_je_id uuid := gen_random_uuid();
|
||||
v_voucher_number integer;
|
||||
v_desc text;
|
||||
v_marked integer;
|
||||
BEGIN
|
||||
IF auth.role() = 'service_role' THEN
|
||||
v_caller := COALESCE(p_user_id, auth.uid());
|
||||
ELSE
|
||||
v_caller := auth.uid();
|
||||
END IF;
|
||||
IF v_caller IS NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
|
||||
END IF;
|
||||
|
||||
-- Same gate as the expense tables' write policies (owner/admin/member);
|
||||
-- SECURITY DEFINER bypasses RLS, so the check has to be explicit.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = v_caller
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
|
||||
END IF;
|
||||
|
||||
SELECT ARRAY(SELECT DISTINCT unnest(p_claim_ids)) INTO v_ids;
|
||||
IF v_ids IS NULL OR cardinality(v_ids) = 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'NO_CLAIMS');
|
||||
END IF;
|
||||
IF p_cash_account IS NULL OR p_cash_account !~ '^19[0-9]{2}$' THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'INVALID_CASH_ACCOUNT');
|
||||
END IF;
|
||||
|
||||
-- Lock the claims. A concurrent caller for any of the same rows queues on
|
||||
-- this lock and, once this transaction commits, reads them as 'paid'.
|
||||
FOR v_claim IN
|
||||
SELECT ec.id, ec.status, ec.employee_id, ec.claimant_name, ec.liability_account, ec.amount_sek
|
||||
FROM public.expense_claims ec
|
||||
WHERE ec.id = ANY(v_ids)
|
||||
AND ec.company_id = p_company_id
|
||||
ORDER BY ec.id
|
||||
FOR UPDATE
|
||||
LOOP
|
||||
v_count := v_count + 1;
|
||||
IF v_claim.status <> 'registered' THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'ALREADY_PAID',
|
||||
'details', jsonb_build_object('claim_id', v_claim.id));
|
||||
END IF;
|
||||
IF v_first THEN
|
||||
v_employee_id := v_claim.employee_id;
|
||||
v_claimant_name := v_claim.claimant_name;
|
||||
v_claimant_key := COALESCE(v_claim.employee_id::text, 'name:' || lower(btrim(v_claim.claimant_name)));
|
||||
v_liability := v_claim.liability_account;
|
||||
v_first := false;
|
||||
ELSE
|
||||
IF COALESCE(v_claim.employee_id::text, 'name:' || lower(btrim(v_claim.claimant_name))) <> v_claimant_key THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'MIXED_CLAIMANTS');
|
||||
END IF;
|
||||
IF v_claim.liability_account <> v_liability THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'MIXED_LIABILITY');
|
||||
END IF;
|
||||
END IF;
|
||||
v_total := v_total + v_claim.amount_sek;
|
||||
END LOOP;
|
||||
|
||||
IF v_count <> cardinality(v_ids) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'CLAIMS_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
-- Open fiscal year covering the payout date (mirrors engine.findFiscalPeriod).
|
||||
SELECT fp.id, fp.locked_at
|
||||
INTO v_period_id, v_period_locked_at
|
||||
FROM public.fiscal_periods fp
|
||||
WHERE fp.company_id = p_company_id
|
||||
AND fp.period_start <= p_payout_date
|
||||
AND fp.period_end >= p_payout_date
|
||||
AND fp.is_closed = false
|
||||
ORDER BY fp.period_start DESC
|
||||
LIMIT 1;
|
||||
IF v_period_id IS NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_PERIOD_NOT_FOUND');
|
||||
END IF;
|
||||
IF v_period_locked_at IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'PERIOD_LOCKED',
|
||||
'details', jsonb_build_object('fiscal_period_id', v_period_id));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.chart_of_accounts a
|
||||
WHERE a.company_id = p_company_id
|
||||
AND a.account_number = p_cash_account
|
||||
AND COALESCE(a.is_active, true)
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'ACCOUNT_NOT_IN_CHART',
|
||||
'details', jsonb_build_object('account', p_cash_account));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.chart_of_accounts a
|
||||
WHERE a.company_id = p_company_id
|
||||
AND a.account_number = v_liability
|
||||
AND COALESCE(a.is_active, true)
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'ACCOUNT_NOT_IN_CHART',
|
||||
'details', jsonb_build_object('account', v_liability));
|
||||
END IF;
|
||||
|
||||
-- Voucher series: the per-source-type default from company_settings, 'A'
|
||||
-- otherwise (mirrors resolveDefaultSeriesForSource).
|
||||
SELECT cs.default_voucher_series_per_source_type ->> 'expense_payout'
|
||||
INTO v_series_raw
|
||||
FROM public.company_settings cs
|
||||
WHERE cs.company_id = p_company_id;
|
||||
IF v_series_raw ~ '^[A-Z]$' THEN
|
||||
v_series := v_series_raw;
|
||||
END IF;
|
||||
|
||||
v_desc := 'Utbetalning utlägg: ' || v_claimant_name || ' (' || v_count || ' st)';
|
||||
|
||||
INSERT INTO public.expense_payout_batches
|
||||
(id, company_id, user_id, employee_id, claimant_name, payout_date,
|
||||
cash_account, liability_account, total_sek, notes)
|
||||
VALUES
|
||||
(v_batch_id, p_company_id, v_caller, v_employee_id, v_claimant_name, p_payout_date,
|
||||
p_cash_account, v_liability, v_total, p_notes);
|
||||
|
||||
INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, source_id, status)
|
||||
VALUES
|
||||
(v_je_id, v_caller, p_company_id, v_period_id, 0, v_series,
|
||||
p_payout_date, v_desc, 'expense_payout', v_batch_id, 'draft');
|
||||
|
||||
INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, currency, sort_order, line_description)
|
||||
VALUES
|
||||
(v_je_id, v_liability, v_total, 0, 'SEK', 0, v_desc),
|
||||
(v_je_id, p_cash_account, 0, v_total, 'SEK', 1, v_desc);
|
||||
|
||||
SELECT voucher_number INTO v_voucher_number
|
||||
FROM public.commit_journal_entry(p_company_id, v_je_id);
|
||||
|
||||
UPDATE public.expense_payout_batches
|
||||
SET journal_entry_id = v_je_id
|
||||
WHERE id = v_batch_id AND company_id = p_company_id;
|
||||
|
||||
UPDATE public.expense_claims
|
||||
SET status = 'paid', payout_batch_id = v_batch_id
|
||||
WHERE id = ANY(v_ids)
|
||||
AND company_id = p_company_id
|
||||
AND status = 'registered';
|
||||
GET DIAGNOSTICS v_marked = ROW_COUNT;
|
||||
IF v_marked <> cardinality(v_ids) THEN
|
||||
-- Cannot happen while the rows are locked above; if it ever does, the
|
||||
-- exception rolls back the batch and the verifikat together.
|
||||
RAISE EXCEPTION 'create_expense_payout_batch: marked % of % claims paid', v_marked, cardinality(v_ids);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'batch_id', v_batch_id,
|
||||
'journal_entry_id', v_je_id,
|
||||
'voucher_number', v_voucher_number,
|
||||
'total_sek', v_total,
|
||||
'claim_count', cardinality(v_ids)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid) TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid) IS
|
||||
'Books one reimbursement transfer for N registered expense claims atomically: locks the claims, posts liability -> cash via commit_journal_entry, marks them paid.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,203 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
|
||||
|
||||
// pg-real coverage for 20260904170000_expense_claims: RLS (member SELECT,
|
||||
// owner/admin/member writes, viewers read-only, strangers see nothing) and
|
||||
// the CHECK constraints (vat_sek < amount_sek, paid requires a payout batch,
|
||||
// cash/liability account whitelists on payout batches).
|
||||
|
||||
async function insertClaim(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
overrides: Partial<{ amountSek: number; vatSek: number }> = {},
|
||||
): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.expense_claims
|
||||
(id, company_id, user_id, claimant_name, description, expense_date, amount_sek, vat_sek, expense_account)
|
||||
VALUES ($1, $2, $3, 'Ägare', 'USB-hubb', '2026-08-25', $4, $5, '5410')`,
|
||||
[id, companyId, userId, overrides.amountSek ?? 500, overrides.vatSek ?? 100],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('expense_claims RLS', () => {
|
||||
it('lets company members read, strangers see nothing', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const claimId = await insertClaim(companyId, userId)
|
||||
const stranger = await insertAuthUser()
|
||||
|
||||
const memberView = await withUserContext(userId, (client) =>
|
||||
client.query<{ id: string }>(`SELECT id FROM public.expense_claims WHERE id = $1`, [claimId]),
|
||||
)
|
||||
expect(memberView.rows).toHaveLength(1)
|
||||
|
||||
const strangerView = await withUserContext(stranger, (client) =>
|
||||
client.query<{ id: string }>(`SELECT id FROM public.expense_claims WHERE id = $1`, [claimId]),
|
||||
)
|
||||
expect(strangerView.rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets viewers read but not register claims', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const claimId = await insertClaim(companyId, userId)
|
||||
const viewer = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
|
||||
|
||||
const viewerRead = await withUserContext(viewer, (client) =>
|
||||
client.query<{ id: string }>(`SELECT id FROM public.expense_claims WHERE id = $1`, [claimId]),
|
||||
)
|
||||
expect(viewerRead.rows).toHaveLength(1)
|
||||
|
||||
await expect(
|
||||
withUserContext(viewer, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO public.expense_claims
|
||||
(company_id, user_id, claimant_name, description, expense_date, amount_sek, expense_account)
|
||||
VALUES ($1, $2, 'Ägare', 'USB-hubb', '2026-08-25', 500, '5410')`,
|
||||
[companyId, viewer],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/row-level security/)
|
||||
})
|
||||
|
||||
it('viewers cannot update or delete claims', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const claimId = await insertClaim(companyId, userId)
|
||||
const viewer = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
|
||||
|
||||
// RLS filters the rows out of UPDATE/DELETE scope: 0 rows affected.
|
||||
const upd = await withUserContext(viewer, (client) =>
|
||||
client.query(`UPDATE public.expense_claims SET description = 'x' WHERE id = $1`, [claimId]),
|
||||
)
|
||||
expect(upd.rowCount).toBe(0)
|
||||
const der = await withUserContext(viewer, (client) =>
|
||||
client.query(`DELETE FROM public.expense_claims WHERE id = $1`, [claimId]),
|
||||
)
|
||||
expect(der.rowCount).toBe(0)
|
||||
|
||||
const still = await getPool().query(`SELECT description FROM public.expense_claims WHERE id = $1`, [claimId])
|
||||
expect(still.rows[0].description).toBe('USB-hubb')
|
||||
})
|
||||
|
||||
it('members cannot write into another company', async () => {
|
||||
const { userId } = await seedCompany()
|
||||
const other = await seedCompany()
|
||||
|
||||
await expect(
|
||||
withUserContext(userId, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO public.expense_claims
|
||||
(company_id, user_id, claimant_name, description, expense_date, amount_sek, expense_account)
|
||||
VALUES ($1, $2, 'Ägare', 'USB-hubb', '2026-08-25', 500, '5410')`,
|
||||
[other.companyId, userId],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/row-level security/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expense_claims constraints', () => {
|
||||
it('rejects vat_sek >= amount_sek', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await expect(insertClaim(companyId, userId, { amountSek: 100, vatSek: 100 })).rejects.toThrow(
|
||||
/check constraint/i,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects status paid without a payout batch', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const claimId = await insertClaim(companyId, userId)
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.expense_claims SET status = 'paid' WHERE id = $1`, [claimId]),
|
||||
).rejects.toThrow(/check/i)
|
||||
})
|
||||
|
||||
it('refuses an employee_id from another company', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
const foreignEmployee = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.employees (id, company_id, user_id, first_name, last_name, personnummer, personnummer_last4, employment_start)
|
||||
VALUES ($1, $2, $3, 'Test', 'Testsson', 'enc', '0000', '2026-01-01')`,
|
||||
[foreignEmployee, b.companyId, b.userId],
|
||||
)
|
||||
|
||||
// A claim in company A pointing at company B's employee: the composite FK
|
||||
// must refuse it even though company_id is A's own.
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.expense_claims
|
||||
(company_id, user_id, employee_id, claimant_name, description, expense_date, amount_sek, expense_account)
|
||||
VALUES ($1, $2, $3, 'Test Testsson', 'USB-hubb', '2026-08-25', 500, '5410')`,
|
||||
[a.companyId, a.userId, foreignEmployee],
|
||||
),
|
||||
).rejects.toThrow(/foreign key/)
|
||||
|
||||
// Same claim shape but referencing an employee in A's own company is fine.
|
||||
const ownEmployee = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.employees (id, company_id, user_id, first_name, last_name, personnummer, personnummer_last4, employment_start)
|
||||
VALUES ($1, $2, $3, 'Egen', 'Anställd', 'enc', '0001', '2026-01-01')`,
|
||||
[ownEmployee, a.companyId, a.userId],
|
||||
)
|
||||
const ok = await getPool().query(
|
||||
`INSERT INTO public.expense_claims
|
||||
(company_id, user_id, employee_id, claimant_name, description, expense_date, amount_sek, expense_account)
|
||||
VALUES ($1, $2, $3, 'Egen Anställd', 'USB-hubb', '2026-08-25', 500, '5410')`,
|
||||
[a.companyId, a.userId, ownEmployee],
|
||||
)
|
||||
expect(ok.rowCount).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses a payout batch from another company', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
const claimId = await insertClaim(a.companyId, a.userId)
|
||||
|
||||
// A batch that exists, but in the other company.
|
||||
const foreign = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.expense_payout_batches
|
||||
(company_id, user_id, claimant_name, payout_date, cash_account, liability_account, total_sek)
|
||||
VALUES ($1, $2, 'Ägare', '2026-08-31', '1930', '2893', 500) RETURNING id`,
|
||||
[b.companyId, b.userId],
|
||||
)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.expense_claims SET status = 'paid', payout_batch_id = $1 WHERE id = $2`,
|
||||
[foreign.rows[0].id, claimId],
|
||||
),
|
||||
).rejects.toThrow(/foreign key/)
|
||||
|
||||
// The same shape inside the claim's own company is accepted.
|
||||
const own = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.expense_payout_batches
|
||||
(company_id, user_id, claimant_name, payout_date, cash_account, liability_account, total_sek)
|
||||
VALUES ($1, $2, 'Ägare', '2026-08-31', '1930', '2893', 500) RETURNING id`,
|
||||
[a.companyId, a.userId],
|
||||
)
|
||||
const ok = await getPool().query(
|
||||
`UPDATE public.expense_claims SET status = 'paid', payout_batch_id = $1 WHERE id = $2`,
|
||||
[own.rows[0].id, claimId],
|
||||
)
|
||||
expect(ok.rowCount).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects a non-19xx cash account and an off-list liability account on batches', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const insertBatch = (cashAccount: string, liabilityAccount: string) =>
|
||||
getPool().query(
|
||||
`INSERT INTO public.expense_payout_batches
|
||||
(company_id, user_id, claimant_name, payout_date, cash_account, liability_account, total_sek)
|
||||
VALUES ($1, $2, 'Ägare', '2026-08-31', $3, $4, 500)`,
|
||||
[companyId, userId, cashAccount, liabilityAccount],
|
||||
)
|
||||
await expect(insertBatch('2440', '2893')).rejects.toThrow(/cash_account/)
|
||||
await expect(insertBatch('1930', '2440')).rejects.toThrow(/liability_account/)
|
||||
await expect(insertBatch('1930', '2893')).resolves.toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,285 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { PoolClient } from 'pg'
|
||||
import { getPool, getClient, withUserContext } from './setup'
|
||||
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
|
||||
|
||||
// pg-real coverage for 20260904171000_expense_payout_batch_rpc:
|
||||
// create_expense_payout_batch books one payout verifikat, links the batch
|
||||
// and marks the claims paid in one transaction, refuses non-writers, and
|
||||
// serializes concurrent callers so the same claims can never be paid twice.
|
||||
|
||||
type RpcResult = {
|
||||
ok: boolean
|
||||
code?: string
|
||||
batch_id?: string
|
||||
journal_entry_id?: string
|
||||
voucher_number?: number
|
||||
total_sek?: string | number
|
||||
claim_count?: number
|
||||
}
|
||||
|
||||
async function seedChart(companyId: string, userId: string): Promise<void> {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.chart_of_accounts
|
||||
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance, is_active)
|
||||
SELECT $1, $2, n, name, cls, atype, nbal, true
|
||||
FROM (VALUES
|
||||
('1930', 'Företagskonto', 1, 'asset', 'debit'),
|
||||
('2893', 'Skuld till aktieägare', 2, 'liability', 'credit')
|
||||
) AS t(n, name, cls, atype, nbal)`,
|
||||
[userId, companyId],
|
||||
)
|
||||
}
|
||||
|
||||
async function insertClaim(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
amountSek: number,
|
||||
overrides: Partial<{ claimantName: string; status: string }> = {},
|
||||
): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.expense_claims
|
||||
(id, company_id, user_id, claimant_name, description, expense_date, amount_sek, vat_sek, expense_account, liability_account, status)
|
||||
VALUES ($1, $2, $3, $4, 'Kvitto', '2026-08-25', $5, 0, '5410', '2893', $6)`,
|
||||
[id, companyId, userId, overrides.claimantName ?? 'Ägare', amountSek, overrides.status ?? 'registered'],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function callRpc(
|
||||
client: PoolClient,
|
||||
companyId: string,
|
||||
claimIds: string[],
|
||||
opts: Partial<{ date: string; cash: string }> = {},
|
||||
): Promise<RpcResult> {
|
||||
const { rows } = await client.query<{ r: RpcResult }>(
|
||||
`SELECT public.create_expense_payout_batch($1, $2::uuid[], $3::date, $4) AS r`,
|
||||
[companyId, claimIds, opts.date ?? '2026-08-31', opts.cash ?? '1930'],
|
||||
)
|
||||
return rows[0].r
|
||||
}
|
||||
|
||||
/** Like withUserContext but COMMITs, so a second session can observe the result. */
|
||||
async function asUser<T>(userId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await getClient()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
||||
JSON.stringify({ sub: userId, role: 'authenticated' }),
|
||||
])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
|
||||
await client.query('SET LOCAL ROLE authenticated')
|
||||
const result = await fn(client)
|
||||
await client.query('COMMIT')
|
||||
return result
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function payoutState(companyId: string, claimIds: string[]) {
|
||||
const claims = await getPool().query<{ status: string; payout_batch_id: string | null }>(
|
||||
`SELECT status, payout_batch_id FROM public.expense_claims WHERE id = ANY($1::uuid[]) ORDER BY id`,
|
||||
[claimIds],
|
||||
)
|
||||
const batches = await getPool().query<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM public.expense_payout_batches WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
const entries = await getPool().query<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM public.journal_entries
|
||||
WHERE company_id = $1 AND source_type = 'expense_payout' AND status = 'posted'`,
|
||||
[companyId],
|
||||
)
|
||||
return { claims: claims.rows, batches: Number(batches.rows[0].n), postedPayouts: Number(entries.rows[0].n) }
|
||||
}
|
||||
|
||||
describe('create_expense_payout_batch', () => {
|
||||
it('books one payout verifikat, links the batch and marks the claims paid', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await seedChart(companyId, userId)
|
||||
const a = await insertClaim(companyId, userId, 500)
|
||||
const b = await insertClaim(companyId, userId, 250.5)
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await callRpc(client, companyId, [a, b])
|
||||
expect(r.ok).toBe(true)
|
||||
expect(Number(r.total_sek)).toBe(750.5)
|
||||
expect(r.claim_count).toBe(2)
|
||||
expect(r.voucher_number).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const je = await client.query<{
|
||||
status: string
|
||||
voucher_number: number
|
||||
source_type: string
|
||||
source_id: string
|
||||
entry_date: string
|
||||
}>(
|
||||
`SELECT status, voucher_number, source_type, source_id::text, entry_date::text
|
||||
FROM public.journal_entries WHERE id = $1`,
|
||||
[r.journal_entry_id],
|
||||
)
|
||||
expect(je.rows[0]).toMatchObject({
|
||||
status: 'posted',
|
||||
voucher_number: r.voucher_number,
|
||||
source_type: 'expense_payout',
|
||||
source_id: r.batch_id,
|
||||
entry_date: '2026-08-31',
|
||||
})
|
||||
|
||||
const lines = await client.query<{ account_number: string; debit_amount: string; credit_amount: string }>(
|
||||
`SELECT account_number, debit_amount::text, credit_amount::text
|
||||
FROM public.journal_entry_lines WHERE journal_entry_id = $1 ORDER BY account_number`,
|
||||
[r.journal_entry_id],
|
||||
)
|
||||
expect(
|
||||
lines.rows.map((l) => ({ ...l, debit_amount: Number(l.debit_amount), credit_amount: Number(l.credit_amount) })),
|
||||
).toEqual([
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 750.5 },
|
||||
{ account_number: '2893', debit_amount: 750.5, credit_amount: 0 },
|
||||
])
|
||||
|
||||
const batch = await client.query<{ journal_entry_id: string; total_sek: string; claimant_name: string }>(
|
||||
`SELECT journal_entry_id, total_sek::text, claimant_name FROM public.expense_payout_batches WHERE id = $1`,
|
||||
[r.batch_id],
|
||||
)
|
||||
expect(batch.rows[0]).toEqual({
|
||||
journal_entry_id: r.journal_entry_id,
|
||||
total_sek: '750.50',
|
||||
claimant_name: 'Ägare',
|
||||
})
|
||||
|
||||
const claims = await client.query<{ status: string; payout_batch_id: string }>(
|
||||
`SELECT status, payout_batch_id FROM public.expense_claims WHERE id = ANY($1::uuid[])`,
|
||||
[[a, b]],
|
||||
)
|
||||
expect(claims.rows).toEqual([
|
||||
{ status: 'paid', payout_batch_id: r.batch_id },
|
||||
{ status: 'paid', payout_batch_id: r.batch_id },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a retry once the claims are paid, leaving a single transfer', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await seedChart(companyId, userId)
|
||||
const a = await insertClaim(companyId, userId, 100)
|
||||
|
||||
const first = await asUser(userId, (client) => callRpc(client, companyId, [a]))
|
||||
expect(first.ok).toBe(true)
|
||||
|
||||
const second = await asUser(userId, (client) => callRpc(client, companyId, [a]))
|
||||
expect(second).toMatchObject({ ok: false, code: 'ALREADY_PAID' })
|
||||
|
||||
expect(await payoutState(companyId, [a])).toMatchObject({ batches: 1, postedPayouts: 1 })
|
||||
})
|
||||
|
||||
it('serializes concurrent payouts of the same claims: the loser gets ALREADY_PAID', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await seedChart(companyId, userId)
|
||||
const a = await insertClaim(companyId, userId, 100)
|
||||
const b = await insertClaim(companyId, userId, 200)
|
||||
|
||||
const setContext = async (client: PoolClient) => {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
||||
JSON.stringify({ sub: userId, role: 'authenticated' }),
|
||||
])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
|
||||
await client.query('SET LOCAL ROLE authenticated')
|
||||
}
|
||||
|
||||
const c1 = await getClient()
|
||||
const c2 = await getClient()
|
||||
try {
|
||||
await setContext(c1)
|
||||
await setContext(c2)
|
||||
|
||||
// Session 1 books the payout but does not commit yet: it holds the
|
||||
// row locks on both claims.
|
||||
const r1 = await callRpc(c1, companyId, [a, b])
|
||||
expect(r1.ok).toBe(true)
|
||||
|
||||
// Session 2 issues the identical request. Without the FOR UPDATE it
|
||||
// would read both claims as 'registered' and book a second transfer.
|
||||
let settled = false
|
||||
const pending = callRpc(c2, companyId, [b, a]).then((r) => {
|
||||
settled = true
|
||||
return r
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(settled).toBe(false)
|
||||
|
||||
await c1.query('COMMIT')
|
||||
const r2 = await pending
|
||||
expect(r2).toMatchObject({ ok: false, code: 'ALREADY_PAID' })
|
||||
await c2.query('ROLLBACK')
|
||||
} finally {
|
||||
await c1.query('ROLLBACK').catch(() => {})
|
||||
await c2.query('ROLLBACK').catch(() => {})
|
||||
c1.release()
|
||||
c2.release()
|
||||
}
|
||||
|
||||
const state = await payoutState(companyId, [a, b])
|
||||
expect(state.batches).toBe(1)
|
||||
expect(state.postedPayouts).toBe(1)
|
||||
expect(state.claims.map((c) => c.status)).toEqual(['paid', 'paid'])
|
||||
})
|
||||
|
||||
it('refuses viewers and non-members without touching the ledger', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await seedChart(companyId, userId)
|
||||
const a = await insertClaim(companyId, userId, 100)
|
||||
|
||||
const viewer = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
|
||||
const stranger = await insertAuthUser()
|
||||
|
||||
for (const uid of [viewer, stranger]) {
|
||||
await withUserContext(uid, async (client) => {
|
||||
const r = await callRpc(client, companyId, [a])
|
||||
expect(r).toMatchObject({ ok: false, code: 'FORBIDDEN' })
|
||||
})
|
||||
}
|
||||
|
||||
expect(await payoutState(companyId, [a])).toMatchObject({
|
||||
batches: 0,
|
||||
postedPayouts: 0,
|
||||
claims: [{ status: 'registered', payout_batch_id: null }],
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses mixed claimants, unknown ids, and an off-chart cash account', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await seedChart(companyId, userId)
|
||||
const owner = await insertClaim(companyId, userId, 100)
|
||||
const other = await insertClaim(companyId, userId, 100, { claimantName: 'Anna Anställd' })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
expect(await callRpc(client, companyId, [owner, other])).toMatchObject({
|
||||
ok: false,
|
||||
code: 'MIXED_CLAIMANTS',
|
||||
})
|
||||
expect(await callRpc(client, companyId, [owner, randomUUID()])).toMatchObject({
|
||||
ok: false,
|
||||
code: 'CLAIMS_NOT_FOUND',
|
||||
})
|
||||
expect(await callRpc(client, companyId, [owner], { cash: '1940' })).toMatchObject({
|
||||
ok: false,
|
||||
code: 'ACCOUNT_NOT_IN_CHART',
|
||||
})
|
||||
expect(await callRpc(client, companyId, [owner], { date: '2027-03-01' })).toMatchObject({
|
||||
ok: false,
|
||||
code: 'FISCAL_PERIOD_NOT_FOUND',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await payoutState(companyId, [owner, other])).toMatchObject({ batches: 0, postedPayouts: 0 })
|
||||
})
|
||||
})
|
||||
@@ -1818,6 +1818,8 @@ export type JournalEntrySourceType =
|
||||
| 'vat_settlement'
|
||||
| 'stripe_payout'
|
||||
| 'webshop_order'
|
||||
| 'expense_claim'
|
||||
| 'expense_payout'
|
||||
|
||||
// Journal entry status
|
||||
export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' | 'cancelled'
|
||||
|
||||
Reference in New Issue
Block a user