diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 74ed9aa26b..aa79c6c1df 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -11,6 +11,13 @@ Humans: follow instructions below or talk to [@iscekic](https://github.com/iscek - PostHog feature flag infrastructure and the version-aware registry remain available for future flags. - KiloClaw tab visibility still depends on instance ownership. +## Profile credits + +The bonus-credit subtitle includes only grants with an expiry strictly after the +current render time. Elapsed grants and non-expiring credits do not contribute to +its amount or earliest expiry, for both personal and organization accounts. +The balance continues to come from the balance query. + ## Getting started Generally speaking, you only need a new dev build if making dependency/native changes. diff --git a/apps/mobile/src/components/profile-credits-card.mounted.test.tsx b/apps/mobile/src/components/profile-credits-card.mounted.test.tsx index ed402ec062..85aa566247 100644 --- a/apps/mobile/src/components/profile-credits-card.mounted.test.tsx +++ b/apps/mobile/src/components/profile-credits-card.mounted.test.tsx @@ -281,6 +281,36 @@ beforeEach(() => { }); describe('CreditsCard balance state', () => { + it.each([ + { name: 'expired grants', future: false }, + { name: 'mixed grants', future: true }, + ])('excludes elapsed expiry dates from $name', async ({ future }) => { + const now = Date.parse('2026-10-17T12:00:00Z'); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now); + currentUser.userId = 'user-a'; + getContextBalanceQueryFn.mockResolvedValue({ balance: 0 }); + personalCreditBlocksQueryFn.mockResolvedValue({ + creditBlocks: [ + { balance_mUsd: 1_900_000, expiry_date: '2026-06-17T12:00:00Z' }, + { balance_mUsd: 3_000_000, expiry_date: '2026-10-17T12:00:00Z' }, + { balance_mUsd: 7_000_000, expiry_date: null }, + ...(future ? [{ balance_mUsd: 2_000_000, expiry_date: '2026-12-17T12:00:00Z' }] : []), + ], + }); + try { + const { texts, unmount } = await mountCard(); + try { + await waitFor(() => texts().includes('$0.00') && !texts().includes('SKELETON')); + const notes = texts().filter(text => text.includes('in bonus credits')); + expect(notes).toEqual(future ? ['$2.00 in bonus credits expiring 12/17/2026'] : []); + } finally { + unmount(); + } + } finally { + clock.mockRestore(); + } + }); + it.each([ { orgs: [], options: ['Personal', 'Cancel'], choice: 0, expected: null, label: 'Personal' }, { diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index f9611efb0e..adba5e743a 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -110,7 +110,11 @@ export function CreditsCard({ enabled, orgs }: Readonly) { const balancePending = balance === undefined && !balanceFailed; const showBalanceSkeleton = (balanceLoading || balancePending || !hideBalanceLoaded) && !balanceFailed; - const expiringBlocks = creditData?.creditBlocks.filter(b => b.expiry_date !== null) ?? []; + const now = Date.now(); + const expiringBlocks = + creditData?.creditBlocks.filter( + b => b.expiry_date !== null && parseTimestamp(b.expiry_date).getTime() > now + ) ?? []; const expiringTotal = fromMicrodollars( expiringBlocks.reduce((sum, b) => sum + b.balance_mUsd, 0) );