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