llm-router / apps /api /src /services /expense /slip-queue /slip-queue.controller.ts
ClaraSu's picture
Upload folder using huggingface_hub
0ccc0de verified
Raw History Blame Contribute Delete
9.45 kB
import type { Request, RequestHandler } from 'express'
import type { AuthenticatedRequest } from '@llm-router/auth-client'
import { container } from '../loader.js'
import type { ParsedExpense, SlipStatus, SlipQueueCursor } from '../ports/index.js'
function tenantOf(req: Request): string {
return (req as AuthenticatedRequest).auth!.tenantId
}
// POST /v1/slip-queue β€” { images: string[] }
// Response: { succeeded: SlipQueueItem[], failed: Array<{ error: string }> } β€” a
// per-image compression/upload failure doesn't fail the whole batch (see
// SlipQueueService.enqueue); the caller can inspect `failed` for partial-failure info.
export const postSlipQueue: RequestHandler = async (req, res) => {
const { images } = req.body as { images?: unknown }
if (!Array.isArray(images) || images.length === 0) {
res.status(400).json({ error: 'images must be a non-empty array of base64 strings' })
return
}
if (images.length > 50) {
res.status(400).json({ error: 'Max 50 images per request' })
return
}
const result = await container.slipQueueService.enqueue(tenantOf(req), images as string[])
res.status(201).json(result)
}
const VALID_STATUSES: SlipStatus[] = ['pending', 'processing', 'success', 'needs_review', 'failed', 'confirmed', 'duplicate']
const DEFAULT_PAGE_SIZE = 30
const MAX_PAGE_SIZE = 100
// GET /v1/slip-queue?status=<SlipStatus>&limit=<n>&after_created_at=<iso>&after_id=<uuid>
// One tab's page at a time β€” status is required, there's no "everything"
// mode anymore. Keyset-paginated (see SlipQueueRepository.listByStatus)
// rather than offset-based, so pages stay correct as new slips land mid-scroll.
export const getSlipQueue: RequestHandler = async (req, res) => {
const query = req.query as Record<string, string | undefined>
const status = query['status']
if (!status || !VALID_STATUSES.includes(status as SlipStatus)) {
res.status(400).json({ error: `status must be one of: ${VALID_STATUSES.join(', ')}` })
return
}
const limitParam = Number(query['limit'] ?? DEFAULT_PAGE_SIZE)
const limit = Math.min(Math.max(Number.isFinite(limitParam) ? limitParam : DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE)
const after = query['after_created_at'] && query['after_id']
? { createdAt: query['after_created_at'], id: query['after_id'] }
: undefined
const opts: { limit: number; after?: SlipQueueCursor } = after ? { limit, after } : { limit }
const page = await container.slipQueueService.listByStatus(tenantOf(req), status as SlipStatus, opts)
res.json(page)
}
// GET /v1/slip-queue/counts β€” lightweight per-status tallies for the tab
// badges, polled independently of whichever tab's page is currently loaded.
export const getSlipQueueCounts: RequestHandler = async (req, res) => {
const counts = await container.slipQueueService.countsByStatus(tenantOf(req))
res.json(counts)
}
// POST /v1/slip-queue/process β€” called by GitHub Actions cron (INTERNAL_SECRET auth via the global auth
// middleware, which bypasses JWT verification entirely β€” req.auth is never populated here). Sweeps pending
// slips across every tenant; each row is updated back through its own tenant scope (see SlipQueueService.processAll).
export const processSlipQueue: RequestHandler = async (req, res) => {
const limit = Number((req.query as Record<string, string | undefined>)['limit'] ?? 20)
const result = await container.slipQueueService.processAll(limit)
res.json(result)
}
// POST /v1/slip-queue/:id/retry
export const retrySlipQueueItem: RequestHandler = async (req, res) => {
const tenantId = tenantOf(req)
const id = String(req.params['id'] ?? '')
const item = await container.slipQueueService.get(tenantId, id)
if (!item) { res.status(404).json({ error: 'Slip not found' }); return }
if (item.status !== 'failed') { res.status(400).json({ error: 'Only failed slips can be retried' }); return }
const updated = await container.slipQueueService.resetForRetry(tenantId, id)
res.json(updated)
}
// POST /v1/slip-queue/:id/confirm β€” { category_id: string, overrides?: Partial<CreateExpense>,
// description?: string, shares?: [{person_id, amount}] }
//
// `shares` carries the already-resolved per-person amounts the frontend's SplitBillPanel
// computed (equal-split or manual) β€” the same shape bill-shares.routes.ts accepts today.
// This handler performs the same two-call, non-transactional sequence the manual/upload
// flows already use (create expense, then create the split): a split failure must not
// undo the already-created expense or block confirmation, since retrying "Confirm" would
// otherwise create a duplicate expense.
export const confirmSlipQueueItem: RequestHandler = async (req, res) => {
const tenantId = tenantOf(req)
const id = String(req.params['id'] ?? '')
const item = await container.slipQueueService.get(tenantId, id)
if (!item) { res.status(404).json({ error: 'Slip not found' }); return }
if (item.status !== 'success') { res.status(400).json({ error: 'Only parsed slips can be confirmed' }); return }
if (!item.parsed_result) { res.status(400).json({ error: 'No parsed result to confirm' }); return }
const body = req.body as {
category_id: string
date?: string
merchant?: string
currency?: string
description?: string
shares?: Array<{ person_id: string; amount: number }>
}
if (!body.category_id) { res.status(400).json({ error: 'category_id is required' }); return }
const today = new Date().toISOString().slice(0, 10)
let expense
try {
expense = await container.expenseService.create(tenantId, {
amount: item.parsed_result.amount,
currency: body.currency ?? item.parsed_result.currency ?? 'USD',
category_id: body.category_id,
date: body.date ?? item.parsed_result.date ?? today,
...(body.merchant ?? item.parsed_result.merchant ? { merchant: body.merchant ?? item.parsed_result.merchant ?? undefined } : {}),
...(body.description?.trim() ? { description: body.description.trim() } : {}),
slip_image_key: item.image_key,
reference: item.parsed_result.reference,
})
} catch (err: unknown) {
const status = (err as NodeJS.ErrnoException & { status?: number })?.status
if (err instanceof Error && status) {
res.status(status).json({ error: err.message })
return
}
throw err
}
await container.slipQueueService.setConfirmed(tenantId, id)
const validShares = (body.shares ?? []).filter(s => s.person_id && Number(s.amount) > 0)
if (validShares.length > 0) {
try {
await container.billShareService.split(tenantId, {
expense_id: expense.id,
total: expense.amount,
shares: validShares,
})
} catch (splitErr) {
console.error('Slip confirmed, but splitting it failed:', splitErr)
}
}
res.status(201).json(expense)
}
// POST /v1/slip-queue/:id/apply-match β€” resolves a needs_review slip via
// either of slip-queue-match-review-ui's two option boxes:
// - "use matched template": body is {} β€” reuses the slip's own already-
// persisted match_confidence/match_template_id/parsed_result.
// - "set up a new template": body carries the freshly-taught template's
// match result β€” { parsed_result, match_confidence, match_template_id }
// β€” which overwrites the stale below-threshold one.
// Either way the slip lands on 'success', same as today's confident-auto-
// match path, so the existing Review & Confirm flow takes over from there.
export const applyMatchSlipQueueItem: RequestHandler = async (req, res) => {
const tenantId = tenantOf(req)
const id = String(req.params['id'] ?? '')
const item = await container.slipQueueService.get(tenantId, id)
if (!item) { res.status(404).json({ error: 'Slip not found' }); return }
if (item.status !== 'needs_review') { res.status(400).json({ error: 'Only needs_review slips can have a match applied' }); return }
const body = req.body as {
parsed_result?: ParsedExpense
match_confidence?: number
match_template_id?: string | null
}
const hasOverride = body.parsed_result !== undefined && body.match_confidence !== undefined
const fields = hasOverride
? { parsed_result: body.parsed_result!, match_confidence: body.match_confidence!, match_template_id: body.match_template_id ?? null }
: { parsed_result: item.parsed_result, match_confidence: item.match_confidence, match_template_id: item.match_template_id }
if (!hasOverride && !fields.match_template_id) {
res.status(400).json({ error: 'No matched template to apply β€” teach a new one instead' })
return
}
const updated = await container.slipQueueService.applyMatch(tenantId, id, {
parsed_result: fields.parsed_result,
match_confidence: fields.match_confidence ?? 0,
match_template_id: fields.match_template_id,
})
res.json(updated)
}
// DELETE /v1/slip-queue/:id
export const deleteSlipQueueItem: RequestHandler = async (req, res) => {
const tenantId = tenantOf(req)
const id = String(req.params['id'] ?? '')
const item = await container.slipQueueService.get(tenantId, id)
if (!item) { res.status(404).json({ error: 'Slip not found' }); return }
if (item.status === 'confirmed') {
res.status(400).json({ error: 'Cannot discard a confirmed slip β€” its image is still used by the confirmed expense' })
return
}
await container.slipQueueService.delete(tenantId, id)
res.status(204).send()
}