@@ -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