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 @@
+
+
+ {{ $t('billing.meter.error.refreshFailed') }}
+
+
-
-
-
-
-
-
-
- {{ $t('billing.checkout.error.alreadyActive.title') }}
-
-
-
- {{ $t('billing.checkout.error.alreadyActive.message') }}
-
-
-
- {{ $t('billing.checkout.error.alreadyActive.close') }}
-
-
- {{ $t('billing.checkout.error.alreadyActive.cta') }}
-
-
-
-
@@ -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);
});
});