Spaces:
Runtime error
Runtime error
File size: 9,447 Bytes
1de388a 07c37a1 747e1c6 07c37a1 1de388a 07c37a1 6822ee4 07c37a1 6822ee4 07c37a1 163916c 747e1c6 1de388a 747e1c6 07c37a1 1de388a 07c37a1 1de388a 07c37a1 1de388a 07c37a1 1de388a 07c37a1 0b0f8c2 07c37a1 1de388a 07c37a1 1de388a 07c37a1 0b0f8c2 07c37a1 0ccc0de 07c37a1 1de388a 0b0f8c2 07c37a1 b207060 07c37a1 1de388a 07c37a1 1de388a 07c37a1 6822ee4 1de388a 07c37a1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | 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()
}
|