Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 17 additions & 62 deletions src/modules/billing/components/billing.subscriptions.component.vue
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,21 @@

<!-- ── Meter mode sections (gated) ──────────────────────────────── -->
<template v-if="meterMode">
<!-- ── Meter polling error ──────────────────────────────── -->
<v-alert
v-if="meterError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
:icon="'fa-solid fa-triangle-exclamation'"
aria-live="polite"
@click:close="meterError = null"
>
{{ $t('billing.meter.error.refreshFailed') }}
</v-alert>

<!-- Usage bar (informational, opt-in here in account context) -->
<BillingUsageBarComponent
mode="meter"
Expand Down Expand Up @@ -257,43 +272,6 @@
v-model="extrasCheckoutDialog"
:packs="extrasPacks"
/>

<!-- Bonus: 409 subscription_already_active dialog -->
<v-dialog v-model="alreadyActiveDialog" max-width="480">
<v-card :class="config.vuetify.theme.rounded" class="pa-6">
<div class="d-flex align-center mb-3">
<v-icon icon="fa-solid fa-circle-check" color="success" size="small" class="mr-2" />
<span class="text-title-medium font-weight-medium">
{{ $t('billing.checkout.error.alreadyActive.title') }}
</span>
</div>
<p class="text-body-medium text-medium-emphasis mb-6">
{{ $t('billing.checkout.error.alreadyActive.message') }}
</p>
<div class="d-flex ga-3 justify-end">
<v-btn
variant="outlined"
:class="config.vuetify.theme.rounded"
class="text-none text-body-medium"
@click="alreadyActiveDialog = false"
>
{{ $t('billing.checkout.error.alreadyActive.close') }}
</v-btn>
<v-btn
v-if="alreadyActivePortalUrl"
color="primary"
variant="flat"
:class="config.vuetify.theme.rounded"
class="text-none text-body-medium"
:href="alreadyActivePortalUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('billing.checkout.error.alreadyActive.cta') }}
</v-btn>
</div>
</v-card>
</v-dialog>
</v-container>
</template>

Expand Down Expand Up @@ -366,6 +344,7 @@ export default {
breakdown: meterBreakdown,
overage: meterOverage,
netRemainingRaw: meterNetRemainingRaw,
meterError,
} = meter;

const meterMode = computed(() => authStore.serverConfig?.billing?.meterMode === true);
Expand All @@ -392,6 +371,7 @@ export default {
meterBreakdown,
meterOverage,
meterNetRemainingRaw,
meterError,
};
},
data() {
Expand All @@ -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: {
Expand Down Expand Up @@ -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;
},
},
};
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 6 additions & 0 deletions src/modules/billing/lang/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<div><slot /></div>', 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);
});
});

Expand Down
Loading