import Foundation // MARK: - API Errors enum APIError: LocalizedError { case invalidURL case noData case decodingError(String) case serverError(String) case unauthorized case networkError(String) var errorDescription: String? { switch self { case .invalidURL: return "Invalid URL" case .noData: return "No data received" case .decodingError(let msg): return "Decoding error: \(msg)" case .serverError(let msg): return msg case .unauthorized: return "Unauthorized" case .networkError(let msg): return msg } } } // MARK: - Login Response struct LoginResponse { let userId: Int let userFirstName: String let token: String let photoUrl: String } // MARK: - Chat Messages Result struct ChatMessagesResult { let messages: [ChatMessage] let chatClosed: Bool } // MARK: - API Service actor APIService { static let shared = APIService() private enum Environment { case development, production var baseURL: String { switch self { case .development: return "https://dev.payfrit.com/api" case .production: return "https://biz.payfrit.com/api" } } } private let environment: Environment = .development var isDev: Bool { environment == .development } private var userToken: String? private var userId: Int? private var businessId: Int = 0 var baseURL: String { environment.baseURL } // MARK: - Configuration func setAuth(token: String?, userId: Int?) { self.userToken = token self.userId = userId } func setBusinessId(_ id: Int) { self.businessId = id } func getToken() -> String? { userToken } func getUserId() -> Int? { userId } func getBusinessId() -> Int { businessId } // MARK: - Core Request private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] { let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)") guard let url = URL(string: urlString) else { throw APIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") if let token = userToken, !token.isEmpty { request.setValue(token, forHTTPHeaderField: "X-User-Token") } if businessId > 0 { request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID") } request.httpBody = try JSONSerialization.data(withJSONObject: payload) let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode == 401 { throw APIError.unauthorized } guard (200...299).contains(httpResponse.statusCode) else { throw APIError.serverError("HTTP \(httpResponse.statusCode)") } } // Try to parse JSON, handling CFML prefix junk if let json = tryDecodeJSON(data) { return json } throw APIError.decodingError("Non-JSON response") } private func tryDecodeJSON(_ data: Data) -> [String: Any]? { // Try direct parse if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return json } // Try extracting JSON from mixed response guard let body = String(data: data, encoding: .utf8), let start = body.firstIndex(of: "{"), let end = body.lastIndex(of: "}") else { return nil } let jsonStr = String(body[start...end]) guard let jsonData = jsonStr.data(using: .utf8) else { return nil } return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] } private func ok(_ json: [String: Any]) -> Bool { if let b = json["OK"] as? Bool { return b } if let b = json["ok"] as? Bool { return b } if let i = json["OK"] as? Int { return i == 1 } if let s = json["OK"] as? String { return s == "true" || s == "1" || s == "YES" } return false } private func err(_ json: [String: Any]) -> String { (json["ERROR"] as? String) ?? (json["error"] as? String) ?? (json["Error"] as? String) ?? (json["message"] as? String) ?? "" } /// All expected PascalCase keys our models use. nonisolated private static let expectedKeys: [String] = [ "TaskID", "TaskBusinessID", "TaskCategoryID", "TaskTypeID", "TaskTitle", "TaskDetails", "TaskCreatedOn", "TaskStatusID", "TaskSourceType", "TaskSourceID", "TaskCategoryName", "TaskCategoryColor", "EmployeeID", "BusinessID", "BusinessName", "BusinessAddress", "BusinessCity", "EmployeeStatusID", "PendingTaskCount", "OrderID", "OrderRemarks", "OrderSubmittedOn", "ServicePointID", "ServicePointName", "ServicePointTypeID", "DeliveryAddress", "DeliveryLat", "DeliveryLng", "CustomerUserID", "CustomerFirstName", "CustomerLastName", "CustomerPhone", "CustomerPhotoUrl", "BeaconUUID", "LineItems", "TableMembers", "LineItemID", "ItemName", "Quantity", "PriceCents", "Remark", "IsModifier", "ParentLineItemID", "UserID", "FirstName", "LastName", "IsHost", "MessageID", "SenderUserID", "SenderType", "SenderName", "Text", "MessageText", "CreatedOn", "IsRead", "OK", "ERROR", "TASKS", "BUSINESSES", "MESSAGES", "TASK", "UserFirstName", "Token", "PhotoUrl", "UserPhotoUrl", "TIER", "STRIPE", "ACTIVATION", "HasAccount", "PayoutsEnabled", "SetupIncomplete", "BalanceCents", "CapCents", "RemainingCents", "IsComplete", "ProgressPercent", "ENTRIES", "TOTALS", "ID", "GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt", "TotalGrossCents", "TotalWithheldCents", "TotalNetCents", "AccountID", "ACCOUNT_ID", "URL", "url", "CHAT_CLOSED", "chat_closed", "PayCents" ] /// Build a lookup: stripped key (no underscores, lowercased) → expected PascalCase key. /// This handles ALL CAPS, underscore_separated, camelCase, PascalCase — any format. nonisolated private static let keyLookup: [String: String] = { var map: [String: String] = [:] for k in expectedKeys { // Strip underscores and lowercase for fuzzy matching let stripped = k.replacingOccurrences(of: "_", with: "").lowercased() map[stripped] = k // Also add exact uppercased (for direct ALL CAPS match) map[k.uppercased()] = k } return map }() /// Normalize dictionary keys: maps ANY casing/format to expected PascalCase. /// Strips underscores and lowercases for fuzzy matching against known keys. nonisolated static func normalizeKeys(_ dict: [String: Any]) -> [String: Any] { var result: [String: Any] = [:] for (key, value) in dict { // Try exact uppercased match first, then stripped fuzzy match let stripped = key.replacingOccurrences(of: "_", with: "").lowercased() let normalizedKey = keyLookup[key.uppercased()] ?? keyLookup[stripped] ?? key if let arr = value as? [[String: Any]] { result[normalizedKey] = arr.map { normalizeKeys($0) } } else if let sub = value as? [String: Any] { result[normalizedKey] = normalizeKeys(sub) } else { result[normalizedKey] = value } } return result } /// Find an array value in the JSON by trying multiple key variants nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? { for key in keys { if let arr = json[key] as? [[String: Any]] { return arr } } // Fallback: search all values for the first array of dicts for (_, value) in json { if let arr = value as? [[String: Any]], !arr.isEmpty { return arr } } return nil } // MARK: - Auth func login(username: String, password: String) async throws -> LoginResponse { let json = try await postJSON("/auth/login.cfm", payload: [ "username": username, "password": password ]) guard ok(json) else { let e = err(json) if e == "bad_credentials" { throw APIError.serverError("Invalid email/phone or password") } throw APIError.serverError("Login failed: \(e)") } let uid = (json["UserID"] as? Int) ?? Int(json["UserID"] as? String ?? "") ?? (json["UserId"] as? Int) ?? 0 let token = (json["Token"] as? String) ?? (json["token"] as? String) ?? "" let firstName = (json["UserFirstName"] as? String) ?? (json["FirstName"] as? String) ?? (json["firstName"] as? String) ?? (json["Name"] as? String) ?? (json["name"] as? String) ?? "" let photoUrl = (json["UserPhotoUrl"] as? String) ?? (json["PhotoUrl"] as? String) ?? (json["photoUrl"] as? String) ?? "" self.userToken = token self.userId = uid return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl) } func logout() { userToken = nil userId = nil businessId = 0 } // MARK: - Businesses func getMyBusinesses() async throws -> [Employment] { guard let uid = userId, uid > 0 else { throw APIError.serverError("User not logged in") } let json = try await postJSON("/workers/myBusinesses.cfm", payload: [ "UserID": uid ]) guard ok(json) else { throw APIError.serverError("Failed to load businesses: \(err(json))") } guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else { return [] } return arr.map { Employment(json: $0) } } // MARK: - Tasks func listPendingTasks(categoryId: Int? = nil) async throws -> [WorkTask] { var payload: [String: Any] = ["BusinessID": businessId] if let cid = categoryId, cid > 0 { payload["CategoryID"] = cid } let json = try await postJSON("/tasks/listPending.cfm", payload: payload) guard ok(json) else { throw APIError.serverError("Failed to load tasks: \(err(json))") } guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] } return arr.map { WorkTask(json: $0) } } func acceptTask(taskId: Int) async throws { // Flutter only sends TaskID + BusinessID (no UserID) let json = try await postJSON("/tasks/accept.cfm", payload: [ "TaskID": taskId, "BusinessID": businessId ]) guard ok(json) else { let e = err(json) if e == "already_accepted" { throw APIError.serverError("This task has already been claimed by someone else.") } throw APIError.serverError("Failed to accept task: \(e)") } } func listMyTasks(filterType: String = "active") async throws -> [WorkTask] { var payload: [String: Any] = [ "UserID": userId ?? 0, "FilterType": filterType ] if businessId > 0 { payload["BusinessID"] = businessId } let json = try await postJSON("/tasks/listMine.cfm", payload: payload) guard ok(json) else { throw APIError.serverError("Failed to load my tasks: \(err(json))") } guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] } return arr.map { WorkTask(json: $0) } } func completeTask(taskId: Int) async throws { let json = try await postJSON("/tasks/complete.cfm", payload: [ "TaskID": taskId, "UserID": userId ?? 0 ]) guard ok(json) else { throw APIError.serverError("Failed to complete task: \(err(json))") } } func closeChat(taskId: Int) async throws { let json = try await postJSON("/tasks/completeChat.cfm", payload: [ "TaskID": taskId ]) guard ok(json) else { throw APIError.serverError("Failed to close chat: \(err(json))") } } func getTaskDetails(taskId: Int) async throws -> TaskDetails { let json = try await postJSON("/tasks/getDetails.cfm", payload: [ "TaskID": taskId ]) guard ok(json) else { throw APIError.serverError("Failed to load task details: \(err(json))") } // Find the TASK object — try known keys, then fallback to first dict value var taskJson: [String: Any]? for key in ["TASK", "Task", "task"] { if let d = json[key] as? [String: Any] { taskJson = d; break } } if taskJson == nil { // Fallback: look for first nested dict that looks like a task for (_, value) in json { if let d = value as? [String: Any], d.count > 3 { taskJson = d; break } } } guard let taskJson = taskJson else { throw APIError.serverError("Invalid task details response") } let details = TaskDetails(json: taskJson) return details } // MARK: - Chat func getChatMessages(taskId: Int, afterMessageId: Int? = nil) async throws -> ChatMessagesResult { var payload: [String: Any] = ["TaskID": taskId] if let after = afterMessageId, after > 0 { payload["AfterMessageID"] = after } let json = try await postJSON("/chat/getMessages.cfm", payload: payload) guard ok(json) else { throw APIError.serverError("Failed to load chat messages") } let arr = Self.findArray(json, ["MESSAGES", "Messages", "messages"]) ?? [] let messages: [ChatMessage] = arr.map { ChatMessage(json: $0) } let chatClosed = (json["CHAT_CLOSED"] as? Bool) == true || (json["chat_closed"] as? Bool) == true return ChatMessagesResult(messages: messages, chatClosed: chatClosed) } func sendChatMessage(taskId: Int, message: String, userId: Int? = nil, senderType: String? = nil) async throws -> Int { var payload: [String: Any] = [ "TaskID": taskId, "Message": message ] if let uid = userId { payload["UserID"] = uid } if let st = senderType { payload["SenderType"] = st } let json = try await postJSON("/chat/sendMessage.cfm", payload: payload) guard ok(json) else { throw APIError.serverError("Failed to send message") } return (json["MessageID"] as? Int) ?? (json["MESSAGE_ID"] as? Int) ?? 0 } func markChatMessagesRead(taskId: Int, readerType: String) async throws { let json = try await postJSON("/chat/markRead.cfm", payload: [ "TaskID": taskId, "ReaderType": readerType ]) guard ok(json) else { throw APIError.serverError("Failed to mark messages as read") } } // MARK: - Payout / Tier Endpoints func getTierStatus() async throws -> TierStatus { let json = try await postJSON("/workers/tierStatus.cfm", payload: [ "UserID": userId ?? 0 ]) guard ok(json) else { throw APIError.serverError("Failed to load tier status: \(err(json))") } let data = try JSONSerialization.data(withJSONObject: json) return (try? JSONDecoder().decode(TierStatus.self, from: data)) ?? TierStatus() } func createStripeAccount() async throws -> String { let json = try await postJSON("/workers/createAccount.cfm", payload: [ "UserID": userId ?? 0 ]) guard ok(json) else { throw APIError.serverError("Failed to create Stripe account: \(err(json))") } return (json["AccountID"] as? String) ?? (json["ACCOUNT_ID"] as? String) ?? "" } func getOnboardingLink() async throws -> String { let json = try await postJSON("/workers/onboardingLink.cfm", payload: [ "UserID": userId ?? 0 ]) guard ok(json) else { throw APIError.serverError("Failed to get onboarding link: \(err(json))") } return (json["URL"] as? String) ?? (json["url"] as? String) ?? "" } func getEarlyUnlockUrl() async throws -> String { let json = try await postJSON("/workers/earlyUnlock.cfm", payload: [ "UserID": userId ?? 0 ]) guard ok(json) else { throw APIError.serverError("Failed to get unlock URL: \(err(json))") } return (json["URL"] as? String) ?? (json["url"] as? String) ?? "" } func getLedger() async throws -> LedgerResponse { let json = try await postJSON("/workers/ledger.cfm", payload: [ "UserID": userId ?? 0 ]) guard ok(json) else { throw APIError.serverError("Failed to load ledger: \(err(json))") } let data = try JSONSerialization.data(withJSONObject: json) return try JSONDecoder().decode(LedgerResponse.self, from: data) } // MARK: - Debug /// Returns raw JSON string for a given endpoint (for debugging key issues) func debugRawJSON(_ path: String, payload: [String: Any]) async -> String { let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)") guard let url = URL(string: urlString) else { return "Invalid URL" } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") if let token = userToken, !token.isEmpty { request.setValue(token, forHTTPHeaderField: "X-User-Token") } if businessId > 0 { request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID") } request.httpBody = try? JSONSerialization.data(withJSONObject: payload) guard let (data, _) = try? await URLSession.shared.data(for: request) else { return "Network error" } let raw = String(data: data, encoding: .utf8) ?? "Non-UTF8" // Return first 2000 chars return String(raw.prefix(2000)) } // MARK: - URL Helpers /// Resolve a photo URL — if it starts with "/" prepend the base domain nonisolated static func resolvePhotoUrl(_ rawUrl: String) -> String { let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return "" } if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed } // Relative URL — prepend base domain let baseDomain = "https://dev.payfrit.com" if trimmed.hasPrefix("/") { return baseDomain + trimmed } return baseDomain + "/" + trimmed } // MARK: - Date Parsing private static let iso8601Formatter: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() private static let iso8601NoFrac: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() private static let simpleDateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd HH:mm:ss" f.locale = Locale(identifier: "en_US_POSIX") return f }() private static let cfmlDateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z" f.locale = Locale(identifier: "en_US_POSIX") return f }() private static let cfmlShortFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" f.locale = Locale(identifier: "en_US_POSIX") return f }() private static let cfmlAltFormatters: [DateFormatter] = { let formats = [ "MMM dd, yyyy HH:mm:ss", "MM/dd/yyyy HH:mm:ss", "yyyy-MM-dd HH:mm:ss.S", "yyyy-MM-dd'T'HH:mm:ss.S", "yyyy-MM-dd'T'HH:mm:ssZ", "yyyy-MM-dd'T'HH:mm:ss.SZ", ] return formats.map { fmt in let f = DateFormatter() f.dateFormat = fmt f.locale = Locale(identifier: "en_US_POSIX") return f } }() nonisolated static func parseDate(_ string: String) -> Date? { let s = string.trimmingCharacters(in: .whitespacesAndNewlines) if s.isEmpty { return nil } // Try epoch (milliseconds or seconds) if let epoch = Double(s) { if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) } if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) } } if let d = iso8601Formatter.date(from: s) { return d } if let d = iso8601NoFrac.date(from: s) { return d } if let d = simpleDateFormatter.date(from: s) { return d } if let d = cfmlDateFormatter.date(from: s) { return d } if let d = cfmlShortFormatter.date(from: s) { return d } for formatter in cfmlAltFormatters { if let d = formatter.date(from: s) { return d } } return nil } }