import Foundation // MARK: - API Errors enum APIError: LocalizedError, Equatable { 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: - 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)") } } if let json = tryDecodeJSON(data) { return json } throw APIError.decodingError("Non-JSON response") } private func tryDecodeJSON(_ data: Data) -> [String: Any]? { if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return json } 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 { for key in ["OK", "ok", "Ok"] { if let b = json[key] as? Bool { return b } if let i = json[key] as? Int { return i == 1 } if let s = json[key] as? String { let lower = s.lowercased() return lower == "true" || lower == "1" || lower == "yes" } } return false } private func err(_ json: [String: Any]) -> String { let msg = (json["ERROR"] as? String) ?? (json["error"] as? String) ?? (json["Error"] as? String) ?? (json["message"] as? String) ?? "" return msg.isEmpty ? "Unknown error" : msg } 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 } } 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) ?? "" guard uid > 0 else { throw APIError.serverError("Login failed: no user ID returned") } guard !token.isEmpty else { throw APIError.serverError("Login failed: no token returned") } 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: - Beacons func listBeacons() async throws -> [Beacon] { let json = try await postJSON("/beacons/list.cfm", payload: [ "BusinessID": businessId ]) guard ok(json) else { throw APIError.serverError("Failed to load beacons: \(err(json))") } guard let arr = Self.findArray(json, ["BEACONS", "Beacons", "beacons"]) else { return [] } return arr.map { Beacon(json: $0) } } func getBeacon(beaconId: Int) async throws -> Beacon { let json = try await postJSON("/beacons/get.cfm", payload: [ "BeaconID": beaconId, "BusinessID": businessId ]) guard ok(json) else { throw APIError.serverError("Failed to load beacon: \(err(json))") } var beaconJson: [String: Any]? for key in ["BEACON", "Beacon", "beacon"] { if let d = json[key] as? [String: Any] { beaconJson = d; break } } if beaconJson == nil { for (_, value) in json { if let d = value as? [String: Any], d.count > 3 { beaconJson = d; break } } } guard let beaconJson = beaconJson else { throw APIError.serverError("Invalid beacon response") } return Beacon(json: beaconJson) } func createBeacon(name: String, uuid: String) async throws -> Int { let json = try await postJSON("/beacons/create.cfm", payload: [ "BusinessID": businessId, "Name": name, "UUID": uuid ]) guard ok(json) else { throw APIError.serverError("Failed to create beacon: \(err(json))") } return (json["BeaconID"] as? Int) ?? (json["ID"] as? Int) ?? Int(json["BeaconID"] as? String ?? "") ?? 0 } func updateBeacon(beaconId: Int, name: String, uuid: String, isActive: Bool) async throws { let json = try await postJSON("/beacons/update.cfm", payload: [ "BeaconID": beaconId, "BusinessID": businessId, "Name": name, "UUID": uuid, "IsActive": isActive ]) guard ok(json) else { throw APIError.serverError("Failed to update beacon: \(err(json))") } } func deleteBeacon(beaconId: Int) async throws { let json = try await postJSON("/beacons/delete.cfm", payload: [ "BeaconID": beaconId, "BusinessID": businessId ]) guard ok(json) else { throw APIError.serverError("Failed to delete beacon: \(err(json))") } } // MARK: - Service Points func listServicePoints() async throws -> [ServicePoint] { let json = try await postJSON("/servicePoints/list.cfm", payload: [ "BusinessID": businessId ]) guard ok(json) else { throw APIError.serverError("Failed to load service points: \(err(json))") } guard let arr = Self.findArray(json, ["SERVICE_POINTS", "ServicePoints", "servicePoints", "SERVICEPOINTS"]) else { return [] } return arr.map { ServicePoint(json: $0) } } func assignBeaconToServicePoint(servicePointId: Int, beaconId: Int?) async throws { var payload: [String: Any] = [ "ServicePointID": servicePointId, "BusinessID": businessId ] if let bid = beaconId { payload["BeaconID"] = bid } let json = try await postJSON("/servicePoints/assignBeacon.cfm", payload: payload) guard ok(json) else { throw APIError.serverError("Failed to assign beacon: \(err(json))") } } func listServicePointTypes() async throws -> [ServicePointType] { let json = try await postJSON("/servicePoints/types.cfm", payload: [ "BusinessID": businessId ]) guard ok(json) else { throw APIError.serverError("Failed to load service point types: \(err(json))") } guard let arr = Self.findArray(json, ["TYPES", "Types", "types"]) else { return [] } return arr.map { ServicePointType(json: $0) } } // MARK: - URL Helpers 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 = environment == .development ? "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 } 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 } }