feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

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