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.
85 lines
3.3 KiB
TypeScript
85 lines
3.3 KiB
TypeScript
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: [] } })
|
|
}
|
|
},
|
|
)
|