Skip to content

Commit 0e218b9

Browse files
committed
fix: detect purchased credits from credit_grants array
Perplexity now sends purchased credits as a credit_grant with type="purchased" instead of (or in addition to) the top-level current_period_purchased_cents field. Take the larger of the two sources to avoid double-counting while catching either path. Without this fix, 40k purchased credits were invisible to the waterfall calculation, causing bonus credits to show 100% used when they still had ~42% remaining.
1 parent b4d89c4 commit 0e218b9

3 files changed

Lines changed: 125 additions & 1 deletion

File tree

Sources/CodexBarCLI/TokenAccountCLI.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ struct TokenAccountCLIContext {
178178
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
179179
ideBasePath: nil))
180180
case .perplexity:
181+
let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
182+
let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
181183
return self.makeSnapshot(
182184
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
183185
cookieSource: cookieSource,

Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@ public struct PerplexityUsageSnapshot: Sendable {
1818
let promotional = response.creditGrants.filter {
1919
$0.type == "promotional" && ($0.expiresAtTs ?? .infinity) > now.timeIntervalSince1970
2020
}
21+
let purchased = response.creditGrants.filter { $0.type == "purchased" }
2122

2223
// All timestamps from the Perplexity API are Unix seconds (verified Feb 2026).
2324
let recurringSum = max(0, recurring.reduce(0.0) { $0 + $1.amountCents })
2425
let promoSum = max(0, promotional.reduce(0.0) { $0 + $1.amountCents })
25-
let purchasedSum = max(0, response.currentPeriodPurchasedCents)
26+
// Purchased credits may appear in the top-level field, in the credit_grants
27+
// array (type == "purchased"), or both. Take whichever is larger to avoid
28+
// double-counting while still catching either source.
29+
let purchasedFromGrants = max(0, purchased.reduce(0.0) { $0 + $1.amountCents })
30+
let purchasedFromField = max(0, response.currentPeriodPurchasedCents)
31+
let purchasedSum = max(purchasedFromGrants, purchasedFromField)
2632

2733
// Waterfall attribution: recurring → purchased → promotional
2834
var remaining = response.totalUsageCents

Tests/CodexBarTests/PerplexityUsageFetcherTests.swift

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,122 @@ struct PerplexityUsageFetcherTests {
204204
#expect(tertiary.usedPercent == 100.0)
205205
}
206206

207+
// MARK: - Purchased credits from credit_grants
208+
209+
@Test
210+
func purchasedCreditsFromCreditGrantsArray() throws {
211+
// Purchased credits appear as credit_grant type="purchased" instead of
212+
// current_period_purchased_cents. The snapshot should pick them up.
213+
let json = """
214+
{
215+
"balance_cents": 23065,
216+
"renewal_date_ts": \(Self.renewalTs),
217+
"current_period_purchased_cents": 0,
218+
"credit_grants": [
219+
{ "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
220+
{ "type": "purchased", "amount_cents": 40000 },
221+
{ "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) }
222+
],
223+
"total_usage_cents": 81935
224+
}
225+
"""
226+
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
227+
228+
#expect(snapshot.recurringTotal == 10000)
229+
#expect(snapshot.purchasedTotal == 40000)
230+
#expect(snapshot.promoTotal == 55000)
231+
232+
// Waterfall: recurring eats 10000, purchased eats 40000, promo eats 31935
233+
#expect(snapshot.recurringUsed == 10000)
234+
#expect(snapshot.purchasedUsed == 40000)
235+
#expect(snapshot.promoUsed == 31935)
236+
}
237+
238+
@Test
239+
func purchasedCreditsPreferGrantsOverFieldWhenBothPresent() throws {
240+
// When both current_period_purchased_cents AND credit_grants type="purchased"
241+
// are provided, the larger value wins.
242+
let json = """
243+
{
244+
"balance_cents": 0,
245+
"renewal_date_ts": \(Self.renewalTs),
246+
"current_period_purchased_cents": 3000,
247+
"credit_grants": [
248+
{ "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) },
249+
{ "type": "purchased", "amount_cents": 8000 },
250+
{ "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
251+
],
252+
"total_usage_cents": 14000
253+
}
254+
"""
255+
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
256+
257+
// Purchased should use max(8000, 3000) = 8000
258+
#expect(snapshot.purchasedTotal == 8000)
259+
// Waterfall: 5000 recurring + 8000 purchased + 1000 promo = 14000
260+
#expect(snapshot.recurringUsed == 5000)
261+
#expect(snapshot.purchasedUsed == 8000)
262+
#expect(snapshot.promoUsed == 1000)
263+
}
264+
265+
@Test
266+
func purchasedCreditsFromFieldWhenNoGrantType() throws {
267+
// Legacy path: current_period_purchased_cents is set but no "purchased" grant
268+
let json = """
269+
{
270+
"balance_cents": 0,
271+
"renewal_date_ts": \(Self.renewalTs),
272+
"current_period_purchased_cents": 3000,
273+
"credit_grants": [
274+
{ "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) },
275+
{ "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
276+
],
277+
"total_usage_cents": 9000
278+
}
279+
"""
280+
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
281+
282+
// Still picks up purchased from the top-level field
283+
#expect(snapshot.purchasedTotal == 3000)
284+
#expect(snapshot.recurringUsed == 5000)
285+
#expect(snapshot.purchasedUsed == 3000)
286+
#expect(snapshot.promoUsed == 1000)
287+
}
288+
289+
@Test
290+
func realWorldMaxPlanWithAllThreePools() throws {
291+
// Real-world scenario: Max plan, 10k recurring + 40k purchased + 55k bonus
292+
// Total 105,000 available, 23,065 remaining → 81,935 used
293+
let json = """
294+
{
295+
"balance_cents": 23065,
296+
"renewal_date_ts": \(Self.renewalTs),
297+
"current_period_purchased_cents": 0,
298+
"credit_grants": [
299+
{ "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
300+
{ "type": "purchased", "amount_cents": 40000 },
301+
{ "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) }
302+
],
303+
"total_usage_cents": 81935
304+
}
305+
"""
306+
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
307+
let usage = snapshot.toUsageSnapshot()
308+
309+
// Primary (recurring): fully consumed → 100%
310+
let primary = try #require(usage.primary)
311+
#expect(primary.usedPercent == 100.0)
312+
313+
// Tertiary (purchased): fully consumed → 100%
314+
let tertiary = try #require(usage.tertiary)
315+
#expect(tertiary.usedPercent == 100.0)
316+
317+
// Secondary (bonus): 31935/55000 ≈ 58.06% used → ~42% remaining
318+
let secondary = try #require(usage.secondary)
319+
let expectedPromoPercent = 31935.0 / 55000.0 * 100.0
320+
#expect(abs(secondary.usedPercent - expectedPromoPercent) < 0.1)
321+
}
322+
207323
@Test
208324
func toUsageSnapshotPrimaryPercentMatchesUsage() throws {
209325
let json = """

0 commit comments

Comments
 (0)