import Foundation class Api { static let shared = Api() // ── DEV toggle: driven by DEV compiler flag (set in build configuration) ── #if DEV static let IS_DEV = true #else static let IS_DEV = false #endif private static var BASE_URL: String { IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api" } private let session: URLSession private var authToken: String? private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 config.timeoutIntervalForResource = 30 session = URLSession(configuration: config) } func setAuthToken(_ token: String?) { authToken = token } func getAuthToken() -> String? { return authToken } private func buildRequest(endpoint: String) -> URLRequest { let url = URL(string: "\(Api.BASE_URL)\(endpoint)")! var request = URLRequest(url: url) if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue(token, forHTTPHeaderField: "X-User-Token") } return request } private func postRequest(endpoint: String, body: [String: Any], extraHeaders: [String: String] = [:]) async throws -> [String: Any] { var request = buildRequest(endpoint: endpoint) request.httpMethod = "POST" request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) for (key, value) in extraHeaders { request.setValue(value, forHTTPHeaderField: key) } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw ApiException("Invalid response") } guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { throw ApiException("Request failed: \(httpResponse.statusCode)") } guard !data.isEmpty else { throw ApiException("Empty response") } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw ApiException("Invalid JSON response") } return json } private func getRequest(endpoint: String) async throws -> [String: Any] { var request = buildRequest(endpoint: endpoint) request.httpMethod = "GET" let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw ApiException("Invalid response") } guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { throw ApiException("Request failed: \(httpResponse.statusCode)") } guard !data.isEmpty else { throw ApiException("Empty response") } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw ApiException("Invalid JSON response") } return json } // ========================================================================= // AUTH // ========================================================================= func sendLoginOtp(phone: String) async throws -> OtpResponse { let json = try await postRequest(endpoint: "/auth/loginOTP.php", body: ["Phone": phone]) guard let uuid = (json["UUID"] as? String) ?? (json["uuid"] as? String), !uuid.isEmpty else { throw ApiException("Server error - please try again") } return OtpResponse(uuid: uuid) } func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse { let json = try await postRequest(endpoint: "/auth/verifyLoginOTP.php", body: [ "UUID": uuid, "OTP": otp ]) let ok = parseBool(json["OK"] ?? json["ok"]) if !ok { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Invalid code" throw ApiException(error) } return LoginResponse( userId: parseIntValue(json["UserID"] ?? json["USERID"]) ?? 0, token: ((json["Token"] ?? json["TOKEN"] ?? json["token"]) as? String) ?? "", userFirstName: ((json["UserFirstName"] ?? json["USERFIRSTNAME"]) as? String) ?? "" ) } // ========================================================================= // BUSINESSES // ========================================================================= func listBusinesses() async throws -> [Business] { let json = try await postRequest(endpoint: "/businesses/list.php", body: [:]) guard let businesses = (json["BUSINESSES"] ?? json["businesses"] ?? json["Items"] ?? json["ITEMS"]) as? [[String: Any]] else { return [] } return businesses.compactMap { b in guard let businessId = parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) else { return nil } let name = ((b["BusinessName"] ?? b["BUSINESSNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" let headerImageExtension = (b["HeaderImageExtension"] ?? b["HEADERIMAGEEXTENSION"]) as? String return Business(businessId: businessId, name: name, headerImageExtension: headerImageExtension) } } /// Get a single business by ID func getBusiness(businessId: Int) async throws -> Business? { let json = try await postRequest( endpoint: "/businesses/get.php", body: ["BusinessID": businessId], extraHeaders: ["X-Business-Id": String(businessId)] ) guard parseBool(json["OK"] ?? json["ok"]) else { return nil } guard let b = (json["BUSINESS"] ?? json["business"]) as? [String: Any], let id = parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) else { return nil } let name = ((b["BusinessName"] ?? b["BUSINESSNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" let headerImageExtension = (b["HeaderImageExtension"] ?? b["HEADERIMAGEEXTENSION"]) as? String return Business(businessId: id, name: name, headerImageExtension: headerImageExtension) } // Business name cache (matches Android's caching behavior) private var businessNameCache: [Int: String] = [:] /// Get business name with caching (avoids repeated API calls) func getBusinessName(businessId: Int) async throws -> String { if let cached = businessNameCache[businessId] { return cached } if let business = try await getBusiness(businessId: businessId) { businessNameCache[business.businessId] = business.name return business.name } return "" } // ========================================================================= // BEACONS // ========================================================================= func listAllBeacons() async throws -> [String: Int] { let json = try await getRequest(endpoint: "/beacons/list_all.php") guard let items = (json["ITEMS"] ?? json["items"]) as? [[String: Any]] else { return [:] } var result: [String: Int] = [:] for item in items { guard let uuid = ((item["UUID"] ?? item["uuid"] ?? item["BeaconUUID"] ?? item["BEACONUUID"]) as? String)? .normalizedUUID, let beaconId = parseIntValue(item["BeaconID"] ?? item["BEACONID"]) else { continue } result[uuid] = beaconId } return result } func lookupBeacons(uuids: [String]) async throws -> [BeaconLookupResult] { if uuids.isEmpty { return [] } let json = try await postRequest(endpoint: "/beacons/lookup.php", body: ["UUIDs": uuids]) guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else { return [] } return beacons.compactMap { b in guard let uuid = ((b["UUID"] ?? b["uuid"]) as? String)? .normalizedUUID else { return nil } return BeaconLookupResult( uuid: uuid, beaconId: parseIntValue(b["BeaconID"] ?? b["BEACONID"]) ?? 0, businessId: parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) ?? 0, businessName: ((b["BusinessName"] ?? b["BUSINESSNAME"]) as? String) ?? "", servicePointName: ((b["ServicePointName"] ?? b["SERVICEPOINTNAME"]) as? String) ?? "", beaconName: ((b["BeaconName"] ?? b["BEACONNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" ) } } func listBeacons(businessId: Int) async throws -> [BeaconInfo] { let json = try await postRequest( endpoint: "/beacons/list.php", body: ["BusinessID": businessId], extraHeaders: ["X-Business-Id": String(businessId)] ) guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else { return [] } return beacons.compactMap { b in let beaconId = parseIntValue(b["BeaconID"] ?? b["BEACONID"] ?? b["ID"]) ?? 0 let name = ((b["Name"] ?? b["NAME"] ?? b["BeaconName"] ?? b["BEACONNAME"]) as? String) ?? "" let uuid = ((b["UUID"] ?? b["uuid"]) as? String)? .normalizedUUID ?? "" let isActive = parseBool(b["IsActive"] ?? b["ISACTIVE"] ?? true) return BeaconInfo(beaconId: beaconId, name: name, uuid: uuid, isActive: isActive) } } func saveBeacon(businessId: Int, name: String, uuid: String, macAddress: String? = nil) async throws -> SavedBeacon { var params: [String: Any] = [ "BusinessID": businessId, "Name": name, "UUID": uuid ] if let mac = macAddress, !mac.isEmpty { params["MACAddress"] = mac } let json = try await postRequest( endpoint: "/beacons/save.php", body: params, extraHeaders: ["X-Business-Id": String(businessId)] ) if !parseBool(json["OK"] ?? json["ok"]) { throw ApiException(((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save beacon") } let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] return SavedBeacon( beaconId: parseIntValue(beacon?["BeaconID"] ?? beacon?["BEACONID"] ?? beacon?["ID"]) ?? 0, name: (beacon?["Name"] ?? beacon?["NAME"]) as? String ?? name, uuid: uuid, macAddress: (beacon?["MACAddress"] ?? beacon?["MACADDRESS"]) as? String ) } func lookupByMac(macAddress: String) async throws -> MacLookupResult? { let json = try await postRequest(endpoint: "/beacons/lookupByMac.php", body: ["MACAddress": macAddress]) if !parseBool(json["OK"] ?? json["ok"]) { return nil } guard let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] else { return nil } return MacLookupResult( beaconId: parseIntValue(beacon["BeaconID"] ?? beacon["BEACONID"]) ?? 0, businessId: parseIntValue(beacon["BusinessID"] ?? beacon["BUSINESSID"]) ?? 0, businessName: ((beacon["BusinessName"] ?? beacon["BUSINESSNAME"]) as? String) ?? "", beaconName: ((beacon["BeaconName"] ?? beacon["BEACONNAME"]) as? String) ?? "", uuid: ((beacon["UUID"] ?? beacon["uuid"]) as? String) ?? "", macAddress: ((beacon["MACAddress"] ?? beacon["MACADDRESS"]) as? String) ?? "", servicePointName: ((beacon["ServicePointName"] ?? beacon["SERVICEPOINTNAME"]) as? String) ?? "" ) } func wipeBeacon(businessId: Int, beaconId: Int) async throws -> Bool { let json = try await postRequest( endpoint: "/beacons/wipe.php", body: ["BusinessID": businessId, "BeaconID": beaconId], extraHeaders: ["X-Business-Id": String(businessId)] ) if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to wipe beacon" let message = (json["MESSAGE"] ?? json["message"]) as? String throw ApiException(message ?? error) } return true } // ========================================================================= // BEACON SHARDING / PROVISIONING // ========================================================================= /// Get beacon config for a service point (UUID, Major, Minor to write to beacon) func getBeaconConfig(businessId: Int, servicePointId: Int) async throws -> BeaconConfigResponse { DebugLog.shared.log("[API] getBeaconConfig businessId=\(businessId) servicePointId=\(servicePointId)") let json = try await postRequest( endpoint: "/beacon-sharding/get_beacon_config.php", body: ["BusinessID": businessId, "ServicePointID": servicePointId], extraHeaders: ["X-Business-Id": String(businessId)] ) DebugLog.shared.log("[API] getBeaconConfig response keys: \(json.keys.sorted())") DebugLog.shared.log("[API] getBeaconConfig full response: \(json)") if !parseBool(json["OK"] ?? json["ok"] ?? json["Ok"]) { let error = ((json["ERROR"] ?? json["error"] ?? json["Error"]) as? String) ?? "Failed to get beacon config" throw ApiException(error) } guard let uuid = (json["UUID"] ?? json["uuid"] ?? json["Uuid"] ?? json["BeaconUUID"] ?? json["BEACONUUID"]) as? String else { throw ApiException("Invalid beacon config response - no UUID. Keys: \(json.keys.sorted())") } guard let major = parseIntValue(json["MAJOR"] ?? json["major"] ?? json["Major"] ?? json["BeaconMajor"] ?? json["BEACONMAJOR"]) else { throw ApiException("Invalid beacon config response - no Major. Keys: \(json.keys.sorted())") } guard let minor = parseIntValue(json["MINOR"] ?? json["minor"] ?? json["Minor"] ?? json["BeaconMinor"] ?? json["BEACONMINOR"]) else { throw ApiException("Invalid beacon config response - no Minor. Keys: \(json.keys.sorted())") } // Parse new fields from updated endpoint let measuredPower = parseIntValue(json["MeasuredPower"] ?? json["MEASUREDPOWER"] ?? json["measuredPower"]) ?? -59 let advInterval = parseIntValue(json["AdvInterval"] ?? json["ADVINTERVAL"] ?? json["advInterval"]) ?? 2 let txPower = parseIntValue(json["TxPower"] ?? json["TXPOWER"] ?? json["txPower"]) ?? 1 let servicePointName = (json["ServicePointName"] ?? json["SERVICEPOINTNAME"] ?? json["servicePointName"]) as? String ?? "" let businessName = (json["BusinessName"] ?? json["BUSINESSNAME"] ?? json["businessName"]) as? String ?? "" DebugLog.shared.log("[API] getBeaconConfig parsed: uuid=\(uuid) major=\(major) minor=\(minor) measuredPower=\(measuredPower) advInterval=\(advInterval) txPower=\(txPower)") return BeaconConfigResponse( uuid: uuid.normalizedUUID, major: UInt16(major), minor: UInt16(minor), measuredPower: Int8(clamping: measuredPower), advInterval: UInt8(clamping: advInterval), txPower: UInt8(clamping: txPower), servicePointName: servicePointName, businessName: businessName ) } /// Register beacon hardware after provisioning func registerBeaconHardware(businessId: Int, servicePointId: Int, uuid: String, major: UInt16, minor: UInt16, hardwareId: String, macAddress: String? = nil) async throws -> Bool { var body: [String: Any] = [ "BusinessID": businessId, "ServicePointID": servicePointId, "UUID": uuid, "Major": major, "Minor": minor, "HardwareId": hardwareId ] if let mac = macAddress, !mac.isEmpty { body["MACAddress"] = mac } DebugLog.shared.log("[API] registerBeaconHardware body: \(body)") let json = try await postRequest( endpoint: "/beacon-sharding/register_beacon_hardware.php", body: body, extraHeaders: ["X-Business-Id": String(businessId)] ) DebugLog.shared.log("[API] registerBeaconHardware response: \(json)") if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to register beacon" throw ApiException(error) } return true } /// Resolve beacon ownership by UUID and Major func resolveBusiness(uuid: String, major: UInt16) async throws -> ResolveBusinessResponse { let json = try await postRequest( endpoint: "/beacon-sharding/resolve_business.php", body: ["UUID": uuid, "Major": Int(major)] ) if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to resolve business" throw ApiException(error) } guard let businessId = parseIntValue(json["BusinessID"] ?? json["BUSINESSID"] ?? json["businessId"]) else { throw ApiException("Invalid resolve response - no BusinessID") } let businessName = (json["BusinessName"] ?? json["BUSINESSNAME"] ?? json["businessName"]) as? String ?? "" return ResolveBusinessResponse(businessId: businessId, businessName: businessName) } /// Verify beacon is broadcasting expected values func verifyBeaconBroadcast(businessId: Int, uuid: String, major: UInt16, minor: UInt16) async throws -> Bool { let json = try await postRequest( endpoint: "/beacon-sharding/verify_beacon_broadcast.php", body: [ "BusinessID": businessId, "UUID": uuid, "Major": major, "Minor": minor ], extraHeaders: ["X-Business-Id": String(businessId)] ) return parseBool(json["OK"] ?? json["ok"]) } /// Allocate beacon namespace for a business (shard + major) func allocateBusinessNamespace(businessId: Int) async throws -> BusinessNamespace { let json = try await postRequest( endpoint: "/beacon-sharding/allocate_business_namespace.php", body: ["BusinessID": businessId], extraHeaders: ["X-Business-Id": String(businessId)] ) // Debug log DebugLog.shared.log("[API] allocateBusinessNamespace response: \(json)") if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to allocate namespace" throw ApiException(error) } // API returns BeaconShardUUID and BeaconMajor guard let uuid = (json["BeaconShardUUID"] ?? json["BEACONSHARDUUID"] ?? json["UUID"] ?? json["uuid"]) as? String else { throw ApiException("Invalid namespace response - no UUID") } guard let major = parseIntValue(json["BeaconMajor"] ?? json["BEACONMAJOR"] ?? json["MAJOR"] ?? json["Major"]) else { throw ApiException("Invalid namespace response - no Major") } return BusinessNamespace( shardId: parseIntValue(json["ShardID"] ?? json["SHARDID"]) ?? 0, uuid: uuid, uuidClean: uuid.normalizedUUID, major: UInt16(major), alreadyAllocated: parseBool(json["AlreadyAllocated"] ?? json["ALREADYALLOCATED"]) ) } /// Allocate a minor value for a service point (auto-assigns next available minor) func allocateServicePointMinor(businessId: Int, servicePointId: Int) async throws -> UInt16 { let json = try await postRequest( endpoint: "/beacon-sharding/allocate_servicepoint_minor.php", body: ["BusinessID": businessId, "ServicePointID": servicePointId], extraHeaders: ["X-Business-Id": String(businessId)] ) DebugLog.shared.log("[API] allocateServicePointMinor response: \(json)") if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to allocate minor" throw ApiException(error) } guard let minor = parseIntValue(json["MINOR"] ?? json["minor"] ?? json["Minor"] ?? json["BeaconMinor"] ?? json["BEACONMINOR"]) else { throw ApiException("Invalid response - no Minor value") } return UInt16(minor) } /// List service points for a business (for beacon assignment) func listServicePoints(businessId: Int) async throws -> [ServicePoint] { let json = try await postRequest( endpoint: "/servicepoints/list.php", body: ["BusinessID": businessId], extraHeaders: ["X-Business-Id": String(businessId)] ) guard let items = (json["SERVICEPOINTS"] ?? json["servicepoints"] ?? json["ITEMS"] ?? json["items"]) as? [[String: Any]] else { return [] } return items.compactMap { sp in guard let spId = parseIntValue(sp["ServicePointID"] ?? sp["SERVICEPOINTID"] ?? sp["ID"]) else { return nil } let name = ((sp["Name"] ?? sp["NAME"] ?? sp["ServicePointName"] ?? sp["SERVICEPOINTNAME"]) as? String) ?? "Table \(spId)" let hasBeacon = parseBool(sp["HasBeacon"] ?? sp["HASBEACON"]) let beaconMinor = parseIntValue(sp["BeaconMinor"] ?? sp["BEACONMINOR"]) return ServicePoint(servicePointId: spId, name: name, hasBeacon: hasBeacon, beaconMinor: beaconMinor != nil ? UInt16(beaconMinor!) : nil) } } /// Create/save a service point (auto-allocates minor) func saveServicePoint(businessId: Int, name: String, servicePointId: Int? = nil) async throws -> ServicePoint { var body: [String: Any] = ["BusinessID": businessId, "Name": name] if let spId = servicePointId { body["ServicePointID"] = spId } DebugLog.shared.log("[API] saveServicePoint businessId=\(businessId) name=\(name) servicePointId=\(String(describing: servicePointId))") let json = try await postRequest( endpoint: "/servicepoints/save.php", body: body, extraHeaders: ["X-Business-Id": String(businessId)] ) DebugLog.shared.log("[API] saveServicePoint response: \(json)") if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save service point" throw ApiException(error) } // Response has SERVICEPOINT object containing the data let sp = (json["SERVICEPOINT"] ?? json["servicepoint"]) as? [String: Any] ?? json let spId = parseIntValue(sp["ServicePointID"] ?? sp["SERVICEPOINTID"]) ?? servicePointId ?? 0 let beaconMinor = parseIntValue(sp["BeaconMinor"] ?? sp["BEACONMINOR"]) DebugLog.shared.log("[API] saveServicePoint parsed: spId=\(spId) beaconMinor=\(String(describing: beaconMinor))") if spId == 0 { DebugLog.shared.log("[API] WARNING: servicePointId is 0! Full sp dict: \(sp)") } return ServicePoint( servicePointId: spId, name: name, hasBeacon: beaconMinor != nil, beaconMinor: beaconMinor != nil ? UInt16(beaconMinor!) : nil ) } /// Create a new service point (legacy - calls save) func createServicePoint(businessId: Int, name: String) async throws -> ServicePoint { return try await saveServicePoint(businessId: businessId, name: name) } /// Delete a service point func deleteServicePoint(businessId: Int, servicePointId: Int) async throws { let json = try await postRequest( endpoint: "/servicepoints/delete.php", body: ["BusinessID": businessId, "ServicePointID": servicePointId], extraHeaders: ["X-Business-Id": String(businessId)] ) if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to delete service point" throw ApiException(error) } } // ========================================================================= // HELPERS // ========================================================================= private func parseBool(_ value: Any?) -> Bool { switch value { case nil: return false case let b as Bool: return b case let n as NSNumber: return n.intValue != 0 case let s as String: return ["true", "1"].contains(s.lowercased()) default: return false } } private func parseIntValue(_ value: Any?) -> Int? { if let n = value as? NSNumber { return n.intValue } if let s = value as? String, let i = Int(s) { return i } return nil } } struct ApiException: LocalizedError { let message: String init(_ message: String) { self.message = message } var errorDescription: String? { message } } // ========================================================================= // DATA MODELS // ========================================================================= struct OtpResponse { let uuid: String } struct LoginResponse { let userId: Int let token: String let userFirstName: String } struct Business: Identifiable { var id: Int { businessId } let businessId: Int let name: String let headerImageExtension: String? } struct BeaconLookupResult { let uuid: String let beaconId: Int let businessId: Int let businessName: String let servicePointName: String let beaconName: String } struct BeaconInfo { let beaconId: Int let name: String let uuid: String let isActive: Bool } struct SavedBeacon { let beaconId: Int let name: String let uuid: String let macAddress: String? } struct MacLookupResult { let beaconId: Int let businessId: Int let businessName: String let beaconName: String let uuid: String let macAddress: String let servicePointName: String } struct BeaconConfigResponse { let uuid: String // 32-char hex, no dashes let major: UInt16 let minor: UInt16 let measuredPower: Int8 // RSSI@1m (e.g., -59) let advInterval: UInt8 // Advertising interval (raw value, e.g., 2 = 200ms) let txPower: UInt8 // TX power level let servicePointName: String let businessName: String } struct ResolveBusinessResponse { let businessId: Int let businessName: String } struct ServicePoint: Identifiable { var id: Int { servicePointId } let servicePointId: Int let name: String let hasBeacon: Bool var beaconMinor: UInt16? } struct BusinessNamespace { let shardId: Int let uuid: String // Original UUID from API (with dashes, as-is) let uuidClean: String // 32-char hex, no dashes, uppercase (for BLE provisioning) let major: UInt16 let alreadyAllocated: Bool }