import Foundation public struct PerplexityUsageSnapshot: Sendable { public let recurringTotal: Double public let recurringUsed: Double public let promoTotal: Double public let promoUsed: Double public let purchasedTotal: Double public let purchasedUsed: Double public let balanceCents: Double public let totalUsageCents: Double public let renewalDate: Date public let promoExpiration: Date? public let updatedAt: Date public init(response: PerplexityCreditsResponse, now: Date) { let recurring = response.creditGrants.filter { $0.type == "recurring" } let promotional = response.creditGrants.filter { $0.type == "promotional" && ($0.expiresAtTs ?? .infinity) > now.timeIntervalSince1970 } let purchased = response.creditGrants.filter { $0.type == "purchased" } // All timestamps from the Perplexity API are Unix seconds (verified Feb 2026). let recurringSum = max(0, recurring.reduce(0.0) { $0 + $1.amountCents }) let promoSum = max(0, promotional.reduce(0.0) { $0 + $1.amountCents }) // Purchased credits may appear in the top-level field, in the credit_grants // array (type == "purchased"), or both. Take whichever is larger to avoid // double-counting while still catching either source. let purchasedFromGrants = max(0, purchased.reduce(0.0) { $0 + $1.amountCents }) let purchasedFromField = max(0, response.currentPeriodPurchasedCents) let purchasedSum = max(purchasedFromGrants, purchasedFromField) // Waterfall attribution: recurring → purchased → promotional var remaining = response.totalUsageCents let usedFromRecurring = min(remaining, recurringSum); remaining -= usedFromRecurring let usedFromPurchased = min(remaining, purchasedSum); remaining -= usedFromPurchased let usedFromPromo = min(remaining, promoSum) self.recurringTotal = recurringSum self.recurringUsed = usedFromRecurring self.promoTotal = promoSum self.promoUsed = usedFromPromo self.purchasedTotal = purchasedSum self.purchasedUsed = usedFromPurchased self.balanceCents = response.balanceCents self.totalUsageCents = response.totalUsageCents self.renewalDate = Date(timeIntervalSince1970: response.renewalDateTs) self.promoExpiration = promotional .compactMap { $0.expiresAtTs.map { Date(timeIntervalSince1970: $0) } } .min() self.updatedAt = now } /// Infer plan name from recurring credit allotment. /// Free = 0, Pro = small pool (~500–1000), Max = 10,000+. public var planName: String? { if self.recurringTotal <= 0 { return nil } if self.recurringTotal < 5000 { return "Pro" } return "Max" } private static let promoExpiryFormatter: DateFormatter = { let fmt = DateFormatter() fmt.dateFormat = "MMM d" return fmt }() } extension PerplexityUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { // Primary: recurring (monthly) credits let hasFallbackCredits = self.promoTotal > 0 || self.purchasedTotal > 0 let primaryWindow: RateWindow? = { if self.recurringTotal > 0 { let primaryPercent = min(100, max(0, self.recurringUsed / self.recurringTotal * 100)) return RateWindow( usedPercent: primaryPercent, windowMinutes: nil, resetsAt: self.renewalDate, resetDescription: "\(Int(self.recurringUsed.rounded()))/\(Int(self.recurringTotal)) credits") } if hasFallbackCredits { // When recurring is absent but bonus/purchased credits remain, omit the fake 0/0 primary lane // so automatic menu-bar rendering can fall through to the usable pool. return nil } return RateWindow( usedPercent: 100, windowMinutes: nil, resetsAt: self.renewalDate, resetDescription: "0/0 credits") }() // Secondary: promotional bonus credits — always shown. // usedPercent=100 when promoTotal==0 so the bar renders empty rather than full. let promoPercent = self.promoTotal > 0 ? min(100, max(0, self.promoUsed / self.promoTotal * 100)) : 100.0 var promoDesc = "\(Int(promoUsed.rounded()))/\(Int(self.promoTotal)) bonus" if let expiry = promoExpiration { promoDesc += " \u{00b7} exp. \(Self.promoExpiryFormatter.string(from: expiry))" } let secondary = RateWindow( usedPercent: promoPercent, windowMinutes: nil, resetsAt: nil, resetDescription: promoDesc) // Tertiary: on-demand purchased credits — always shown. // usedPercent=100 when purchasedTotal==0 so the bar renders empty rather than full. let purchasedPercent = self.purchasedTotal > 0 ? min(100, max(0, self.purchasedUsed / self.purchasedTotal * 100)) : 100.0 let tertiary = RateWindow( usedPercent: purchasedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: "\(Int(purchasedUsed.rounded()))/\(Int(self.purchasedTotal)) credits") let identity = ProviderIdentitySnapshot( providerID: .perplexity, accountEmail: nil, accountOrganization: nil, loginMethod: planName) return UsageSnapshot( primary: primaryWindow, secondary: secondary, tertiary: tertiary, providerCost: nil, updatedAt: self.updatedAt, identity: identity) } }