feat: add iOS support and harden receipt scanning
This commit is contained in:
72
app/ios/ScanReceipts/Networking/ReceiptsAPI.swift
Normal file
72
app/ios/ScanReceipts/Networking/ReceiptsAPI.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
/// `POST /api/scan` response (src/app/api/scan/route.ts). This does NOT
|
||||
/// persist anything — it only runs the AI extraction and hands back the
|
||||
/// result. The caller must follow up with `ReceiptsAPI.sync(...)` once the
|
||||
/// user has reviewed/confirmed the receipt, exactly like the web dashboard's
|
||||
/// scan → review → save flow.
|
||||
struct ScanResponse: Decodable {
|
||||
let success: Bool
|
||||
let receipt: Receipt?
|
||||
let receipts: [Receipt]
|
||||
let pageCount: Int?
|
||||
let sourcePageCount: Int?
|
||||
let truncated: Bool?
|
||||
}
|
||||
|
||||
enum ReceiptsAPI {
|
||||
/// `POST /api/scan` — uploads one image/PDF for AI extraction. Requires a
|
||||
/// verified session (401 `unauthorized` / 403 `email_not_verified`
|
||||
/// otherwise) and is subject to the account's monthly/daily scan quota
|
||||
/// (402 `scan_limit_reached` / 429 `daily_scan_limit_reached`) — surface
|
||||
/// those via `APIError.server(code:...)`.
|
||||
static func scan(fileData: Data, fileName: String, mimeType: String) async throws -> ScanResponse {
|
||||
let request = APIRequest.multipartFile(
|
||||
path: "/api/scan",
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
fileData: fileData
|
||||
)
|
||||
return try await APIClient.shared.send(request)
|
||||
}
|
||||
|
||||
/// `GET /api/receipts` — the signed-in user's receipts, newest first.
|
||||
/// `includePreview` pulls the base64 preview image inline for each row —
|
||||
/// keep it `false` for list screens and only request it (or fetch a
|
||||
/// single id) when actually displaying an image.
|
||||
static func list(limit: Int = 100, includePreview: Bool = false) async throws -> [Receipt] {
|
||||
var query = [URLQueryItem(name: "limit", value: String(limit))]
|
||||
if includePreview { query.append(URLQueryItem(name: "includePreview", value: "1")) }
|
||||
let request = APIRequest(path: "/api/receipts", method: .get, query: query)
|
||||
let response: ReceiptListResponse = try await APIClient.shared.send(request)
|
||||
return response.receipts
|
||||
}
|
||||
|
||||
static func get(id: String) async throws -> Receipt? {
|
||||
let request = APIRequest(
|
||||
path: "/api/receipts",
|
||||
method: .get,
|
||||
query: [URLQueryItem(name: "id", value: id), URLQueryItem(name: "includePreview", value: "1")]
|
||||
)
|
||||
let response: ReceiptListResponse = try await APIClient.shared.send(request)
|
||||
return response.receipts.first
|
||||
}
|
||||
|
||||
/// `POST /api/receipts` — upserts one or more receipts (matched by `id`).
|
||||
/// This is how a scanned-and-reviewed receipt actually gets saved.
|
||||
@discardableResult
|
||||
static func sync(_ receipts: [Receipt]) async throws -> ReceiptSyncResponse {
|
||||
let request = try APIRequest.json(path: "/api/receipts", method: .post, body: receipts)
|
||||
return try await APIClient.shared.send(request)
|
||||
}
|
||||
|
||||
/// `DELETE /api/receipts?id=...`
|
||||
static func delete(id: String) async throws {
|
||||
let request = APIRequest(
|
||||
path: "/api/receipts",
|
||||
method: .delete,
|
||||
query: [URLQueryItem(name: "id", value: id)]
|
||||
)
|
||||
try await APIClient.shared.sendDiscardingResponse(request)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user