feat: add iOS support and harden receipt scanning
This commit is contained in:
26
app/ios/ScanReceipts/Models/Project.swift
Normal file
26
app/ios/ScanReceipts/Models/Project.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// Mirrors the objects returned by `GET /api/projects` (src/app/api/projects/route.ts).
|
||||
/// Projects are Pro-only "folders" receipts can be filed under.
|
||||
struct Project: Codable, Identifiable, Equatable {
|
||||
let id: String
|
||||
var name: String
|
||||
var color: String?
|
||||
var receiptCount: Int
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
/// Fixed palette the web app offers when creating/editing a project —
|
||||
/// keep in sync with `PROJECT_COLORS` in src/app/api/projects/route.ts.
|
||||
static let colorPalette = ["slate", "blue", "emerald", "amber", "rose", "violet"]
|
||||
}
|
||||
|
||||
struct ProjectListResponse: Decodable {
|
||||
let success: Bool
|
||||
let projects: [Project]
|
||||
}
|
||||
|
||||
struct CreateProjectRequest: Encodable {
|
||||
let name: String
|
||||
let color: String?
|
||||
}
|
||||
148
app/ios/ScanReceipts/Models/Receipt.swift
Normal file
148
app/ios/ScanReceipts/Models/Receipt.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
import Foundation
|
||||
|
||||
/// Mirrors `ProcessedReceipt` (src/lib/schema/receipt.ts) exactly — this is
|
||||
/// the shape returned by `GET /api/receipts` and accepted by
|
||||
/// `POST /api/receipts`. Keep this file and that Zod schema in sync; when the
|
||||
/// web schema gains a field, add it here too rather than silently dropping it
|
||||
/// on decode (optional fields decode fine as `nil` when absent, so older
|
||||
/// receipts synced before a schema change won't crash the app).
|
||||
struct Receipt: Codable, Identifiable, Equatable {
|
||||
let id: String
|
||||
var projectId: String?
|
||||
let imageHash: String
|
||||
let originalFileName: String
|
||||
let fileSizeBytes: Int
|
||||
var previewUrl: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
var status: ReceiptStatus
|
||||
|
||||
var merchant: Merchant
|
||||
var date: ReceiptDate
|
||||
var documentType: DocumentType
|
||||
var receiptNumber: String?
|
||||
var currency: String
|
||||
var totalAmount: AmountWithConfidence
|
||||
var netAmount: Double?
|
||||
var tipAmount: Double?
|
||||
var taxBreakdown: [TaxBreakdownItem]
|
||||
var lineItems: [LineItem]
|
||||
var suggestedCategory: ReceiptCategory
|
||||
var paymentMethod: PaymentMethod?
|
||||
var hospitality: Hospitality?
|
||||
var validation: Validation
|
||||
|
||||
/// Total actually paid: `totalAmount.value + tipAmount`. Mirrors
|
||||
/// `grossWithTip()` on the backend — tip is deliberately excluded from
|
||||
/// `totalAmount` there because VAT is never charged on it.
|
||||
var grossWithTip: Double {
|
||||
((totalAmount.value + (tipAmount ?? 0)) * 100).rounded() / 100
|
||||
}
|
||||
|
||||
struct Merchant: Codable, Equatable {
|
||||
var name: String
|
||||
var address: String?
|
||||
var taxId: String?
|
||||
var confidence: Double
|
||||
}
|
||||
|
||||
struct ReceiptDate: Codable, Equatable {
|
||||
var isoDate: String
|
||||
var time: String?
|
||||
var confidence: Double
|
||||
}
|
||||
|
||||
struct AmountWithConfidence: Codable, Equatable {
|
||||
var value: Double
|
||||
var confidence: Double
|
||||
}
|
||||
|
||||
struct TaxBreakdownItem: Codable, Equatable, Identifiable {
|
||||
var ratePercent: Double
|
||||
var taxAmount: Double
|
||||
var netAmount: Double?
|
||||
var id: String { "\(ratePercent)-\(taxAmount)" }
|
||||
}
|
||||
|
||||
struct LineItem: Codable, Equatable, Identifiable {
|
||||
var id = UUID()
|
||||
var description: String
|
||||
var quantity: Double
|
||||
var price: Double
|
||||
var unitPrice: Double?
|
||||
var taxRate: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case description, quantity, price, unitPrice, taxRate
|
||||
}
|
||||
}
|
||||
|
||||
struct Hospitality: Codable, Equatable {
|
||||
var occasion: String?
|
||||
var participants: String?
|
||||
}
|
||||
|
||||
struct Validation: Codable, Equatable {
|
||||
var isMathValid: Bool
|
||||
var isDuplicateSuspected: Bool
|
||||
var needsUserReview: Bool
|
||||
var reviewField: String
|
||||
var reviewReason: String?
|
||||
var issues: [Issue]?
|
||||
var userConfirmed: Bool?
|
||||
|
||||
struct Issue: Codable, Equatable {
|
||||
var field: String
|
||||
var severity: String
|
||||
var message: String
|
||||
}
|
||||
}
|
||||
|
||||
enum ReceiptStatus: String, Codable {
|
||||
case pending, processing, ready, needsReview = "needs_review", error
|
||||
}
|
||||
|
||||
enum DocumentType: String, Codable, CaseIterable {
|
||||
case kassenbon = "KASSENBON"
|
||||
case rechnung = "RECHNUNG"
|
||||
case tankbeleg = "TANKBELEG"
|
||||
case bewirtungsbeleg = "BEWIRTUNGSBELEG"
|
||||
case parkticket = "PARKTICKET"
|
||||
case sonstiges = "SONSTIGES"
|
||||
}
|
||||
|
||||
enum PaymentMethod: String, Codable, CaseIterable {
|
||||
case bar = "BAR"
|
||||
case ecKarte = "EC_KARTE"
|
||||
case kreditkarte = "KREDITKARTE"
|
||||
case ueberweisung = "UEBERWEISUNG"
|
||||
case paypal = "PAYPAL"
|
||||
case applePay = "APPLE_PAY"
|
||||
case googlePay = "GOOGLE_PAY"
|
||||
case sonstige = "SONSTIGE"
|
||||
}
|
||||
|
||||
enum ReceiptCategory: String, Codable, CaseIterable {
|
||||
case bewirtung = "Bewirtung"
|
||||
case reisekosten = "Reisekosten & Hotel"
|
||||
case tanken = "Tanken & KFZ"
|
||||
case buerobedarf = "Bürobedarf & IT"
|
||||
case verpflegungsmehraufwand = "Verpflegungsmehraufwand"
|
||||
case material = "Material & Einkauf"
|
||||
case sonstiges = "Sonstiges"
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/receipts` response envelope.
|
||||
struct ReceiptListResponse: Decodable {
|
||||
let success: Bool
|
||||
let receipts: [Receipt]
|
||||
let count: Int
|
||||
}
|
||||
|
||||
/// `POST /api/receipts` response envelope (batch upsert).
|
||||
struct ReceiptSyncResponse: Decodable {
|
||||
let success: Bool
|
||||
let syncedCount: Int?
|
||||
let message: String?
|
||||
}
|
||||
88
app/ios/ScanReceipts/Models/User.swift
Normal file
88
app/ios/ScanReceipts/Models/User.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
/// Mirrors the `user` object returned by `GET /api/auth/session` and
|
||||
/// `POST /api/auth/login` (src/app/api/auth/session/route.ts). Field names
|
||||
/// are already camelCase in the JSON, so no custom `CodingKeys`/key-decoding
|
||||
/// strategy is needed anywhere in this file.
|
||||
///
|
||||
/// Date fields are kept as raw ISO-8601 strings (with fractional seconds,
|
||||
/// e.g. `2026-08-20T19:37:42.307Z` from `Date.toISOString()`) rather than
|
||||
/// `Date`, because `JSONDecoder`'s built-in `.iso8601` strategy does NOT
|
||||
/// parse fractional seconds and would silently fail to decode every
|
||||
/// timestamp this backend sends. Use `ISO8601.parse(_:)` (Support/ISO8601.swift)
|
||||
/// where you actually need a `Date`.
|
||||
struct User: Decodable, Identifiable, Equatable {
|
||||
let id: String
|
||||
let email: String?
|
||||
let name: String?
|
||||
let plan: String
|
||||
let isGuest: Bool
|
||||
let isPro: Bool
|
||||
let cancelAtPeriodEnd: Bool
|
||||
let emailVerified: Bool
|
||||
let launchBonus: Bool
|
||||
let freeScanAllowance: Int
|
||||
let scanCount: Int
|
||||
let company: String?
|
||||
let useCase: String?
|
||||
let expiresAt: String?
|
||||
let createdAt: String?
|
||||
let onboardingCompletedAt: String?
|
||||
|
||||
/// True once onboarding (company/use-case/etc.) has been completed — the
|
||||
/// dashboard's post-signup wizard on web, skippable but tracked the same
|
||||
/// way in the app.
|
||||
var hasCompletedOnboarding: Bool { onboardingCompletedAt != nil }
|
||||
|
||||
/// Reconstructs the UUID `newId("usr")` originally minted this account's
|
||||
/// `id` from (see `src/lib/auth/tokens.ts` on the backend: `"usr_" +
|
||||
/// randomUUID().replace(/-/g, "")` — i.e. `id` IS a UUID with its dashes
|
||||
/// stripped and a prefix glued on, nothing more). Re-inserting the
|
||||
/// dashes recovers that exact UUID losslessly — no extra network call,
|
||||
/// no server-issued token to fetch and cache.
|
||||
///
|
||||
/// Passed as StoreKit's `appAccountToken` on purchase (see
|
||||
/// `StoreKitPurchaseService.purchase`) so `POST /api/webhooks/apple`
|
||||
/// (`src/lib/billing/appleIAP.ts` → `userIdFromAppAccountToken`) can
|
||||
/// reverse the same transformation server-side and know which account a
|
||||
/// given App Store transaction belongs to — that's the ONLY thing this
|
||||
/// value is for; it carries no other meaning to Apple.
|
||||
var appleAccountToken: UUID? {
|
||||
guard id.hasPrefix("usr_") else { return nil }
|
||||
let hex = String(id.dropFirst(4))
|
||||
guard hex.count == 32, hex.allSatisfy(\.isHexDigit) else { return nil }
|
||||
let parts = [
|
||||
hex.prefix(8),
|
||||
hex.dropFirst(8).prefix(4),
|
||||
hex.dropFirst(12).prefix(4),
|
||||
hex.dropFirst(16).prefix(4),
|
||||
hex.dropFirst(20),
|
||||
]
|
||||
return UUID(uuidString: parts.joined(separator: "-"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimal user echo returned inline by `POST /api/auth/login` and
|
||||
/// `/signup` (a subset of `User` — those endpoints don't run the extra
|
||||
/// queries `/api/auth/session` does). Kept separate so a login response
|
||||
/// decodes without requiring fields it doesn't send.
|
||||
struct LoginResponse: Decodable {
|
||||
let status: String
|
||||
let user: LoginUser
|
||||
/// Present only when the request carried `X-Client: ios` — see
|
||||
/// src/app/api/auth/login/route.ts. Absent for a browser-style caller.
|
||||
let token: String?
|
||||
let expiresAt: String?
|
||||
}
|
||||
|
||||
struct LoginUser: Decodable {
|
||||
let id: String
|
||||
let email: String?
|
||||
let name: String?
|
||||
let plan: String
|
||||
let onboardingCompletedAt: String?
|
||||
}
|
||||
|
||||
struct SessionResponse: Decodable {
|
||||
let user: User?
|
||||
}
|
||||
Reference in New Issue
Block a user