File size: 4,795 Bytes
07c37a1
 
163916c
07c37a1
c5a5343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07c37a1
 
6822ee4
 
07c37a1
 
c5a5343
07c37a1
b207060
 
 
 
 
07c37a1
 
 
 
6822ee4
 
 
 
 
 
07c37a1
 
163916c
07c37a1
 
 
 
 
6822ee4
07c37a1
 
747e1c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07c37a1
747e1c6
 
 
 
 
 
 
07c37a1
 
 
 
 
 
 
 
 
 
 
0b0f8c2
 
 
 
 
 
 
 
747e1c6
 
 
 
0b0f8c2
07c37a1
 
 
 
 
 
 
 
 
 
b207060
c5a5343
b207060
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { apiFetch } from './client'

export type SlipStatus = 'pending' | 'processing' | 'success' | 'needs_review' | 'failed' | 'confirmed' | 'duplicate'

// Mirrors ParsedExpense (apps/api/src/services/expense/ports/index.ts)
// exactly — narrower ParsedExpenseResult (./expenses, the older
// /v1/expense/parse response) doesn't carry `reference`/`template_id`.
export interface SlipParsedExpense {
  amount: number
  currency: string
  date: string | null
  merchant: string | null
  category: string | null
  confidence: number
  raw_description: string
  reference: string | null
  template_id?: string | null
}

export interface SlipQueueItem {
  id: string
  image_base64: string | null
  image_url: string | null
  status: SlipStatus
  retry_count: number
  parsed_result: SlipParsedExpense | null
  error_message: string | null
  // Best-candidate template match info, present whenever a template match
  // was attempted (regardless of whether it cleared the confidence bar) —
  // see slip-queue-match-review-ui.
  match_confidence: number | null
  match_template_id: string | null
  created_at: string
  processed_at: string | null
}

export interface EnqueueSlipsResult {
  succeeded: SlipQueueItem[]
  failed: Array<{ error: string }>
}

export async function enqueueSlips(images: string[]): Promise<EnqueueSlipsResult> {
  const res = await apiFetch('/v1/slip-queue', {
    method: 'POST',
    body: JSON.stringify({ images, format: 'json' }),
  })
  if (!res.ok) {
    const err = await res.json() as { error?: string }
    throw new Error(err.error ?? 'Failed to enqueue slips')
  }
  return res.json() as Promise<EnqueueSlipsResult>
}

export interface SlipQueueCursor {
  createdAt: string
  id: string
}

export interface SlipQueuePage {
  items: SlipQueueItem[]
  hasMore: boolean
}

export async function listSlipQueuePage(status: SlipStatus, opts: { limit?: number; after?: SlipQueueCursor } = {}): Promise<SlipQueuePage> {
  const params = new URLSearchParams({ status, limit: String(opts.limit ?? 30) })
  if (opts.after) {
    params.set('after_created_at', opts.after.createdAt)
    params.set('after_id', opts.after.id)
  }
  const res = await apiFetch(`/v1/slip-queue?${params.toString()}`)
  if (!res.ok) throw new Error('Failed to load slip queue')
  return res.json() as Promise<SlipQueuePage>
}

export async function getSlipQueueCounts(): Promise<Record<SlipStatus, number>> {
  const res = await apiFetch('/v1/slip-queue/counts')
  if (!res.ok) throw new Error('Failed to load slip queue counts')
  return res.json() as Promise<Record<SlipStatus, number>>
}

export async function retrySlip(id: string): Promise<SlipQueueItem> {
  const res = await apiFetch(`/v1/slip-queue/${id}/retry`, { method: 'POST' })
  if (!res.ok) {
    const err = await res.json() as { error?: string }
    throw new Error(err.error ?? 'Failed to retry slip')
  }
  return res.json() as Promise<SlipQueueItem>
}

export interface SlipConfirmOverrides {
  date?: string
  merchant?: string
  currency?: string
  description?: string
  shares?: Array<{ person_id: string; amount: number }>
}

export interface SlipConfirmDetails extends SlipConfirmOverrides {
  category_id: string
}

export async function confirmSlip(id: string, categoryId: string, overrides?: SlipConfirmOverrides): Promise<void> {
  const res = await apiFetch(`/v1/slip-queue/${id}/confirm`, {
    method: 'POST',
    body: JSON.stringify({ category_id: categoryId, ...overrides }),
  })
  if (!res.ok) {
    const err = await res.json() as { error?: string }
    throw new Error(err.error ?? 'Failed to confirm slip')
  }
}

export interface ApplyMatchOverride {
  parsed_result: SlipParsedExpense
  match_confidence: number
  match_template_id: string | null
}

// POST /v1/slip-queue/:id/apply-match — resolves a needs_review slip via one
// of slip-queue-match-review-ui's two option boxes. No `override` reuses the
// slip's own already-persisted match ("use matched template"); an `override`
// carries a freshly-taught template's match result instead ("set up a new
// template"). Either way the slip lands on 'success', same as today's
// confident-auto-match path.
export async function applySlipMatch(id: string, override?: ApplyMatchOverride): Promise<SlipQueueItem> {
  const res = await apiFetch(`/v1/slip-queue/${id}/apply-match`, {
    method: 'POST',
    body: JSON.stringify(override ?? {}),
  })
  if (!res.ok) {
    const err = await res.json() as { error?: string }
    throw new Error(err.error ?? 'Failed to apply the template match')
  }
  return res.json() as Promise<SlipQueueItem>
}

export async function discardSlip(id: string): Promise<void> {
  const res = await apiFetch(`/v1/slip-queue/${id}`, { method: 'DELETE' })
  if (!res.ok) throw new Error('Failed to discard slip')
}