feat(expenses): expense claims module (utlägg) (#2145)

Contributed by @joakimhew. Maintainer commits on top: migration re-versioned to 20260904170000 (main's 20260901210000 took the original version), payout batches booked atomically through the create_expense_payout_batch RPC, accounted-api skill regenerated, main merged. Closes #2143.
This commit is contained in:
Joakim Hansson
2026-09-05 13:36:51 +02:00
committed by GitHub
parent f7f40c04e4
commit 397a3b9bca
24 changed files with 5085 additions and 5 deletions
@@ -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')
})
})
+53
View File
@@ -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)
})
})
+73
View File
@@ -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 },
)
+82
View File
@@ -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: [] } })
}
},
)