feat: support effective_balance_micros for user balance display (#7658)

## 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)
This commit is contained in:
Hunter
2025-12-20 16:30:16 -05:00
committed by GitHub
parent 0977e6e751
commit e5edbf91eb
4 changed files with 267 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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