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=&limit=&after_created_at=&after_id= // 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 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)['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, // 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() }