diff --git a/src/modules/billing/components/billing.subscriptions.component.vue b/src/modules/billing/components/billing.subscriptions.component.vue index ba197040b..008e224cd 100644 --- a/src/modules/billing/components/billing.subscriptions.component.vue +++ b/src/modules/billing/components/billing.subscriptions.component.vue @@ -198,6 +198,21 @@ @@ -366,6 +344,7 @@ export default { breakdown: meterBreakdown, overage: meterOverage, netRemainingRaw: meterNetRemainingRaw, + meterError, } = meter; const meterMode = computed(() => authStore.serverConfig?.billing?.meterMode === true); @@ -392,6 +371,7 @@ export default { meterBreakdown, meterOverage, meterNetRemainingRaw, + meterError, }; }, data() { @@ -413,9 +393,6 @@ export default { checkoutPollSnapshotPlan: null, // V5 P2: visibility-change subscription refresh debounce (timestamp of last fetch) subscriptionLastFetchedAt: 0, - // Bonus: 409 already-active dialog - alreadyActiveDialog: false, - alreadyActivePortalUrl: null, }; }, computed: { @@ -864,28 +841,6 @@ export default { console.error('Failed to load ledger page:', error); } }, - - /** - * @desc Show the 409 already-active dialog with a validated portal URL. - * Only accepts HTTPS URLs to guard against malformed or compromised payloads. - * Called by consumers (e.g. pricing view) that catch the structured error. - * @param {string} portalUrl - Stripe customer portal URL from the 409 payload - * @returns {void} - */ - showAlreadyActiveDialog(portalUrl) { - this.alreadyActivePortalUrl = null; - if (portalUrl) { - try { - const parsed = new URL(portalUrl); - if (parsed.protocol === 'https:') { - this.alreadyActivePortalUrl = parsed.toString(); - } - } catch { - // Invalid URL — link will not be shown - } - } - this.alreadyActiveDialog = true; - }, }, }; diff --git a/src/modules/billing/components/billing.usageBar.component.vue b/src/modules/billing/components/billing.usageBar.component.vue index 654206987..7fd9f620a 100644 --- a/src/modules/billing/components/billing.usageBar.component.vue +++ b/src/modules/billing/components/billing.usageBar.component.vue @@ -192,8 +192,9 @@ export default { extras: meterExtras, netRemainingRaw: meterNetRemainingRaw, overage: meterOverage, + meterError, } = useMeter({ pollIntervalMs: 0 }); - return { usage, limits, usagePercent, isPlanActive, meterUsed, meterQuota, meterExtras, meterNetRemainingRaw, meterOverage, authStore, billingStore }; + return { usage, limits, usagePercent, isPlanActive, meterUsed, meterQuota, meterExtras, meterNetRemainingRaw, meterOverage, meterError, authStore, billingStore }; }, computed: { diff --git a/src/modules/billing/lang/en.js b/src/modules/billing/lang/en.js index b3929293d..f0c984ad4 100644 --- a/src/modules/billing/lang/en.js +++ b/src/modules/billing/lang/en.js @@ -40,6 +40,12 @@ export const billingEn = { /** i18n key: billing.period.year */ year: '/year', }, + meter: { + error: { + /** i18n key: billing.meter.error.refreshFailed */ + refreshFailed: 'Could not refresh usage. Counters may be out of date. Retrying automatically.', + }, + }, extras: { /** i18n key: billing.extras.title */ title: 'Extra units', diff --git a/src/modules/billing/tests/billing.subscriptions.component.unit.tests.js b/src/modules/billing/tests/billing.subscriptions.component.unit.tests.js index e75a48e21..cc2edf9d1 100644 --- a/src/modules/billing/tests/billing.subscriptions.component.unit.tests.js +++ b/src/modules/billing/tests/billing.subscriptions.component.unit.tests.js @@ -764,66 +764,74 @@ describe('BillingSubscriptionsComponent — checkout success polling (P1-2)', () }); }); -// ─── Suite 9: Bonus — 409 already-active dialog (showAlreadyActiveDialog) ───── +// ─── Suite 9: meterError surfacing via v-alert (gated to meterMode) ────────── -describe('BillingSubscriptionsComponent — 409 already-active dialog (Bonus)', () => { +describe('BillingSubscriptionsComponent — meterError surfacing', () => { let wrapper; let store; + let useMeter; + let __resetUseMeterForTests; - beforeEach(() => { + beforeEach(async () => { setActivePinia(createPinia()); vi.clearAllMocks(); sessionStorage.clear(); store = useBillingStore(); seedMeterStore(store); store.subscription = { status: 'active', plan: 'starter', currentPeriodEnd: new Date().toISOString() }; - // Stub v-dialog to avoid visualViewport errors in jsdom - componentStubs['VDialog'] = { name: 'VDialog', template: '
', props: ['modelValue'] }; + // Import via composable so we mutate the shared meterError ref the component consumes. + ({ useMeter, __resetUseMeterForTests } = await import('../composables/billing.useMeter.js')); + __resetUseMeterForTests(); }); afterEach(() => { wrapper?.unmount(); wrapper = null; - delete componentStubs['VDialog']; + __resetUseMeterForTests?.(); }); - it('alreadyActiveDialog is false by default', async () => { - wrapper = mountSubscriptions({ serverConfig: { billing: { meterMode: false } } }); + it('renders v-alert with refreshFailed copy when meterError is set and meterMode is true', async () => { + wrapper = mountSubscriptions({ serverConfig: { billing: { meterMode: true } } }); await flushPromises(); - expect(wrapper.vm.alreadyActiveDialog).toBe(false); - }); - - it('showAlreadyActiveDialog sets alreadyActiveDialog true and stores valid HTTPS portalUrl', async () => { - wrapper = mountSubscriptions({ serverConfig: { billing: { meterMode: false } } }); + // Mutate AFTER mount + initial fetch resolves — fetchMissingMeterData clears + // meterError on success, so setting it before mount would be wiped. + const { meterError } = useMeter({ pollIntervalMs: 0 }); + meterError.value = new Error('refresh failed'); await flushPromises(); - const portalUrl = 'https://billing.stripe.com/portal/sess_abc'; - wrapper.vm.showAlreadyActiveDialog(portalUrl); - expect(wrapper.vm.alreadyActiveDialog).toBe(true); - expect(wrapper.vm.alreadyActivePortalUrl).toBe(portalUrl); + const alert = wrapper.find('.v-alert'); + expect(alert.exists()).toBe(true); + expect(alert.text()).toContain('Could not refresh usage'); }); - it('showAlreadyActiveDialog rejects non-HTTPS portalUrl', async () => { - wrapper = mountSubscriptions({ serverConfig: { billing: { meterMode: false } } }); + it('does not render v-alert when meterError is null', async () => { + wrapper = mountSubscriptions({ serverConfig: { billing: { meterMode: true } } }); await flushPromises(); - wrapper.vm.showAlreadyActiveDialog('http://evil.example.com/portal'); - expect(wrapper.vm.alreadyActiveDialog).toBe(true); - expect(wrapper.vm.alreadyActivePortalUrl).toBeNull(); + const { meterError } = useMeter({ pollIntervalMs: 0 }); + expect(meterError.value).toBeNull(); + expect(wrapper.find('.v-alert').exists()).toBe(false); }); - it('showAlreadyActiveDialog handles invalid URL gracefully', async () => { + it('does not render v-alert when meterError is set but meterMode is false (gated)', async () => { wrapper = mountSubscriptions({ serverConfig: { billing: { meterMode: false } } }); await flushPromises(); - wrapper.vm.showAlreadyActiveDialog('not-a-url'); - expect(wrapper.vm.alreadyActiveDialog).toBe(true); - expect(wrapper.vm.alreadyActivePortalUrl).toBeNull(); + const { meterError } = useMeter({ pollIntervalMs: 0 }); + meterError.value = new Error('refresh failed'); + await flushPromises(); + expect(wrapper.find('.v-alert').exists()).toBe(false); }); - it('showAlreadyActiveDialog handles missing portalUrl gracefully', async () => { - wrapper = mountSubscriptions({ serverConfig: { billing: { meterMode: false } } }); + it('clears meterError on close click and the alert disappears', async () => { + wrapper = mountSubscriptions({ serverConfig: { billing: { meterMode: true } } }); + await flushPromises(); + const { meterError } = useMeter({ pollIntervalMs: 0 }); + meterError.value = new Error('refresh failed'); + await flushPromises(); + expect(wrapper.find('.v-alert').exists()).toBe(true); + // Simulate the v-alert close emit by mutating the bound ref the same way the + // template handler does (`meterError = null`). + meterError.value = null; await flushPromises(); - wrapper.vm.showAlreadyActiveDialog(null); - expect(wrapper.vm.alreadyActiveDialog).toBe(true); - expect(wrapper.vm.alreadyActivePortalUrl).toBeNull(); + expect(wrapper.find('.v-alert').exists()).toBe(false); }); });