import Foundation import os.log private let logger = Logger(subsystem: "com.payfrit.works", category: "API") // MARK: - Dev Flag /// Master flag: flip to `false` for production builds. /// Controls API endpoint, dev banner, and magic OTPs. let IS_DEV = false // MARK: - API Errors enum APIError: LocalizedError { case invalidURL case noData case decodingError(String) case serverError(String) case unauthorized case networkError(String) case ratingRequired(customerUserId: Int) 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 case .ratingRequired: return "Rating required" } } } // MARK: - Login Response struct LoginResponse { let userId: Int let userFirstName: String let token: String let photoUrl: String } // MARK: - OTP Response struct SendOtpResponse { let uuid: String let message: String } // MARK: - Chat Messages Result struct ChatMessagesResult { let messages: [ChatMessage] let chatClosed: Bool } /// Backend-authoritative cash completion result. /// All monetary values are formatted strings (e.g. "12.50") from the server. struct CashCompletionResult { let cashReceived: String let orderTotal: String let change: String let customerFee: String? let businessFee: String? let cashRoutedTo: String // "worker" or "business" let balanceApplied: String? var changeDollars: Double { Double(change) ?? 0 } } // MARK: - API Service actor APIService { static let shared = APIService() private static let devBaseURL = "https://dev.payfrit.com/api" private static let prodBaseURL = "https://biz.payfrit.com/api" private var userToken: String? private var userId: Int? private var businessId: Int = 0 var baseURL: String { IS_DEV ? Self.devBaseURL : Self.prodBaseURL } // 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 = 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 getJSON(_ path: String) async throws -> [String: Any] { let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)") guard let url = URL(string: urlString) else { throw APIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" if let token = userToken, !token.isEmpty { request.setValue(token, forHTTPHeaderField: "X-User-Token") } if businessId > 0 { request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID") } 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)") } } 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", "TaskTypeName", "OrderTotal", "CashReceivedCents" ] /// 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.php", 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) } // MARK: - OTP Login func sendLoginOtp(phone: String) async throws -> SendOtpResponse { let json = try await postJSON("/auth/loginOTP.php", payload: [ "phone": phone ]) guard ok(json) else { throw APIError.serverError("Failed to send code: \(err(json))") } let data = json["DATA"] as? [String: Any] ?? json let uuid = (data["UUID"] as? String) ?? (data["uuid"] as? String) ?? "" let message = (data["MESSAGE"] as? String) ?? (data["message"] as? String) ?? "OTP sent" return SendOtpResponse(uuid: uuid, message: message) } func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse { let json = try await postJSON("/auth/verifyLoginOTP.php", payload: [ "uuid": uuid, "otp": otp ]) guard ok(json) else { throw APIError.serverError("Invalid code") } let data = json["DATA"] as? [String: Any] ?? json let uid = (data["UserID"] as? Int) ?? Int(data["UserID"] as? String ?? "") ?? (data["USERID"] as? Int) ?? 0 let token = (data["Token"] as? String) ?? (data["TOKEN"] as? String) ?? (data["token"] as? String) ?? "" let firstName = (data["FirstName"] as? String) ?? (data["FIRSTNAME"] as? String) ?? (data["USERFIRSTNAME"] as? String) ?? "" let photoUrl = (data["PhotoUrl"] as? String) ?? (data["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: - Profile struct UserProfile { let userId: Int let firstName: String let lastName: String let email: String let phone: String let photoUrl: String } func getProfile() async throws -> UserProfile { guard let uid = userId, uid > 0 else { throw APIError.serverError("User not logged in") } let json = try await postJSON("/auth/profile.php", payload: [ "UserID": uid ]) guard ok(json) else { throw APIError.serverError("Failed to load profile: \(err(json))") } let data = json["DATA"] as? [String: Any] ?? json return UserProfile( userId: (data["UserID"] as? Int) ?? (data["USERID"] as? Int) ?? uid, firstName: (data["FirstName"] as? String) ?? (data["FIRSTNAME"] as? String) ?? "", lastName: (data["LastName"] as? String) ?? (data["LASTNAME"] as? String) ?? "", email: (data["Email"] as? String) ?? (data["EMAIL"] as? String) ?? (data["EmailAddress"] as? String) ?? "", phone: (data["Phone"] as? String) ?? (data["PHONE"] as? String) ?? (data["ContactNumber"] as? String) ?? "", photoUrl: Self.resolvePhotoUrl((data["PhotoUrl"] as? String) ?? (data["PHOTOURL"] as? String) ?? "") ) } func updateProfile(firstName: String? = nil, lastName: String? = nil, email: String? = nil, phone: String? = nil) async throws { guard let uid = userId, uid > 0 else { throw APIError.serverError("User not logged in") } var payload: [String: Any] = ["UserID": uid] if let fn = firstName { payload["FirstName"] = fn } if let ln = lastName { payload["LastName"] = ln } if let em = email { payload["Email"] = em } if let ph = phone { payload["Phone"] = ph } let json = try await postJSON("/auth/profile.php", payload: payload) guard ok(json) else { throw APIError.serverError("Failed to update profile: \(err(json))") } } // 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.php", 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.php", 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.php", 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.php", 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) } } /// Complete a task. Returns CashCompletionResult when cashReceivedCents is provided. @discardableResult func completeTask(taskId: Int, workerRating: [String: Bool]? = nil, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws -> CashCompletionResult? { var payload: [String: Any] = [ "TaskID": taskId, "UserID": userId ?? 0 ] if let rating = workerRating { payload["workerRating"] = rating } if let cents = cashReceivedCents { payload["CashReceivedCents"] = cents } if cancelOrder { payload["CancelOrder"] = true } let json = try await postJSON("/tasks/complete.php", payload: payload) guard ok(json) else { let errorMsg = err(json) if errorMsg == "rating_required" { let customerUserId = json["CustomerUserID"] as? Int ?? 0 throw APIError.ratingRequired(customerUserId: customerUserId) } throw APIError.serverError("Failed to complete task: \(errorMsg)") } // Parse cash completion response from backend (authoritative values) if let _ = cashReceivedCents, json["CashProcessed"] as? Bool == true { return CashCompletionResult( cashReceived: json["CashReceived"] as? String ?? "0.00", orderTotal: json["OrderTotal"] as? String ?? "0.00", change: json["Change"] as? String ?? "0.00", customerFee: json["CustomerFee"] as? String, businessFee: json["BusinessFee"] as? String, cashRoutedTo: json["CashRoutedTo"] as? String ?? "worker", balanceApplied: json["BalanceApplied"] as? String ) } return nil } func closeChat(taskId: Int) async throws { let payload: [String: Any] = [ "TaskID": taskId ] let json = try await postJSON("/tasks/completeChat.php", payload: payload) guard ok(json) else { throw APIError.serverError("Close chat failed: \(err(json))") } } func getTaskDetails(taskId: Int) async throws -> TaskDetails { let json = try await postJSON("/tasks/getDetails.php", 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") } // Debug: log ALL fields to find customer data if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { let debugFile = docs.appendingPathComponent("task_debug.txt") var lines: [String] = ["=== ALL KEYS ==="] for key in taskJson.keys.sorted() { let val = taskJson[key] if let str = val as? String { lines.append("\(key): \(str)") } else if let num = val as? Int { lines.append("\(key): \(num)") } else if val is [[String: Any]] { lines.append("\(key): [array]") } else { lines.append("\(key): \(val ?? "nil")") } } try? lines.joined(separator: "\n").write(to: debugFile, atomically: true, encoding: .utf8) } 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.php", 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.php", 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.php", 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.php", 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.php", 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.php", 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.php", 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.php", 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: - Avatar func getAvatarUrl() async throws -> String? { let json = try await getJSON("/auth/avatar.php") print("[Avatar] Response: \(json)") guard ok(json) else { print("[Avatar] Response not OK") return nil } let data = json["DATA"] as? [String: Any] ?? json print("[Avatar] Data: \(data)") // Try all possible key variations for avatar URL let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url", "PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL", "UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"] for key in keys { if let url = data[key] as? String, !url.isEmpty { let resolved = Self.resolvePhotoUrl(url) print("[Avatar] Found key '\(key)' with value: \(url) -> \(resolved)") return resolved } if let url = json[key] as? String, !url.isEmpty { let resolved = Self.resolvePhotoUrl(url) print("[Avatar] Found key '\(key)' in json with value: \(url) -> \(resolved)") return resolved } } print("[Avatar] No avatar URL found in response") return nil } /// Get avatar URL for any user by their userId func getUserAvatarUrl(userId: Int) async throws -> String? { // Try the avatar endpoint with UserID let json = try await postJSON("/auth/avatar.php", payload: ["UserID": userId]) print("[Avatar] getUserAvatarUrl(\(userId)) Response: \(json)") let data = json["DATA"] as? [String: Any] ?? json let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url", "PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL", "UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"] for key in keys { if let url = data[key] as? String, !url.isEmpty { print("[Avatar] Found avatar for userId \(userId): \(url)") return Self.resolvePhotoUrl(url) } if let url = json[key] as? String, !url.isEmpty { print("[Avatar] Found avatar for userId \(userId): \(url)") return Self.resolvePhotoUrl(url) } } print("[Avatar] No avatar found for userId \(userId), all keys: \(json.keys.sorted())") return nil } /// Construct direct avatar URL for a user (fallback pattern) nonisolated static func directAvatarUrl(userId: Int) -> String { let baseDomain = IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com" return "\(baseDomain)/uploads/avatars/\(userId).jpg" } // MARK: - Beacon Sharding func resolveServicePoint(uuid: String, major: Int, minor: Int) async throws -> Int { let json = try await postJSON("/beacon-sharding/resolve_servicepoint.php", payload: [ "UUID": uuid, "Major": major, "Minor": minor ]) guard ok(json) else { throw APIError.serverError("Failed to resolve service point: \(err(json))") } let data = json["DATA"] as? [String: Any] ?? json if let spId = data["ServicePointID"] as? Int { return spId } if let spId = data["SERVICEPOINTID"] as? Int { return spId } if let spId = data["servicePointId"] as? Int { return spId } if let spId = json["ServicePointID"] as? Int { return spId } if let spId = json["SERVICEPOINTID"] as? Int { return spId } throw APIError.decodingError("ServicePointID not found in response") } func resolveBusiness(uuid: String, major: Int) async throws -> (businessId: Int, businessName: String) { let json = try await postJSON("/beacon-sharding/resolve_business.php", payload: [ "UUID": uuid, "Major": major ]) guard ok(json) else { throw APIError.serverError("Failed to resolve business: \(err(json))") } let data = json["DATA"] as? [String: Any] ?? json let bizId = (data["BusinessID"] as? Int) ?? (data["BUSINESSID"] as? Int) ?? (json["BusinessID"] as? Int) ?? 0 let bizName = (data["BusinessName"] as? String) ?? (data["BUSINESSNAME"] as? String) ?? (json["BusinessName"] as? String) ?? "" return (businessId: bizId, businessName: bizName) } // 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 = 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: - About Info func getAboutInfo() async throws -> AboutInfo { let json = try await getJSON("app/about.php") guard ok(json) else { throw APIError.serverError(err(json)) } let description = parseString(json["DESCRIPTION"] ?? json["description"]) let copyright = parseString(json["COPYRIGHT"] ?? json["copyright"]) // Parse features var features: [AboutFeature] = [] if let featuresArray = (json["FEATURES"] ?? json["features"]) as? [[String: Any]] { for f in featuresArray { features.append(AboutFeature( icon: parseString(f["ICON"] ?? f["icon"]), title: parseString(f["TITLE"] ?? f["title"]), description: parseString(f["DESCRIPTION"] ?? f["description"]) )) } } // Parse contacts var contacts: [AboutContact] = [] if let contactsArray = (json["CONTACTS"] ?? json["contacts"]) as? [[String: Any]] { for c in contactsArray { contacts.append(AboutContact( icon: parseString(c["ICON"] ?? c["icon"]), label: parseString(c["LABEL"] ?? c["label"]), url: parseString(c["URL"] ?? c["url"]) )) } } return AboutInfo( description: description, features: features, contacts: contacts, copyright: copyright ) } // 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 } let baseDomain = IS_DEV ? "https://dev.payfrit.com" : "https://biz.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 } } // MARK: - String Parsing Helper /// Parse any value to a string safely private func parseString(_ value: Any?) -> String { guard let v = value else { return "" } if let s = v as? String { return s } if let n = v as? NSNumber { return n.stringValue } if let i = v as? Int { return String(i) } if let d = v as? Double { return String(d) } return String(describing: v) } // MARK: - About Info Models struct AboutInfo { let description: String let features: [AboutFeature] let contacts: [AboutContact] let copyright: String } struct AboutFeature { let icon: String let title: String let description: String } struct AboutContact { let icon: String let label: String let url: String }