项目文件夹

文件
2026-07-13 12:22:33 +08:00

135 行
5.7 KiB
Swift

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)
}
}