feat: add iOS support and harden receipt scanning
This commit is contained in:
189
app/ios/ScanReceipts/Networking/APIClient.swift
Normal file
189
app/ios/ScanReceipts/Networking/APIClient.swift
Normal file
@@ -0,0 +1,189 @@
|
||||
import Foundation
|
||||
|
||||
/// One HTTP call against the backend. Feature code builds one of these and
|
||||
/// hands it to `APIClient.shared.send(...)` — nobody outside this file talks
|
||||
/// to `URLSession` directly, so auth headers and error decoding stay in
|
||||
/// exactly one place.
|
||||
struct APIRequest {
|
||||
enum Method: String { case get = "GET", post = "POST", patch = "PATCH", delete = "DELETE" }
|
||||
|
||||
var path: String
|
||||
var method: Method = .get
|
||||
var query: [URLQueryItem] = []
|
||||
var body: Data?
|
||||
var contentType: String = "application/json"
|
||||
/// False only for the couple of endpoints callable while signed out
|
||||
/// (login, signup, forgot-password). Everything else sends the Bearer
|
||||
/// token automatically.
|
||||
var requiresAuth: Bool = true
|
||||
|
||||
static func json<Body: Encodable>(
|
||||
path: String,
|
||||
method: Method,
|
||||
body: Body,
|
||||
requiresAuth: Bool = true
|
||||
) throws -> APIRequest {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
return APIRequest(
|
||||
path: path,
|
||||
method: method,
|
||||
body: try encoder.encode(body),
|
||||
requiresAuth: requiresAuth
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a single-file `multipart/form-data` body — used by
|
||||
/// `POST /api/scan`, which reads the file from a `file` form field (see
|
||||
/// `formData.get("file")` in src/app/api/scan/route.ts). Only one file
|
||||
/// field is needed anywhere in this app; if that changes, generalise this
|
||||
/// rather than hand-rolling multipart bodies elsewhere.
|
||||
static func multipartFile(
|
||||
path: String,
|
||||
fieldName: String = "file",
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
fileData: Data
|
||||
) -> APIRequest {
|
||||
let boundary = "ScanReceipts-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".utf8)
|
||||
body.append("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n".utf8)
|
||||
body.append("Content-Type: \(mimeType)\r\n\r\n".utf8)
|
||||
body.append(fileData)
|
||||
body.append("\r\n--\(boundary)--\r\n".utf8)
|
||||
|
||||
return APIRequest(
|
||||
path: path,
|
||||
method: .post,
|
||||
body: body,
|
||||
contentType: "multipart/form-data; boundary=\(boundary)",
|
||||
requiresAuth: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Posted whenever a request comes back 401. `AppState` observes this and
|
||||
/// signs the user out locally — the server-side session is already gone by
|
||||
/// the time this fires, so there is nothing left to clean up remotely.
|
||||
extension Notification.Name {
|
||||
static let sessionExpired = Notification.Name("ScanReceipts.sessionExpired")
|
||||
}
|
||||
|
||||
final class APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
private let session: URLSession
|
||||
private let baseURL: URL
|
||||
private let tokenStore: KeychainTokenStore
|
||||
|
||||
init(
|
||||
baseURL: URL = APIEnvironment.current.baseURL,
|
||||
session: URLSession = .shared,
|
||||
tokenStore: KeychainTokenStore = .shared
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
self.tokenStore = tokenStore
|
||||
}
|
||||
|
||||
/// Decodes a JSON response body as `T`.
|
||||
@discardableResult
|
||||
func send<T: Decodable>(_ request: APIRequest, as type: T.Type = T.self) async throws -> T {
|
||||
let data = try await sendRaw(request)
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decoding(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// For endpoints that return a raw file, not JSON — the three
|
||||
/// `/api/export/*` routes, which respond with the file bytes directly and
|
||||
/// the filename in `Content-Disposition` (see ExportAPI.swift). Still
|
||||
/// validates the status and decodes `{ error }` JSON on failure exactly
|
||||
/// like `send`.
|
||||
func sendForFile(_ request: APIRequest) async throws -> (data: Data, suggestedFileName: String?) {
|
||||
let (data, response) = try await sendRawWithResponse(request)
|
||||
let fileName = response.value(forHTTPHeaderField: "Content-Disposition")
|
||||
.flatMap { header -> String? in
|
||||
guard let range = header.range(of: "filename=\"") else { return nil }
|
||||
let rest = header[range.upperBound...]
|
||||
guard let end = rest.firstIndex(of: "\"") else { return nil }
|
||||
return String(rest[rest.startIndex..<end])
|
||||
}
|
||||
return (data, fileName)
|
||||
}
|
||||
|
||||
/// For endpoints whose success body carries nothing the caller needs
|
||||
/// (e.g. `DELETE /api/receipts`) — still validates the status code and
|
||||
/// decodes `{ error }` on failure.
|
||||
func sendDiscardingResponse(_ request: APIRequest) async throws {
|
||||
_ = try await sendRaw(request)
|
||||
}
|
||||
|
||||
private func sendRaw(_ apiRequest: APIRequest) async throws -> Data {
|
||||
try await sendRawWithResponse(apiRequest).data
|
||||
}
|
||||
|
||||
private func sendRawWithResponse(_ apiRequest: APIRequest) async throws -> (data: Data, response: HTTPURLResponse) {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent(apiRequest.path), resolvingAgainstBaseURL: false)
|
||||
if !apiRequest.query.isEmpty {
|
||||
components?.queryItems = apiRequest.query
|
||||
}
|
||||
guard let url = components?.url else {
|
||||
throw APIError.unexpectedStatus(-1)
|
||||
}
|
||||
|
||||
var urlRequest = URLRequest(url: url)
|
||||
urlRequest.httpMethod = apiRequest.method.rawValue
|
||||
// Identifies this as a native client so the backend (a) returns the
|
||||
// raw session token in the login/signup JSON body instead of only a
|
||||
// Set-Cookie, and (b) exempts the request from the browser-only CSRF
|
||||
// double-submit check. See src/lib/auth/config.ts#isMobileClientRequest
|
||||
// and src/lib/auth/csrf.ts#isCsrfExempt on the backend.
|
||||
urlRequest.setValue("ios", forHTTPHeaderField: "X-Client")
|
||||
|
||||
if let body = apiRequest.body {
|
||||
urlRequest.httpBody = body
|
||||
urlRequest.setValue(apiRequest.contentType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
if apiRequest.requiresAuth {
|
||||
guard let token = tokenStore.token else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await session.data(for: urlRequest)
|
||||
} catch {
|
||||
throw APIError.network(error)
|
||||
}
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.unexpectedStatus(-1)
|
||||
}
|
||||
|
||||
if (200..<300).contains(http.statusCode) {
|
||||
return (data, http)
|
||||
}
|
||||
|
||||
if http.statusCode == 401 {
|
||||
tokenStore.token = nil
|
||||
NotificationCenter.default.post(name: .sessionExpired, object: nil)
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
if let body = try? JSONDecoder().decode(APIErrorBody.self, from: data) {
|
||||
throw APIError.server(code: body.error, status: http.statusCode, retryAfterSeconds: body.retryAfter)
|
||||
}
|
||||
|
||||
throw APIError.unexpectedStatus(http.statusCode)
|
||||
}
|
||||
}
|
||||
30
app/ios/ScanReceipts/Networking/APIEnvironment.swift
Normal file
30
app/ios/ScanReceipts/Networking/APIEnvironment.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// Which backend the app talks to. All three point at the *same* Next.js API
|
||||
/// described in `app/ios/README.md` — there is no separate mobile backend.
|
||||
enum APIEnvironment {
|
||||
case production
|
||||
case local
|
||||
|
||||
var baseURL: URL {
|
||||
switch self {
|
||||
case .production:
|
||||
// TODO: swap for the real production domain before release.
|
||||
return URL(string: "https://scan-receipts.app")!
|
||||
case .local:
|
||||
// Matches `dev-verify` in the web repo's .claude/launch.json (port
|
||||
// 3901) so the app can be pointed at a live local backend without
|
||||
// colliding with the docker-compose stack on 3000. Only reachable
|
||||
// from an iOS Simulator on the same machine, not a physical device.
|
||||
return URL(string: "http://localhost:3901")!
|
||||
}
|
||||
}
|
||||
|
||||
static var current: APIEnvironment {
|
||||
#if DEBUG
|
||||
return .local
|
||||
#else
|
||||
return .production
|
||||
#endif
|
||||
}
|
||||
}
|
||||
78
app/ios/ScanReceipts/Networking/APIError.swift
Normal file
78
app/ios/ScanReceipts/Networking/APIError.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
/// A decoded `{ "error": "some_code" }` body from the backend. Every route
|
||||
/// under `src/app/api/**` returns one of these machine-readable codes on
|
||||
/// failure (see `src/lib/auth/errors.ts` for the auth ones) — the app is
|
||||
/// responsible for turning the code into user-facing German copy, the same
|
||||
/// way the web dashboard's client code does.
|
||||
struct APIErrorBody: Decodable {
|
||||
let error: String
|
||||
let retryAfter: Int?
|
||||
}
|
||||
|
||||
enum APIError: Error, LocalizedError {
|
||||
/// HTTP 401 — no/invalid/expired session. Callers should route back to
|
||||
/// the login screen and clear the stored token.
|
||||
case unauthorized
|
||||
/// A recognised `{ error: <code> }` body, with the HTTP status attached
|
||||
/// (403 pro_required, 402 scan_limit_reached, 429 rate_limited, ...).
|
||||
case server(code: String, status: Int, retryAfterSeconds: Int?)
|
||||
/// A non-2xx response with a body that isn't the `{ error }` shape.
|
||||
case unexpectedStatus(Int)
|
||||
case network(Error)
|
||||
case decoding(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unauthorized:
|
||||
return "Bitte melde dich erneut an."
|
||||
case .server(let code, _, let retryAfter):
|
||||
return APIError.message(forCode: code, retryAfterSeconds: retryAfter)
|
||||
case .unexpectedStatus(let status):
|
||||
return "Unerwartete Antwort vom Server (\(status))."
|
||||
case .network:
|
||||
return "Keine Verbindung zum Server. Prüfe deine Internetverbindung."
|
||||
case .decoding:
|
||||
return "Antwort des Servers konnte nicht gelesen werden."
|
||||
}
|
||||
}
|
||||
|
||||
/// Central place to translate a backend error code into German UI copy.
|
||||
/// Extend this switch as features land — it deliberately mirrors what the
|
||||
/// web app's route handlers can return, not a guess at future codes.
|
||||
static func message(forCode code: String, retryAfterSeconds: Int?) -> String {
|
||||
switch code {
|
||||
case "invalid_credentials":
|
||||
return "E-Mail oder Passwort ist falsch."
|
||||
case "email_not_verified":
|
||||
return "Bitte bestätige zuerst deine E-Mail-Adresse."
|
||||
case "rate_limited":
|
||||
if let seconds = retryAfterSeconds, seconds > 0 {
|
||||
return "Zu viele Versuche. Bitte in \(seconds) Sekunden erneut versuchen."
|
||||
}
|
||||
return "Zu viele Versuche. Bitte später erneut versuchen."
|
||||
case "invalid_email":
|
||||
return "Diese E-Mail-Adresse ist ungültig."
|
||||
case "weak_password":
|
||||
return "Das Passwort ist zu schwach."
|
||||
case "disposable_email":
|
||||
return "Bitte verwende eine reguläre E-Mail-Adresse."
|
||||
case "mail_failed":
|
||||
return "Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte später erneut versuchen."
|
||||
case "database_unavailable":
|
||||
return "Server vorübergehend nicht erreichbar. Bitte später erneut versuchen."
|
||||
case "pro_required":
|
||||
return "Diese Funktion ist Pro-Nutzern vorbehalten."
|
||||
case "scan_limit_reached":
|
||||
return "Dein monatliches Scan-Kontingent ist aufgebraucht."
|
||||
case "daily_scan_limit_reached":
|
||||
return "Tageslimit für Scans erreicht. Bitte morgen erneut versuchen."
|
||||
case "csrf_failed":
|
||||
return "Sicherheitsprüfung fehlgeschlagen. Bitte App neu starten."
|
||||
case "unauthorized":
|
||||
return "Bitte melde dich erneut an."
|
||||
default:
|
||||
return "Etwas ist schiefgelaufen (\(code))."
|
||||
}
|
||||
}
|
||||
}
|
||||
46
app/ios/ScanReceipts/Networking/AccountAPI.swift
Normal file
46
app/ios/ScanReceipts/Networking/AccountAPI.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// Account-management calls that aren't part of the core auth flow
|
||||
/// (`AuthAPI`) — password change and the irreversible delete-account flow.
|
||||
enum AccountAPI {
|
||||
struct ChangePasswordBody: Encodable {
|
||||
let currentPassword: String
|
||||
let newPassword: String
|
||||
let remember: Bool
|
||||
}
|
||||
|
||||
/// `POST /api/auth/change-password`. On success the backend rotates
|
||||
/// EVERY session for the account (including this device's) and issues a
|
||||
/// brand-new one — but that new session is delivered as a cookie on the
|
||||
/// web path only. For a Bearer client the safest move is to log the user
|
||||
/// out locally and have them log back in with the new password, so do
|
||||
/// that here rather than trying to recover a token this endpoint doesn't
|
||||
/// return.
|
||||
static func changePassword(currentPassword: String, newPassword: String) async throws {
|
||||
let request = try APIRequest.json(
|
||||
path: "/api/auth/change-password",
|
||||
method: .post,
|
||||
body: ChangePasswordBody(currentPassword: currentPassword, newPassword: newPassword, remember: true)
|
||||
)
|
||||
try await APIClient.shared.sendDiscardingResponse(request)
|
||||
}
|
||||
|
||||
struct DeleteAccountBody: Encodable {
|
||||
let password: String?
|
||||
let confirmEmail: String
|
||||
}
|
||||
|
||||
/// `DELETE /api/auth/delete-account`. Irreversible. `password` is
|
||||
/// required for accounts that have one (omit only for a Google-only
|
||||
/// account, which has no password to check); `confirmEmail` must equal
|
||||
/// the account's email exactly, case-sensitively, matching the web
|
||||
/// confirmation flow's "type your email to confirm" pattern.
|
||||
static func deleteAccount(password: String?, confirmEmail: String) async throws {
|
||||
let request = try APIRequest.json(
|
||||
path: "/api/auth/delete-account",
|
||||
method: .delete,
|
||||
body: DeleteAccountBody(password: password, confirmEmail: confirmEmail)
|
||||
)
|
||||
try await APIClient.shared.sendDiscardingResponse(request)
|
||||
}
|
||||
}
|
||||
95
app/ios/ScanReceipts/Networking/AuthAPI.swift
Normal file
95
app/ios/ScanReceipts/Networking/AuthAPI.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
/// Thin wrapper around the `/api/auth/*` endpoints. `AppState` is the only
|
||||
/// caller that should hold state derived from these — feature screens call
|
||||
/// through `AppState`, not this type directly, so there is exactly one place
|
||||
/// that decides what "signed in" means.
|
||||
enum AuthAPI {
|
||||
struct SignupBody: Encodable {
|
||||
let name: String?
|
||||
let email: String
|
||||
let password: String
|
||||
let lang: String
|
||||
}
|
||||
|
||||
struct SignupResponse: Decodable {
|
||||
let status: String
|
||||
/// Only ever present against a local dev backend with no SMTP
|
||||
/// configured (see src/app/api/auth/signup/route.ts) — never in
|
||||
/// production. Useful for the simulator during development.
|
||||
let devLink: String?
|
||||
}
|
||||
|
||||
struct LoginBody: Encodable {
|
||||
let email: String
|
||||
let password: String
|
||||
let remember: Bool
|
||||
}
|
||||
|
||||
struct AppleSignInBody: Encodable {
|
||||
let identityToken: String
|
||||
let fullName: String?
|
||||
}
|
||||
|
||||
/// `POST /api/auth/apple` — native Sign in with Apple. `identityToken` is
|
||||
/// `ASAuthorizationAppleIDCredential.identityToken` decoded to a UTF-8
|
||||
/// string; `fullName` is `credential.fullName` formatted for display,
|
||||
/// which Apple only ever supplies on the user's FIRST authorization with
|
||||
/// this app (pass `nil` on every subsequent sign-in — there is nothing to
|
||||
/// send). The backend verifies the token itself; nothing here is trusted
|
||||
/// data, it's just what gets forwarded for verification.
|
||||
static func appleSignIn(identityToken: String, fullName: String?) async throws -> LoginResponse {
|
||||
let request = try APIRequest.json(
|
||||
path: "/api/auth/apple",
|
||||
method: .post,
|
||||
body: AppleSignInBody(identityToken: identityToken, fullName: fullName),
|
||||
requiresAuth: false
|
||||
)
|
||||
return try await APIClient.shared.send(request)
|
||||
}
|
||||
|
||||
/// `POST /api/auth/signup`. No session is created — the account is inert
|
||||
/// until the emailed confirmation link is opened, exactly like the web
|
||||
/// flow. The response is deliberately neutral (see the route's doc
|
||||
/// comment): callers cannot tell a new signup apart from "already
|
||||
/// registered" from this response alone.
|
||||
static func signup(name: String?, email: String, password: String) async throws -> SignupResponse {
|
||||
let request = try APIRequest.json(
|
||||
path: "/api/auth/signup",
|
||||
method: .post,
|
||||
body: SignupBody(name: name, email: email, password: password, lang: "de"),
|
||||
requiresAuth: false
|
||||
)
|
||||
return try await APIClient.shared.send(request)
|
||||
}
|
||||
|
||||
/// `POST /api/auth/login`. On success the backend returns the raw session
|
||||
/// token in the body (because the request carries `X-Client: ios` — see
|
||||
/// APIClient) instead of only a Set-Cookie header. The caller is
|
||||
/// responsible for persisting it via `KeychainTokenStore`.
|
||||
static func login(email: String, password: String, remember: Bool = true) async throws -> LoginResponse {
|
||||
let request = try APIRequest.json(
|
||||
path: "/api/auth/login",
|
||||
method: .post,
|
||||
body: LoginBody(email: email, password: password, remember: remember),
|
||||
requiresAuth: false
|
||||
)
|
||||
return try await APIClient.shared.send(request)
|
||||
}
|
||||
|
||||
/// `GET /api/auth/session` — "who am I". Never throws for "signed out";
|
||||
/// that's `{ user: null }`, HTTP 200.
|
||||
static func session() async throws -> SessionResponse {
|
||||
try await APIClient.shared.send(APIRequest(path: "/api/auth/session", method: .get))
|
||||
}
|
||||
|
||||
/// `POST /api/auth/logout`. Best-effort: the caller should clear the
|
||||
/// local token and navigate to the login screen regardless of whether
|
||||
/// this succeeds (mirrors the web route's own "logout always proceeds"
|
||||
/// philosophy).
|
||||
static func logout() async throws {
|
||||
try await APIClient.shared.sendDiscardingResponse(
|
||||
APIRequest(path: "/api/auth/logout", method: .post)
|
||||
)
|
||||
}
|
||||
}
|
||||
55
app/ios/ScanReceipts/Networking/ExportAPI.swift
Normal file
55
app/ios/ScanReceipts/Networking/ExportAPI.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// `/api/export/{csv,excel,pdf}` — Pro-only (403 `pro_required` for a
|
||||
/// free-plan account, surfaced via `APIError.server`). Every route takes the
|
||||
/// same request body and returns the raw file with its name in
|
||||
/// `Content-Disposition`; present the returned `Data` via `UIActivityViewController`
|
||||
/// (share sheet) or write it into a temp file and use `.fileExporter` /
|
||||
/// `QLPreviewController` — either is fine, this layer just gets you the bytes.
|
||||
enum ExportAPI {
|
||||
enum Format: String {
|
||||
case csv, excel, pdf
|
||||
|
||||
var path: String { "/api/export/\(rawValue)" }
|
||||
var fallbackExtension: String {
|
||||
switch self {
|
||||
case .csv: return "csv"
|
||||
case .excel: return "xlsx"
|
||||
case .pdf: return "pdf"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExportRequestBody: Encodable {
|
||||
let receipts: [Receipt]
|
||||
let locale: String
|
||||
// Only meaningful for the PDF export (date range on the cover page);
|
||||
// harmless to omit for csv/excel, which ignore unknown fields.
|
||||
var dateFrom: String?
|
||||
var dateTo: String?
|
||||
}
|
||||
|
||||
struct ExportResult {
|
||||
let data: Data
|
||||
let fileName: String
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - receipts: exactly the receipts to include — the backend does not
|
||||
/// look anything up server-side, it only formats what you send.
|
||||
/// - locale: `"de"` or `"en"`; anything else falls back to German on
|
||||
/// the backend, so just always pass one of the two.
|
||||
static func export(
|
||||
_ format: Format,
|
||||
receipts: [Receipt],
|
||||
locale: String = "de",
|
||||
dateFrom: String? = nil,
|
||||
dateTo: String? = nil
|
||||
) async throws -> ExportResult {
|
||||
let body = ExportRequestBody(receipts: receipts, locale: locale, dateFrom: dateFrom, dateTo: dateTo)
|
||||
let request = try APIRequest.json(path: format.path, method: .post, body: body)
|
||||
let (data, suggestedName) = try await APIClient.shared.sendForFile(request)
|
||||
let fileName = suggestedName ?? "Belege_Export.\(format.fallbackExtension)"
|
||||
return ExportResult(data: data, fileName: fileName)
|
||||
}
|
||||
}
|
||||
73
app/ios/ScanReceipts/Networking/KeychainTokenStore.swift
Normal file
73
app/ios/ScanReceipts/Networking/KeychainTokenStore.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Persists the session token issued by `POST /api/auth/login` /
|
||||
/// `/api/auth/signup` in the iOS Keychain — never UserDefaults, which is
|
||||
/// unencrypted on disk. This is the native counterpart to the web app's
|
||||
/// httpOnly session cookie (see `src/lib/auth/session.ts`); the backend has
|
||||
/// no idea which storage a given Bearer token came from.
|
||||
final class KeychainTokenStore {
|
||||
static let shared = KeychainTokenStore()
|
||||
|
||||
private let service = "app.scan-receipts.ios.session"
|
||||
private let account = "session-token"
|
||||
|
||||
private init() {}
|
||||
|
||||
var token: String? {
|
||||
get { read() }
|
||||
set {
|
||||
if let newValue {
|
||||
save(newValue)
|
||||
} else {
|
||||
delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func query(extra: [String: Any] = [:]) -> [String: Any] {
|
||||
var q: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
extra.forEach { q[$0] = $1 }
|
||||
return q
|
||||
}
|
||||
|
||||
private func read() -> String? {
|
||||
var query = self.query()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func save(_ token: String) {
|
||||
let data = Data(token.utf8)
|
||||
// Overwrite-or-insert: a plain SecItemAdd fails with errSecDuplicateItem
|
||||
// on every login after the first, since the (service, account) pair is
|
||||
// stable by design.
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
// Available as soon as the device is unlocked once after boot —
|
||||
// matches what a background-launched app (e.g. from a push
|
||||
// notification) needs, without requiring the passcode be entered
|
||||
// in *this* unlock cycle the way `WhenUnlocked` would.
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||
]
|
||||
let updateStatus = SecItemUpdate(query() as CFDictionary, attributes as CFDictionary)
|
||||
if updateStatus == errSecItemNotFound {
|
||||
var insert = query()
|
||||
attributes.forEach { insert[$0] = $1 }
|
||||
SecItemAdd(insert as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func delete() {
|
||||
SecItemDelete(query() as CFDictionary)
|
||||
}
|
||||
}
|
||||
32
app/ios/ScanReceipts/Networking/ProjectsAPI.swift
Normal file
32
app/ios/ScanReceipts/Networking/ProjectsAPI.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// `/api/projects` — Pro-only folders. `list()` itself is not gated (a
|
||||
/// downgraded Pro user can still see how their receipts were filed), but
|
||||
/// create/rename/delete return `402 pro_required` for a free-plan account —
|
||||
/// surface that as a paywall prompt, not a generic error.
|
||||
enum ProjectsAPI {
|
||||
static func list() async throws -> [Project] {
|
||||
let response: ProjectListResponse = try await APIClient.shared.send(
|
||||
APIRequest(path: "/api/projects", method: .get)
|
||||
)
|
||||
return response.projects
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func create(name: String, color: String?) async throws -> Project {
|
||||
struct Response: Decodable { let success: Bool; let project: Project }
|
||||
let request = try APIRequest.json(
|
||||
path: "/api/projects",
|
||||
method: .post,
|
||||
body: CreateProjectRequest(name: name, color: color)
|
||||
)
|
||||
let response: Response = try await APIClient.shared.send(request)
|
||||
return response.project
|
||||
}
|
||||
|
||||
static func delete(id: String) async throws {
|
||||
try await APIClient.shared.sendDiscardingResponse(
|
||||
APIRequest(path: "/api/projects/\(id)", method: .delete)
|
||||
)
|
||||
}
|
||||
}
|
||||
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