import Foundation /// Errors from API calls enum APIError: LocalizedError { case network(Error) case httpError(Int) case decodingError(Error) case serverError(String) case unauthorized case noData var errorDescription: String? { switch self { case .network(let e): return "Network error: \(e.localizedDescription)" case .httpError(let code): return "HTTP \(code)" case .decodingError(let e): return "Decode error: \(e.localizedDescription)" case .serverError(let msg): return msg case .unauthorized: return "Session expired. Please log in again." case .noData: return "No data received" } } } /// Lightweight API client — all Payfrit backend calls actor APIClient { static let shared = APIClient() private let session: URLSession private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForResource = APIConfig.readTimeout config.timeoutIntervalForRequest = APIConfig.connectTimeout self.session = URLSession(configuration: config) } // MARK: - Auth /// Raw response from /auth/loginOTP.php /// API returns: { "OK": true, "UUID": "...", "MESSAGE": "..." } private struct OTPRawResponse: Codable { let OK: Bool let UUID: String? let MESSAGE: String? let ERROR: String? } func sendOTP(phone: String) async throws -> String { let body: [String: Any] = ["Phone": phone] let data = try await post(path: "/auth/loginOTP.php", body: body) let resp = try JSONDecoder().decode(OTPRawResponse.self, from: data) guard resp.OK, let uuid = resp.UUID, !uuid.isEmpty else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to send OTP") } return uuid } /// Raw response from /auth/verifyLoginOTP.php /// API returns: { "OK": true, "UserID": 123, "Token": "...", "FirstName": "..." } private struct VerifyOTPRawResponse: Codable { let OK: Bool let Token: String? let UserID: IntOrString? let MESSAGE: String? let ERROR: String? } /// Handles UserID coming as either int or string from API enum IntOrString: Codable { case int(Int) case string(String) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let i = try? container.decode(Int.self) { self = .int(i) } else if let s = try? container.decode(String.self) { self = .string(s) } else { self = .string("") } } func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .int(let i): try container.encode(i) case .string(let s): try container.encode(s) } } var stringValue: String { switch self { case .int(let i): return String(i) case .string(let s): return s } } } func verifyOTP(uuid: String, code: String) async throws -> (token: String, userId: String) { let body: [String: Any] = ["UUID": uuid, "OTP": code] let data = try await post(path: "/auth/verifyLoginOTP.php", body: body) let resp = try JSONDecoder().decode(VerifyOTPRawResponse.self, from: data) guard resp.OK, let token = resp.Token, !token.isEmpty else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Invalid OTP") } let userId = resp.UserID?.stringValue ?? "" return (token, userId) } // MARK: - Businesses /// API returns: { "OK": true, "BUSINESSES": [...], "Businesses": [...] } private struct BusinessListResponse: Codable { let OK: Bool let ERROR: String? let BUSINESSES: [Business]? let Businesses: [Business]? var businesses: [Business] { BUSINESSES ?? Businesses ?? [] } } func listBusinesses(token: String) async throws -> [Business] { let data = try await post(path: "/businesses/list.php", body: [:], token: token) let resp = try JSONDecoder().decode(BusinessListResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.ERROR ?? "Failed to load businesses") } return resp.businesses } // MARK: - Service Points /// API returns: { "OK": true, "SERVICEPOINTS": [...], "GRANTED_SERVICEPOINTS": [...] } private struct ServicePointListResponse: Codable { let OK: Bool let ERROR: String? let SERVICEPOINTS: [ServicePoint]? } func listServicePoints(businessId: String, token: String) async throws -> [ServicePoint] { let body: [String: Any] = ["BusinessID": businessId] let data = try await post(path: "/servicepoints/list.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(ServicePointListResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.ERROR ?? "Failed to load service points") } return resp.SERVICEPOINTS ?? [] } /// API returns: { "OK": true, "SERVICEPOINT": { ... } } private struct ServicePointSaveResponse: Codable { let OK: Bool let ERROR: String? let SERVICEPOINT: ServicePoint? } func createServicePoint(name: String, businessId: String, token: String) async throws -> ServicePoint { let body: [String: Any] = ["Name": name, "BusinessID": businessId] let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(ServicePointSaveResponse.self, from: data) guard resp.OK, let sp = resp.SERVICEPOINT else { throw APIError.serverError(resp.ERROR ?? "Failed to create service point") } return sp } // MARK: - Beacon Sharding /// API returns: { "OK": true, "BeaconShardUUID": "...", "BeaconMajor": 5 } private struct AllocateNamespaceResponse: Codable { let OK: Bool let ERROR: String? let MESSAGE: String? let BeaconShardUUID: String? let BeaconMajor: Int? } func allocateBusinessNamespace(businessId: String, token: String) async throws -> (uuid: String, major: Int) { let body: [String: Any] = ["BusinessID": businessId] let data = try await post(path: "/beacon-sharding/allocate_business_namespace.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(AllocateNamespaceResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate namespace") } return (resp.BeaconShardUUID ?? "", resp.BeaconMajor ?? 0) } /// API returns: { "OK": true, "BeaconMinor": 3 } private struct AllocateMinorResponse: Codable { let OK: Bool let ERROR: String? let MESSAGE: String? let BeaconMinor: Int? } func allocateMinor(businessId: String, servicePointId: String, token: String) async throws -> Int { let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId] let data = try await post(path: "/beacon-sharding/allocate_servicepoint_minor.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(AllocateMinorResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor") } guard let minor = resp.BeaconMinor, minor > 0 else { throw APIError.serverError("API returned invalid minor value: \(resp.BeaconMinor ?? 0). Service point may not be configured correctly.") } return minor } /// API returns: { "OK": true, "BeaconHardwareID": 42, ... } private struct OKResponse: Codable { let OK: Bool let ERROR: String? let MESSAGE: String? } func registerBeaconHardware( businessId: String, servicePointId: String, uuid: String, major: Int, minor: Int, macAddress: String?, beaconType: String, token: String ) async throws { var body: [String: Any] = [ "BusinessID": businessId, "ServicePointID": servicePointId, "UUID": uuid, "Major": major, "Minor": minor, "BeaconType": beaconType ] if let mac = macAddress { body["MacAddress"] = mac } let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(OKResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to register beacon") } } func verifyBeaconBroadcast( uuid: String, major: Int, minor: Int, token: String ) async throws { let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor] let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token) let resp = try JSONDecoder().decode(OKResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to verify broadcast") } } /// API returns: { "OK": true, "BusinessName": "...", "BusinessID": 5 } private struct ResolveBusinessResponse: Codable { let OK: Bool let ERROR: String? let BusinessName: String? } func resolveBusiness(uuid: String, major: Int, token: String) async throws -> String { let body: [String: Any] = ["UUID": uuid, "Major": major] let data = try await post(path: "/beacon-sharding/resolve_business.php", body: body, token: token) let resp = try JSONDecoder().decode(ResolveBusinessResponse.self, from: data) return resp.BusinessName ?? "Unknown" } // MARK: - Service Point Management func deleteServicePoint(servicePointId: String, businessId: String, token: String) async throws { let body: [String: Any] = ["ID": servicePointId, "BusinessID": businessId] let data = try await post(path: "/servicepoints/delete.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(OKResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to delete service point") } } func updateServicePoint(servicePointId: String, name: String, businessId: String, token: String) async throws { let body: [String: Any] = ["ID": servicePointId, "Name": name, "BusinessID": businessId] let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(OKResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to update service point") } } // MARK: - Beacon Management struct BeaconListItem: Codable { let id: String? let ID: String? let uuid: String? let UUID: String? let major: Int? let Major: Int? let minor: Int? let Minor: Int? let macAddress: String? let MacAddress: String? let beaconType: String? let BeaconType: String? let servicePointId: String? let ServicePointID: String? let isVerified: Bool? let IsVerified: Bool? } /// API returns: { "OK": true, "BEACONS": [...] } private struct BeaconListAPIResponse: Codable { let OK: Bool let ERROR: String? let BEACONS: [BeaconListItem]? } func listBeacons(businessId: String, token: String) async throws -> [BeaconListItem] { let body: [String: Any] = ["BusinessID": businessId] let data = try await post(path: "/beacons/list.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(BeaconListAPIResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.ERROR ?? "Failed to list beacons") } return resp.BEACONS ?? [] } func decommissionBeacon(beaconId: String, businessId: String, token: String) async throws { let body: [String: Any] = ["ID": beaconId, "BusinessID": businessId] let data = try await post(path: "/beacons/wipe.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(OKResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to decommission beacon") } } func lookupByMac(macAddress: String, token: String) async throws -> BeaconListItem? { let body: [String: Any] = ["MacAddress": macAddress] let data = try await post(path: "/beacons/lookupByMac.php", body: body, token: token) // This may return a single beacon object or OK: false struct LookupResponse: Codable { let OK: Bool let ID: String? let UUID: String? let Major: Int? let Minor: Int? let MacAddress: String? let BeaconType: String? let ServicePointID: String? let IsVerified: Bool? } let resp = try JSONDecoder().decode(LookupResponse.self, from: data) guard resp.OK, resp.ID != nil else { return nil } return BeaconListItem( id: resp.ID, ID: resp.ID, uuid: resp.UUID, UUID: resp.UUID, major: resp.Major, Major: resp.Major, minor: resp.Minor, Minor: resp.Minor, macAddress: resp.MacAddress, MacAddress: resp.MacAddress, beaconType: resp.BeaconType, BeaconType: resp.BeaconType, servicePointId: resp.ServicePointID, ServicePointID: resp.ServicePointID, isVerified: resp.IsVerified, IsVerified: resp.IsVerified ) } func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListItem? { let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor] let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token) let resp = try JSONDecoder().decode(OKResponse.self, from: data) guard resp.OK else { return nil } // The verify endpoint confirms status but doesn't return full beacon details return BeaconListItem( id: nil, ID: nil, uuid: uuid, UUID: uuid, major: major, Major: major, minor: minor, Minor: minor, macAddress: nil, MacAddress: nil, beaconType: nil, BeaconType: nil, servicePointId: nil, ServicePointID: nil, isVerified: true, IsVerified: true ) } // MARK: - Beacon Config (server-configured values) /// API returns: { "OK": true, "UUID": "...", "Major": 5, "Minor": 3, ... } struct BeaconConfigResponse: Codable { let OK: Bool let ERROR: String? let MESSAGE: String? let UUID: String? let Major: Int? let Minor: Int? let TxPower: Int? let MeasuredPower: Int? let AdvInterval: Int? var configUUID: String { UUID ?? "" } var configMajor: Int { Major ?? 0 } var configMinor: Int { Minor ?? 0 } var configTxPower: Int { TxPower ?? 1 } var configMeasuredPower: Int { MeasuredPower ?? -100 } var configAdvInterval: Int { AdvInterval ?? 2 } } func getBeaconConfig(businessId: String, servicePointId: String, token: String) async throws -> BeaconConfigResponse { let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId] let data = try await post(path: "/beacon-sharding/get_beacon_config.php", body: body, token: token, businessId: businessId) let resp = try JSONDecoder().decode(BeaconConfigResponse.self, from: data) guard resp.OK else { throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to get beacon config") } return resp } // MARK: - User Profile /// Note: /users/profile.php endpoint may not exist on server. /// Using a flat response decoder matching the standard API format. struct UserProfile: Codable { let OK: Bool? let ID: IntOrString? let FirstName: String? let LastName: String? let ContactNumber: String? var userId: String { ID?.stringValue ?? "" } var firstName: String { FirstName ?? "" } var lastName: String { LastName ?? "" } } func getProfile(token: String) async throws -> UserProfile { let data = try await post(path: "/users/profile.php", body: [:], token: token) let resp = try JSONDecoder().decode(UserProfile.self, from: data) return resp } // MARK: - Internal private func post( path: String, body: [String: Any], token: String? = nil, businessId: String? = nil ) async throws -> Data { guard let url = URL(string: APIConfig.baseURL + path) else { throw APIError.serverError("Invalid URL: \(path)") } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue(token, forHTTPHeaderField: "X-User-Token") } if let bizId = businessId { request.setValue(bizId, forHTTPHeaderField: "X-Business-Id") } request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) if let http = response as? HTTPURLResponse { if http.statusCode == 401 { throw APIError.unauthorized } guard (200...299).contains(http.statusCode) else { throw APIError.httpError(http.statusCode) } } return data } }