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()
}