import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public struct KiloUsageSnapshot: Sendable { public let creditsUsed: Double? public let creditsTotal: Double? public let creditsRemaining: Double? public let passUsed: Double? public let passTotal: Double? public let passRemaining: Double? public let passBonus: Double? public let passResetsAt: Date? public let planName: String? public let autoTopUpEnabled: Bool? public let autoTopUpMethod: String? public let updatedAt: Date public init( creditsUsed: Double?, creditsTotal: Double?, creditsRemaining: Double?, passUsed: Double? = nil, passTotal: Double? = nil, passRemaining: Double? = nil, passBonus: Double? = nil, passResetsAt: Date? = nil, planName: String?, autoTopUpEnabled: Bool?, autoTopUpMethod: String?, updatedAt: Date) { self.creditsUsed = creditsUsed self.creditsTotal = creditsTotal self.creditsRemaining = creditsRemaining self.passUsed = passUsed self.passTotal = passTotal self.passRemaining = passRemaining self.passBonus = passBonus self.passResetsAt = passResetsAt self.planName = planName self.autoTopUpEnabled = autoTopUpEnabled self.autoTopUpMethod = autoTopUpMethod self.updatedAt = updatedAt } public func toUsageSnapshot() -> UsageSnapshot { let total = self.resolvedTotal let used = self.resolvedUsed let primary: RateWindow? if let total { let usedPercent: Double = if total > 0 { min(100, max(0, (used / total) * 100)) } else { // Preserve a visible exhausted state for valid zero-total snapshots. 100 } let usedText = Self.compactNumber(used) let totalText = Self.compactNumber(total) primary = RateWindow( usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: "\(usedText)/\(totalText) credits") } else { primary = nil } let loginMethod = Self.makeLoginMethod( planName: self.planName, autoTopUpEnabled: self.autoTopUpEnabled, autoTopUpMethod: self.autoTopUpMethod) return UsageSnapshot( primary: primary, secondary: self.passWindow, tertiary: nil, providerCost: nil, updatedAt: self.updatedAt, identity: ProviderIdentitySnapshot( providerID: .kilo, accountEmail: nil, accountOrganization: nil, loginMethod: loginMethod)) } private var resolvedTotal: Double? { if let creditsTotal { return max(0, creditsTotal) } if let creditsUsed, let creditsRemaining { return max(0, creditsUsed + creditsRemaining) } return nil } private var resolvedUsed: Double { if let creditsUsed { return max(0, creditsUsed) } if let total = self.resolvedTotal, let creditsRemaining { return max(0, total - creditsRemaining) } return 0 } private var resolvedPassTotal: Double? { if let passTotal { return max(0, passTotal) } if let passUsed, let passRemaining { return max(0, passUsed + passRemaining) } return nil } private var resolvedPassUsed: Double { if let passUsed { return max(0, passUsed) } if let total = self.resolvedPassTotal, let passRemaining { return max(0, total - passRemaining) } return 0 } private var passWindow: RateWindow? { guard let total = self.resolvedPassTotal else { return nil } let used = self.resolvedPassUsed let bonus = max(0, self.passBonus ?? 0) let baseCredits = max(0, total - bonus) let usedPercent: Double = if total > 0 { min(100, max(0, (used / total) * 100)) } else { 100 } var detail = "$\(Self.currencyNumber(used)) / $\(Self.currencyNumber(baseCredits))" if bonus > 0 { detail += " (+ $\(Self.currencyNumber(bonus)) bonus)" } return RateWindow( usedPercent: usedPercent, windowMinutes: nil, resetsAt: self.passResetsAt, resetDescription: detail) } private static func compactNumber(_ value: Double) -> String { if value.rounded(.towardZero) == value { return String(Int(value)) } return String(format: "%.2f", value) } private static func currencyNumber(_ value: Double) -> String { String(format: "%.2f", max(0, value)) } private static func makeLoginMethod( planName: String?, autoTopUpEnabled: Bool?, autoTopUpMethod: String?) -> String? { var parts: [String] = [] if let planName = Self.trimmed(planName) { parts.append(planName) } if let autoTopUpEnabled { if autoTopUpEnabled { if let method = Self.trimmed(autoTopUpMethod) { parts.append("Auto top-up: \(method)") } else { parts.append("Auto top-up: enabled") } } else { parts.append("Auto top-up: off") } } return parts.isEmpty ? nil : parts.joined(separator: " ยท ") } private static func trimmed(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } } public enum KiloUsageError: LocalizedError, Sendable, Equatable { case missingCredentials case cliSessionMissing(String) case cliSessionUnreadable(String) case cliSessionInvalid(String) case unauthorized case endpointNotFound case serviceUnavailable(Int) case networkError(String) case parseFailed(String) case apiError(Int) public var errorDescription: String? { switch self { case .missingCredentials: "Kilo API credentials missing. Set KILO_API_KEY." case let .cliSessionMissing(path): "Kilo CLI session not found at \(path). Run `kilo login` to create ~/.local/share/kilo/auth.json." case let .cliSessionUnreadable(path): "Kilo CLI session file is unreadable at \(path). Fix permissions or run `kilo login` again." case let .cliSessionInvalid(path): "Kilo CLI session file is invalid at \(path). Run `kilo login` to refresh auth.json." case .unauthorized: "Kilo authentication failed (401/403). Refresh KILO_API_KEY or run `kilo login`." case .endpointNotFound: "Kilo API endpoint not found (404). Verify the tRPC batch path and procedure names." case let .serviceUnavailable(statusCode): "Kilo API is currently unavailable (HTTP \(statusCode)). Try again later." case let .networkError(message): "Kilo network error: \(message)" case .parseFailed: "Failed to parse Kilo API response. Response format may have changed." case let .apiError(statusCode): "Kilo API request failed (HTTP \(statusCode))." } } } // swiftlint:disable:next type_body_length public struct KiloUsageFetcher: Sendable { private struct KiloPassFields { let used: Double? let total: Double? let remaining: Double? let bonus: Double? let resetsAt: Date? } static let procedures = [ "user.getCreditBlocks", "kiloPass.getState", "user.getAutoTopUpPaymentMethod", ] private static let optionalProcedures: Set = [ "user.getAutoTopUpPaymentMethod", ] private static let maxTopLevelEntries = procedures.count public static func fetchUsage( apiKey: String, scope: KiloUsageScope = .personal, environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> KiloUsageSnapshot { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw KiloUsageError.missingCredentials } let baseURL = KiloSettingsReader.apiURL(environment: environment) let request = try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope) let response: ProviderHTTPResponse do { response = try await ProviderHTTPClient.shared.response(for: request) } catch { throw KiloUsageError.networkError(error.localizedDescription) } if let mapped = self.statusError(for: response.statusCode) { throw mapped } guard response.statusCode == 200 else { throw KiloUsageError.apiError(response.statusCode) } return try self.parseSnapshot(data: response.data) } static func _buildBatchURLForTesting(baseURL: URL) throws -> URL { try self.makeBatchURL(baseURL: baseURL) } static func _buildRequestForTesting( baseURL: URL, apiKey: String, scope: KiloUsageScope) throws -> URLRequest { try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope) } private static func makeRequest( baseURL: URL, apiKey: String, scope: KiloUsageScope) throws -> URLRequest { let batchURL = try self.makeBatchURL(baseURL: baseURL) var request = URLRequest(url: batchURL) request.httpMethod = "GET" request.timeoutInterval = 15 request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") if let orgId = scope.organizationID { request.setValue(orgId, forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") } return request } public static func fetchOrganizations( apiKey: String, environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> [KiloOrganization] { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw KiloUsageError.missingCredentials } let baseURL = KiloSettingsReader.apiURL(environment: environment) let trpcRequest = try self.makeOrgListTRPCRequest(baseURL: baseURL, apiKey: apiKey) do { let response = try await ProviderHTTPClient.shared.response(for: trpcRequest) if response.statusCode == 404 { return try await self.fetchOrganizationsRESTFallback(apiKey: apiKey) } if let mapped = self.statusError(for: response.statusCode) { throw mapped } return try self.parseOrganizations(data: response.data) } catch let error as KiloUsageError { throw error } catch { throw KiloUsageError.networkError(error.localizedDescription) } } static func _parseOrganizationsForTesting(_ data: Data) throws -> [KiloOrganization] { try self.parseOrganizations(data: data) } private static func makeOrgListTRPCRequest( baseURL: URL, apiKey: String) throws -> URLRequest { let endpoint = baseURL.appendingPathComponent("user.getOrganizations") let inputData = try JSONSerialization.data( withJSONObject: ["0": ["json": NSNull()]] as [String: Any]) guard let inputString = String(data: inputData, encoding: .utf8), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else { throw KiloUsageError.parseFailed("Invalid org list endpoint") } components.queryItems = [ URLQueryItem(name: "batch", value: "1"), URLQueryItem(name: "input", value: inputString), ] guard let url = components.url else { throw KiloUsageError.parseFailed("Invalid org list endpoint") } var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = 15 request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") return request } private static func fetchOrganizationsRESTFallback(apiKey: String) async throws -> [KiloOrganization] { guard let url = URL(string: "https://api.kilo.ai/api/profile") else { throw KiloUsageError.parseFailed("Invalid REST fallback URL") } var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = 15 request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") let response = try await ProviderHTTPClient.shared.response(for: request) if let mapped = self.statusError(for: response.statusCode) { throw mapped } guard response.statusCode == 200 else { throw KiloUsageError.apiError(response.statusCode) } return try self.parseOrganizations(data: response.data) } private static func parseOrganizations(data: Data) throws -> [KiloOrganization] { guard let root = try? JSONSerialization.jsonObject(with: data) else { throw KiloUsageError.parseFailed("Invalid JSON") } // tRPC batch shape: [ { result: { data: { json: [orgs] } } } ] if let entries = root as? [[String: Any]], let first = entries.first, let resultObject = first["result"] as? [String: Any] { if let dataObject = resultObject["data"] as? [String: Any], let payload = dataObject["json"] as? [[String: Any]] { return self.decodeOrganizations(payload) } if let payload = resultObject["data"] as? [[String: Any]] { return self.decodeOrganizations(payload) } } // REST profile shape: { user: ..., organizations: [orgs] } if let dictionary = root as? [String: Any] { if let orgs = dictionary["organizations"] as? [[String: Any]] { return self.decodeOrganizations(orgs) } // Some single-procedure tRPC shapes flatten to { result: { data: { json: { organizations: [...] }}}} if let resultObject = dictionary["result"] as? [String: Any], let dataObject = resultObject["data"] as? [String: Any] { if let payload = dataObject["json"] as? [[String: Any]] { return self.decodeOrganizations(payload) } if let payload = dataObject["json"] as? [String: Any], let orgs = payload["organizations"] as? [[String: Any]] { return self.decodeOrganizations(orgs) } } } return [] } private static func decodeOrganizations(_ raw: [[String: Any]]) -> [KiloOrganization] { raw.compactMap { item -> KiloOrganization? in guard let id = item["id"] as? String, !id.isEmpty else { return nil } let name = (item["name"] as? String).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? id let role = (item["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedRole = (role?.isEmpty ?? true) ? nil : role return KiloOrganization(id: id, name: name.isEmpty ? id : name, role: normalizedRole) } } static func _parseSnapshotForTesting(_ data: Data) throws -> KiloUsageSnapshot { try self.parseSnapshot(data: data) } static func _statusErrorForTesting(_ statusCode: Int) -> KiloUsageError? { self.statusError(for: statusCode) } private static func statusError(for statusCode: Int) -> KiloUsageError? { switch statusCode { case 401, 403: .unauthorized case 404: .endpointNotFound case 500...599: .serviceUnavailable(statusCode) default: nil } } private static func makeBatchURL(baseURL: URL) throws -> URL { let joinedProcedures = self.procedures.joined(separator: ",") let endpoint = baseURL.appendingPathComponent(joinedProcedures) let inputMap = Dictionary(uniqueKeysWithValues: self.procedures.indices.map { (String($0), ["json": NSNull()]) }) let inputData = try JSONSerialization.data(withJSONObject: inputMap) guard let inputString = String(data: inputData, encoding: .utf8) else { throw KiloUsageError.parseFailed("Invalid batch input") } guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else { throw KiloUsageError.parseFailed("Invalid batch endpoint") } components.queryItems = [ URLQueryItem(name: "batch", value: "1"), URLQueryItem(name: "input", value: inputString), ] guard let url = components.url else { throw KiloUsageError.parseFailed("Invalid batch endpoint") } return url } private static func parseSnapshot(data: Data) throws -> KiloUsageSnapshot { guard let root = try? JSONSerialization.jsonObject(with: data) else { throw KiloUsageError.parseFailed("Invalid JSON") } let entriesByIndex = try self.responseEntriesByIndex(from: root) var payloadsByProcedure: [String: Any] = [:] for (index, procedure) in self.procedures.enumerated() { guard let entry = entriesByIndex[index] else { continue } if let mappedError = self.trpcError(from: entry) { guard self.isRequiredProcedure(procedure) else { continue } throw mappedError } if let payload = self.resultPayload(from: entry) { payloadsByProcedure[procedure] = payload } } let creditFields = self.creditFields(from: payloadsByProcedure[self.procedures[0]]) let passFields = self.passFields(from: payloadsByProcedure[self.procedures[1]]) let planName = self.planName(from: payloadsByProcedure[self.procedures[1]]) let autoTopUp = self.autoTopUpState( creditBlocksPayload: payloadsByProcedure[self.procedures[0]], autoTopUpPayload: payloadsByProcedure[self.procedures[2]]) return KiloUsageSnapshot( creditsUsed: creditFields.used, creditsTotal: creditFields.total, creditsRemaining: creditFields.remaining, passUsed: passFields.used, passTotal: passFields.total, passRemaining: passFields.remaining, passBonus: passFields.bonus, passResetsAt: passFields.resetsAt, planName: planName, autoTopUpEnabled: autoTopUp.enabled, autoTopUpMethod: autoTopUp.method, updatedAt: Date()) } private static func isRequiredProcedure(_ procedure: String) -> Bool { !self.optionalProcedures.contains(procedure) } private static func responseEntriesByIndex(from root: Any) throws -> [Int: [String: Any]] { if let entries = root as? [[String: Any]] { let limited = Array(entries.prefix(self.maxTopLevelEntries)) return Dictionary(uniqueKeysWithValues: limited.enumerated().map { ($0.offset, $0.element) }) } if let dictionary = root as? [String: Any] { if dictionary["result"] != nil || dictionary["error"] != nil { return [0: dictionary] } let indexedEntries = dictionary .compactMap { key, value -> (Int, [String: Any])? in guard let index = Int(key), let entry = value as? [String: Any] else { return nil } return (index, entry) } if !indexedEntries.isEmpty { let limitedEntries = indexedEntries.filter { $0.0 >= 0 && $0.0 < self.maxTopLevelEntries } return Dictionary(uniqueKeysWithValues: limitedEntries) } } throw KiloUsageError.parseFailed("Unexpected tRPC batch shape") } private static func trpcError(from entry: [String: Any]) -> KiloUsageError? { guard let errorObject = entry["error"] as? [String: Any] else { return nil } let code = self.stringValue(for: ["json", "data", "code"], in: errorObject) ?? self.stringValue(for: ["data", "code"], in: errorObject) ?? self.stringValue(for: ["code"], in: errorObject) let message = self.stringValue(for: ["json", "message"], in: errorObject) ?? self.stringValue(for: ["message"], in: errorObject) let combined = [code, message] .compactMap { $0?.lowercased() } .joined(separator: " ") if combined.contains("unauthorized") || combined.contains("forbidden") { return .unauthorized } if combined.contains("not_found") || combined.contains("not found") { return .endpointNotFound } return .parseFailed("tRPC error payload") } private static func resultPayload(from entry: [String: Any]) -> Any? { guard let resultObject = entry["result"] as? [String: Any] else { return nil } if let dataObject = resultObject["data"] as? [String: Any] { if let jsonPayload = dataObject["json"] { if jsonPayload is NSNull { return nil } return jsonPayload } return dataObject } if let jsonPayload = resultObject["json"] { if jsonPayload is NSNull { return nil } return jsonPayload } return nil } private static func creditFields(from payload: Any?) -> (used: Double?, total: Double?, remaining: Double?) { guard let payload else { return (nil, nil, nil) } let contexts = self.dictionaryContexts(from: payload) let blocks = self.firstArray(forKeys: ["creditBlocks"], in: contexts) if let blocks { var totalFromBlocks: Double = 0 var remainingFromBlocks: Double = 0 var sawTotal = false var sawRemaining = false for case let block as [String: Any] in blocks { if let amountMicroUSD = self.double(from: block["amount_mUsd"]) { totalFromBlocks += amountMicroUSD / 1_000_000 sawTotal = true } if let balanceMicroUSD = self.double(from: block["balance_mUsd"]) { remainingFromBlocks += balanceMicroUSD / 1_000_000 sawRemaining = true } } if sawTotal || sawRemaining { let total = sawTotal ? max(0, totalFromBlocks) : nil let remaining = sawRemaining ? max(0, remainingFromBlocks) : nil let used: Double? = if let total, let remaining { max(0, total - remaining) } else { nil } return (used, total, remaining) } } let genericBlocks = self.firstArray(forKeys: ["blocks"], in: contexts) let blockContexts = (genericBlocks ?? []).compactMap { $0 as? [String: Any] } var used = self.firstDouble( forKeys: ["used", "usedCredits", "consumed", "spent", "creditsUsed"], in: blockContexts) var total = self.firstDouble(forKeys: ["total", "totalCredits", "creditsTotal", "limit"], in: blockContexts) var remaining = self.firstDouble( forKeys: ["remaining", "remainingCredits", "creditsRemaining"], in: blockContexts) if used == nil { used = self.firstDouble( forKeys: ["used", "usedCredits", "creditsUsed", "consumed", "spent"], in: contexts) } if total == nil { total = self.firstDouble(forKeys: ["total", "totalCredits", "creditsTotal", "limit"], in: contexts) } if remaining == nil { remaining = self.firstDouble( forKeys: ["remaining", "remainingCredits", "creditsRemaining"], in: contexts) } if total == nil, let used, let remaining { total = used + remaining } if used == nil, total == nil, remaining == nil, let balanceMilliUSD = self.firstDouble(forKeys: ["totalBalance_mUsd"], in: contexts), balanceMilliUSD == 0 { // Kilo may return an empty creditBlocks list for zero-balance accounts. // Keep this visible as an explicit exhausted edge state instead of "no data". return (0, 0, 0) } if used == nil, total == nil, remaining == nil, let balanceMilliUSD = self.firstDouble(forKeys: ["totalBalance_mUsd"], in: contexts) { let balance = max(0, balanceMilliUSD / 1_000_000) return (max(0, 0), balance, balance) } return (used, total, remaining) } private static func passFields(from payload: Any?) -> KiloPassFields { if let subscription = self.subscriptionData(from: payload) { let used = self.double(from: subscription["currentPeriodUsageUsd"]).map { max(0, $0) } let baseCredits = self.double(from: subscription["currentPeriodBaseCreditsUsd"]).map { max(0, $0) } let bonusCredits = max(0, self.double(from: subscription["currentPeriodBonusCreditsUsd"]) ?? 0) let total = baseCredits.map { $0 + bonusCredits } let remaining: Double? = if let total, let used { max(0, total - used) } else { nil } let resetsAt = self.date(from: subscription["nextBillingAt"]) ?? self.date(from: subscription["nextRenewalAt"]) ?? self.date(from: subscription["renewsAt"]) ?? self.date(from: subscription["renewAt"]) return KiloPassFields( used: used, total: total, remaining: remaining, bonus: bonusCredits > 0 ? bonusCredits : nil, resetsAt: resetsAt) } return self.fallbackPassFields(from: payload) } private static func fallbackPassFields(from payload: Any?) -> KiloPassFields { let contexts = self.dictionaryContexts(from: payload) guard !contexts.isEmpty else { return KiloPassFields(used: nil, total: nil, remaining: nil, bonus: nil, resetsAt: nil) } var total = self.moneyAmount( centsKeys: [ "amountCents", "totalCents", "planAmountCents", "monthlyAmountCents", "limitCents", "includedCents", "valueCents", ], milliUSDKeys: [ "amount_mUsd", "total_mUsd", "planAmount_mUsd", "limit_mUsd", "included_mUsd", "value_mUsd", ], plainKeys: [ "amount", "total", "limit", "included", "value", "creditsTotal", "totalCredits", "planAmount", ], in: contexts) var used = self.moneyAmount( centsKeys: [ "usedCents", "spentCents", "consumedCents", "usedAmountCents", "consumedAmountCents", ], milliUSDKeys: [ "used_mUsd", "spent_mUsd", "consumed_mUsd", "usedAmount_mUsd", ], plainKeys: [ "used", "spent", "consumed", "usage", "creditsUsed", "usedAmount", "consumedAmount", ], in: contexts) var remaining = self.moneyAmount( centsKeys: [ "remainingCents", "remainingAmountCents", "availableCents", "leftCents", "balanceCents", ], milliUSDKeys: [ "remaining_mUsd", "available_mUsd", "left_mUsd", "balance_mUsd", ], plainKeys: [ "remaining", "available", "left", "balance", "creditsRemaining", "remainingAmount", "availableAmount", ], in: contexts) let bonus = self.moneyAmount( centsKeys: [ "bonusCents", "bonusAmountCents", "includedBonusCents", "bonusRemainingCents", ], milliUSDKeys: [ "bonus_mUsd", "bonusAmount_mUsd", ], plainKeys: [ "bonus", "bonusAmount", "bonusCredits", "includedBonus", ], in: contexts) let resetsAt = self.firstDate( forKeys: [ "resetAt", "resetsAt", "nextResetAt", "renewAt", "renewsAt", "nextRenewalAt", "currentPeriodEnd", "periodEndsAt", "expiresAt", "expiryAt", ], in: contexts) if total == nil, let used, let remaining { total = used + remaining } if used == nil, let total, let remaining { used = max(0, total - remaining) } if remaining == nil, let total, let used { remaining = max(0, total - used) } return KiloPassFields( used: used, total: total, remaining: remaining, bonus: bonus, resetsAt: resetsAt) } private static func planName(from payload: Any?) -> String? { if let subscription = self.subscriptionData(from: payload) { if let tier = self.string(from: subscription["tier"]) { let trimmed = tier.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return self.planNameForTier(trimmed) } } return "Kilo Pass" } let contexts = self.dictionaryContexts(from: payload) let candidates = [ self.firstString( forKeys: ["planName", "tier", "tierName", "passName", "subscriptionName"], in: contexts), self.stringValue(for: ["plan", "name"], in: contexts), self.stringValue(for: ["subscription", "plan", "name"], in: contexts), self.stringValue(for: ["subscription", "name"], in: contexts), self.stringValue(for: ["pass", "name"], in: contexts), self.stringValue(for: ["state", "name"], in: contexts), self.stringValue(for: ["state"], in: contexts), ] for candidate in candidates { guard let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { continue } return trimmed } if let fallback = self.firstString(forKeys: ["name"], in: contexts), fallback.lowercased().contains("pass") { return fallback } return nil } private static func autoTopUpState( creditBlocksPayload: Any?, autoTopUpPayload: Any?) -> (enabled: Bool?, method: String?) { let creditContexts = self.dictionaryContexts(from: creditBlocksPayload) let autoTopUpContexts = self.dictionaryContexts(from: autoTopUpPayload) let enabled = self.firstBool(forKeys: ["enabled", "isEnabled", "active"], in: autoTopUpContexts) ?? self.boolFromStatusString(self.firstString(forKeys: ["status"], in: autoTopUpContexts)) ?? self.firstBool(forKeys: ["autoTopUpEnabled"], in: creditContexts) let rawMethod = self.firstString( forKeys: ["paymentMethod", "paymentMethodType", "method", "cardBrand"], in: autoTopUpContexts)?.trimmingCharacters(in: .whitespacesAndNewlines) let amount = self.moneyAmount( centsKeys: ["amountCents"], milliUSDKeys: [], plainKeys: ["amount", "topUpAmount", "amountUsd"], in: autoTopUpContexts) let method: String? = if let rawMethod, !rawMethod.isEmpty { rawMethod } else if let amount, amount > 0 { self.currencyAmountLabel(amount) } else { nil } return (enabled, method) } private static func subscriptionData(from payload: Any?) -> [String: Any]? { guard let payloadDictionary = payload as? [String: Any] else { return nil } if let subscription = payloadDictionary["subscription"] as? [String: Any] { return subscription } if payloadDictionary["subscription"] is NSNull { return nil } let hasSubscriptionShape = payloadDictionary["currentPeriodUsageUsd"] != nil || payloadDictionary["currentPeriodBaseCreditsUsd"] != nil || payloadDictionary["currentPeriodBonusCreditsUsd"] != nil || payloadDictionary["tier"] != nil return hasSubscriptionShape ? payloadDictionary : nil } private static func planNameForTier(_ tier: String) -> String { switch tier { case "tier_19": "Starter" case "tier_49": "Pro" case "tier_199": "Expert" default: tier } } private static func string(from raw: Any?) -> String? { if let value = raw as? String { return value } return nil } private static func dictionaryContexts(from payload: Any?) -> [[String: Any]] { guard let payload else { return [] } guard let dictionary = payload as? [String: Any] else { return [] } var contexts: [[String: Any]] = [] var queue: [([String: Any], Int)] = [(dictionary, 0)] let maxDepth = 2 while !queue.isEmpty { let (current, depth) = queue.removeFirst() contexts.append(current) guard depth < maxDepth else { continue } for value in current.values { if let nested = value as? [String: Any] { queue.append((nested, depth + 1)) continue } if let nestedArray = value as? [Any] { for case let nested as [String: Any] in nestedArray { queue.append((nested, depth + 1)) } } } } return contexts } private static func firstArray(forKeys keys: [String], in contexts: [[String: Any]]) -> [Any]? { for context in contexts { for key in keys { if let values = context[key] as? [Any] { return values } } } return nil } private static func firstDouble(forKeys keys: [String], in contexts: [[String: Any]]) -> Double? { for context in contexts { for key in keys { if let value = self.double(from: context[key]) { return value } } } return nil } private static func firstString(forKeys keys: [String], in contexts: [[String: Any]]) -> String? { for context in contexts { for key in keys { if let value = context[key] as? String { return value } } } return nil } private static func firstBool(forKeys keys: [String], in contexts: [[String: Any]]) -> Bool? { for context in contexts { for key in keys { if let value = self.bool(from: context[key]) { return value } } } return nil } private static func firstDate(forKeys keys: [String], in contexts: [[String: Any]]) -> Date? { for context in contexts { for key in keys { if let value = self.date(from: context[key]) { return value } } } return nil } private static func moneyAmount( centsKeys: [String], milliUSDKeys: [String], plainKeys: [String], in contexts: [[String: Any]]) -> Double? { if let cents = self.firstDouble(forKeys: centsKeys, in: contexts) { return cents / 100 } if let milliUSD = self.firstDouble(forKeys: milliUSDKeys, in: contexts) { return milliUSD / 1_000_000 } return self.firstDouble(forKeys: plainKeys, in: contexts) } private static func currencyAmountLabel(_ amount: Double) -> String { if amount.rounded(.towardZero) == amount { return String(format: "$%.0f", amount) } return String(format: "$%.2f", amount) } private static func stringValue(for path: [String], in dictionary: [String: Any]) -> String? { var cursor: Any = dictionary for key in path { guard let next = (cursor as? [String: Any])?[key] else { return nil } cursor = next } return cursor as? String } private static func stringValue(for path: [String], in contexts: [[String: Any]]) -> String? { for context in contexts { if let value = self.stringValue(for: path, in: context) { return value } } return nil } private static func boolFromStatusString(_ status: String?) -> Bool? { guard let status = status?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !status.isEmpty else { return nil } switch status { case "enabled", "active", "on": return true case "disabled", "inactive", "off", "none": return false default: return nil } } private static func double(from raw: Any?) -> Double? { switch raw { case let value as Double: value case let value as Int: Double(value) case let value as NSNumber: value.doubleValue case let value as String: Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) default: nil } } private static func date(from raw: Any?) -> Date? { switch raw { case let value as Date: return value case let value as Double: return self.dateFromEpoch(value) case let value as Int: return self.dateFromEpoch(Double(value)) case let value as NSNumber: return self.dateFromEpoch(value.doubleValue) case let value as String: let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if let numeric = Double(trimmed) { return self.dateFromEpoch(numeric) } let withFractional = ISO8601DateFormatter() withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let parsed = withFractional.date(from: trimmed) { return parsed } let plain = ISO8601DateFormatter() plain.formatOptions = [.withInternetDateTime] return plain.date(from: trimmed) default: return nil } } private static func dateFromEpoch(_ value: Double) -> Date { let seconds = abs(value) > 10_000_000_000 ? value / 1000 : value return Date(timeIntervalSince1970: seconds) } private static func bool(from raw: Any?) -> Bool? { switch raw { case let value as Bool: return value case let value as NSNumber: return value.boolValue case let value as String: let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if ["true", "1", "yes", "enabled", "on"].contains(normalized) { return true } if ["false", "0", "no", "disabled", "off"].contains(normalized) { return false } return nil default: return nil } } }