From e5edbf91eb1f9a5fb5d4c57bb310fc8ca6cbba60 Mon Sep 17 00:00:00 2001 From: Hunter Date: Sat, 20 Dec 2025 16:30:16 -0500 Subject: [PATCH] feat: support effective_balance_micros for user balance display (#7658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add support for the new `effective_balance_micros` field to show users their effective balance accounting for pending charges. ## Changes - **What**: Update balance display components to use `effective_balance_micros` (with fallback to `amount_micros` for backwards compatibility) - **Types**: Add `pending_charges_micros` and `effective_balance_micros` to `GetCustomerBalance` response type in registry-types ## Review Focus - The fallback pattern ensures backwards compatibility if the API doesn't return the new field - The `effective_balance_micros` can be negative when pending charges exceed the available balance ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7658-feat-support-effective_balance_micros-for-user-balance-display-2cf6d73d36508193a5a7e999f3185078) by [Unito](https://www.unito.io) --- src/components/common/UserCredit.test.ts | 134 ++++++++++++++++++ src/components/common/UserCredit.vue | 12 +- .../topbar/CurrentUserPopover.test.ts | 124 +++++++++++++++- src/components/topbar/CurrentUserPopover.vue | 6 +- 4 files changed, 267 insertions(+), 9 deletions(-) create mode 100644 src/components/common/UserCredit.test.ts diff --git a/src/components/common/UserCredit.test.ts b/src/components/common/UserCredit.test.ts new file mode 100644 index 000000000..7fcb42f49 --- /dev/null +++ b/src/components/common/UserCredit.test.ts @@ -0,0 +1,134 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' with { type: 'json' } + +import UserCredit from './UserCredit.vue' + +vi.mock('firebase/app', () => ({ + initializeApp: vi.fn(), + getApp: vi.fn() +})) + +vi.mock('firebase/auth', () => ({ + getAuth: vi.fn(), + setPersistence: vi.fn(), + browserLocalPersistence: {}, + onAuthStateChanged: vi.fn(), + signInWithEmailAndPassword: vi.fn(), + signOut: vi.fn() +})) + +vi.mock('pinia') + +const mockBalance = vi.hoisted(() => ({ + value: { + amount_micros: 100_000, + effective_balance_micros: 100_000, + currency: 'usd' + } +})) + +const mockIsFetchingBalance = vi.hoisted(() => ({ value: false })) + +vi.mock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: vi.fn(() => ({ + balance: mockBalance.value, + isFetchingBalance: mockIsFetchingBalance.value + })) +})) + +describe('UserCredit', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBalance.value = { + amount_micros: 100_000, + effective_balance_micros: 100_000, + currency: 'usd' + } + mockIsFetchingBalance.value = false + }) + + const mountComponent = (props = {}) => { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages } + }) + + return mount(UserCredit, { + props, + global: { + plugins: [i18n], + stubs: { + Skeleton: true, + Tag: true + } + } + }) + } + + describe('effective_balance_micros handling', () => { + it('uses effective_balance_micros when present (positive balance)', () => { + mockBalance.value = { + amount_micros: 200_000, + effective_balance_micros: 150_000, + currency: 'usd' + } + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('Credits') + }) + + it('uses effective_balance_micros when zero', () => { + mockBalance.value = { + amount_micros: 100_000, + effective_balance_micros: 0, + currency: 'usd' + } + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('0') + }) + + it('uses effective_balance_micros when negative', () => { + mockBalance.value = { + amount_micros: 0, + effective_balance_micros: -50_000, + currency: 'usd' + } + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('-') + }) + + it('falls back to amount_micros when effective_balance_micros is missing', () => { + mockBalance.value = { + amount_micros: 100_000, + currency: 'usd' + } as typeof mockBalance.value + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('Credits') + }) + + it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => { + mockBalance.value = { + currency: 'usd' + } as typeof mockBalance.value + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('0') + }) + }) + + describe('loading state', () => { + it('shows skeleton when loading', () => { + mockIsFetchingBalance.value = true + + const wrapper = mountComponent() + expect(wrapper.findComponent({ name: 'Skeleton' }).exists()).toBe(true) + }) + }) +}) diff --git a/src/components/common/UserCredit.vue b/src/components/common/UserCredit.vue index ee2fb722d..987406ede 100644 --- a/src/components/common/UserCredit.vue +++ b/src/components/common/UserCredit.vue @@ -42,8 +42,10 @@ const balanceLoading = computed(() => authStore.isFetchingBalance) const { t, locale } = useI18n() const formattedBalance = computed(() => { - // Backend returns cents despite the *_micros naming convention. - const cents = authStore.balance?.amount_micros ?? 0 + const cents = + authStore.balance?.effective_balance_micros ?? + authStore.balance?.amount_micros ?? + 0 const amount = formatCreditsFromCents({ cents, locale: locale.value @@ -52,8 +54,10 @@ const formattedBalance = computed(() => { }) const formattedCreditsOnly = computed(() => { - // Backend returns cents despite the *_micros naming convention. - const cents = authStore.balance?.amount_micros ?? 0 + const cents = + authStore.balance?.effective_balance_micros ?? + authStore.balance?.amount_micros ?? + 0 const amount = formatCreditsFromCents({ cents, locale: locale.value, diff --git a/src/components/topbar/CurrentUserPopover.test.ts b/src/components/topbar/CurrentUserPopover.test.ts index 72ef7a1ee..d7315dad5 100644 --- a/src/components/topbar/CurrentUserPopover.test.ts +++ b/src/components/topbar/CurrentUserPopover.test.ts @@ -69,14 +69,27 @@ vi.mock('@/services/dialogService', () => ({ })) })) -// Mock the firebaseAuthStore +// Mock the firebaseAuthStore with hoisted state for per-test manipulation +const mockAuthStoreState = vi.hoisted(() => ({ + balance: { + amount_micros: 100_000, + effective_balance_micros: 100_000, + currency: 'usd' + } as { + amount_micros?: number + effective_balance_micros?: number + currency: string + }, + isFetchingBalance: false +})) + vi.mock('@/stores/firebaseAuthStore', () => ({ useFirebaseAuthStore: vi.fn(() => ({ getAuthHeader: vi .fn() .mockResolvedValue({ Authorization: 'Bearer mock-token' }), - balance: { amount_micros: 100_000 }, // 100,000 cents = ~211,000 credits - isFetchingBalance: false + balance: mockAuthStoreState.balance, + isFetchingBalance: mockAuthStoreState.isFetchingBalance })) })) @@ -162,6 +175,12 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({ describe('CurrentUserPopover', () => { beforeEach(() => { vi.clearAllMocks() + mockAuthStoreState.balance = { + amount_micros: 100_000, + effective_balance_micros: 100_000, + currency: 'usd' + } + mockAuthStoreState.isFetchingBalance = false }) const mountComponent = (): VueWrapper => { @@ -298,4 +317,103 @@ describe('CurrentUserPopover', () => { expect(wrapper.emitted('close')).toBeTruthy() expect(wrapper.emitted('close')!.length).toBe(1) }) + + describe('effective_balance_micros handling', () => { + it('uses effective_balance_micros when present (positive balance)', () => { + mockAuthStoreState.balance = { + amount_micros: 200_000, + effective_balance_micros: 150_000, + currency: 'usd' + } + + const wrapper = mountComponent() + + expect(formatCreditsFromCents).toHaveBeenCalledWith({ + cents: 150_000, + locale: 'en', + numberOptions: { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + } + }) + expect(wrapper.text()).toContain('1500') + }) + + it('uses effective_balance_micros when zero', () => { + mockAuthStoreState.balance = { + amount_micros: 100_000, + effective_balance_micros: 0, + currency: 'usd' + } + + const wrapper = mountComponent() + + expect(formatCreditsFromCents).toHaveBeenCalledWith({ + cents: 0, + locale: 'en', + numberOptions: { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + } + }) + expect(wrapper.text()).toContain('0') + }) + + it('uses effective_balance_micros when negative', () => { + mockAuthStoreState.balance = { + amount_micros: 0, + effective_balance_micros: -50_000, + currency: 'usd' + } + + const wrapper = mountComponent() + + expect(formatCreditsFromCents).toHaveBeenCalledWith({ + cents: -50_000, + locale: 'en', + numberOptions: { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + } + }) + expect(wrapper.text()).toContain('-500') + }) + + it('falls back to amount_micros when effective_balance_micros is missing', () => { + mockAuthStoreState.balance = { + amount_micros: 100_000, + currency: 'usd' + } + + const wrapper = mountComponent() + + expect(formatCreditsFromCents).toHaveBeenCalledWith({ + cents: 100_000, + locale: 'en', + numberOptions: { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + } + }) + expect(wrapper.text()).toContain('1000') + }) + + it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => { + mockAuthStoreState.balance = { + currency: 'usd' + } + + const wrapper = mountComponent() + + expect(formatCreditsFromCents).toHaveBeenCalledWith({ + cents: 0, + locale: 'en', + numberOptions: { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + } + }) + expect(wrapper.text()).toContain('0') + }) + }) }) diff --git a/src/components/topbar/CurrentUserPopover.vue b/src/components/topbar/CurrentUserPopover.vue index 5c500e570..215de81e2 100644 --- a/src/components/topbar/CurrentUserPopover.vue +++ b/src/components/topbar/CurrentUserPopover.vue @@ -175,8 +175,10 @@ const subscriptionDialog = useSubscriptionDialog() const { locale } = useI18n() const formattedBalance = computed(() => { - // Backend returns cents despite the *_micros naming convention. - const cents = authStore.balance?.amount_micros ?? 0 + const cents = + authStore.balance?.effective_balance_micros ?? + authStore.balance?.amount_micros ?? + 0 return formatCreditsFromCents({ cents, locale: locale.value,