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
53 changes: 49 additions & 4 deletions src/modules/billing/components/billing.upgradePrompt.component.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
<template>
<v-alert type="info" variant="tonal" prominent class="my-4">
<!-- Post-grant variant: signup grant is depleted in meter mode (Free plan, balance = 0, ledger has signup_grant entry) -->
<div v-if="exhaustedAfterGrant && mode === 'meter'" class="my-4">
<v-alert type="info" variant="tonal" prominent>
<template #text>
<p class="font-weight-bold mb-1">{{ $t('billing.upgradePrompt.postGrantTitle') }}</p>
<p>{{ $t('billing.upgradePrompt.postGrantBody') }}</p>
</template>
<template #append>
<div class="d-flex flex-column ga-2">
<v-btn
data-test="cta-pack"
color="primary"
variant="flat"
size="small"
class="text-none"
@click="$emit('buy-pack')"
>
{{ $t('billing.upgradePrompt.postGrantBuyPack') }}
</v-btn>
<v-btn
data-test="cta-upgrade"
variant="text"
size="small"
class="text-none"
to="/pricing"
>
{{ $t('billing.upgradePrompt.postGrantUpgrade') }}
</v-btn>
</div>
</template>
</v-alert>
</div>

<!-- Default / meter variant -->
<v-alert v-else type="info" variant="tonal" prominent class="my-4">
<template #text>
<span v-if="hasUsageInfo">{{ $t('billing.upgradePrompt.usageInfo', { current, limit, label: displayLabel }) }}</span>
<span v-else>{{ $t('billing.upgradePrompt.requirePlan', { plan: requiredPlan }) }}</span>
Expand Down Expand Up @@ -34,6 +68,7 @@
* Module dependencies.
*/
import { useQuota } from '../composables/billing.useQuota';
import { useBillingStore } from '../stores/billing.store.js';

/**
* Component definition.
Expand Down Expand Up @@ -86,14 +121,24 @@ export default {
},
emits: ['buy-pack'],
/**
* @desc Wires useQuota composable and exposes reactive usage and limits maps.
* @returns {{ usage: Object, limits: Object }}
* @desc Wires useQuota composable and billing store for exhaustedAfterGrant detection.
* @returns {{ usage: Object, limits: Object, billingStore: Object }}
*/
setup() {
const { usage, limits } = useQuota();
return { usage, limits };
const billingStore = useBillingStore();
return { usage, limits, billingStore };
},
computed: {
/**
* @desc Whether the current user has exhausted their one-shot Free-tier signup grant.
* Delegates to the billing store getter for reactivity. Only rendered in meter mode
* (subscription mode is for feature-gating by plan, not meter exhaustion).
* @returns {boolean}
*/
exhaustedAfterGrant() {
return this.billingStore.exhaustedAfterGrant;
},
/**
* @desc Quota key derived from resource and action.
* @returns {string}
Expand Down
8 changes: 8 additions & 0 deletions src/modules/billing/lang/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,14 @@ export const billingEn = {
buyUnits: 'Buy units',
/** i18n key: billing.upgradePrompt.upgrade */
upgrade: 'Upgrade',
/** i18n key: billing.upgradePrompt.postGrantTitle */
postGrantTitle: 'Your signup grant is depleted',
/** i18n key: billing.upgradePrompt.postGrantBody */
postGrantBody: 'You used your 500 compute one-shot grant. Buy a Boost pack to keep going, or upgrade for monthly compute.',
/** i18n key: billing.upgradePrompt.postGrantBuyPack */
postGrantBuyPack: 'Buy Boost pack — $9',
/** i18n key: billing.upgradePrompt.postGrantUpgrade */
postGrantUpgrade: 'Upgrade Growth / Pro',
},
},
};
Expand Down
8 changes: 8 additions & 0 deletions src/modules/billing/lang/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,14 @@ export const billingFr = {
buyUnits: 'Acheter des unités',
/** i18n key: billing.upgradePrompt.upgrade */
upgrade: 'Passer à un plan supérieur',
/** i18n key: billing.upgradePrompt.postGrantTitle */
postGrantTitle: 'Votre crédit de démarrage est épuisé',
/** i18n key: billing.upgradePrompt.postGrantBody */
postGrantBody: "Vous avez utilisé vos 500 crédits offerts à l'inscription. Achetez un pack Boost pour continuer, ou passez à un abonnement mensuel.",
/** i18n key: billing.upgradePrompt.postGrantBuyPack */
postGrantBuyPack: 'Acheter un pack Boost — 9 $',
/** i18n key: billing.upgradePrompt.postGrantUpgrade */
postGrantUpgrade: 'Passer à Growth / Pro',
},
},
};
Expand Down
27 changes: 27 additions & 0 deletions src/modules/billing/stores/billing.store.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,33 @@ export const useBillingStore = defineStore('billing', {
extrasLedger: { entries: [], total: 0, page: 1, limit: 20 },
}),

getters: {
/**
* @desc Returns true when the current org is on the Free plan AND has exhausted
* their one-shot signup grant. Specifically: plan === 'free', extrasBalance <= 0,
* AND the extrasLedger has at least one entry with source === 'signup_grant'.
*
* Used to trigger the post-grant upgrade modal variant instead of the regular
* meter-exhaustion prompt.
*
* Returns false when any required state is absent (null-safe).
* @param {Object} state Pinia store state
* @returns {boolean}
*/
exhaustedAfterGrant: (state) => {
if (!state.subscription) return false;
if (state.subscription.plan !== 'free') return false;
if (!state.extrasBalance) return false;
const balance = state.extrasBalance.balance ?? 0;
if (balance > 0) return false;
// Optional chaining: extrasLedger is initialized to {entries:[]} in devkit state, but
// downstream projects may override the store shape, so guard defensively.
const entries = state.extrasLedger?.entries;
if (!Array.isArray(entries) || entries.length === 0) return false;
return entries.some((e) => e.source === 'signup_grant');
},
},

actions: {
/**
* @desc Fetch available billing plans (public).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ describe('BillingPricingCardComponent', () => {
expect(wrapper.text()).toContain('For pros');
});

it('renders plan.badge chip when badge is set (e.g. "500 compute @ signup" for Free)', () => {
const planWithBadge = { ...freePlan, badge: '500 compute @ signup' };
const wrapper = mountComponent({ plan: planWithBadge });
expect(wrapper.text()).toContain('500 compute @ signup');
});

it('does not render badge chip when plan.badge is absent', () => {
const wrapper = mountComponent({ plan: freePlan });
// freePlan has no badge — verify by checking the text doesn't contain any badge content
expect(wrapper.text()).not.toContain('500 compute @ signup');
expect(wrapper.text()).not.toContain('Most Popular');
});
Comment on lines +121 to +126

it('displays "Free" for free plan when displayPrice is null', () => {
const wrapper = mountComponent({ plan: freePlan });
expect(wrapper.text()).toContain('Free');
Expand Down
74 changes: 74 additions & 0 deletions src/modules/billing/tests/billing.store.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,80 @@ describe('Billing Store', () => {
});
});

describe('exhaustedAfterGrant getter', () => {
it('returns true when plan=free, balance<=0, ledger has signup_grant entry', () => {
const store = useBillingStore();
store.subscription = { plan: 'free' };
store.extrasBalance = { balance: 0 };
store.extrasLedger = { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 };
expect(store.exhaustedAfterGrant).toBe(true);
});

it('returns true when balance is negative (overdrawn)', () => {
const store = useBillingStore();
store.subscription = { plan: 'free' };
store.extrasBalance = { balance: -5 };
store.extrasLedger = { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 };
expect(store.exhaustedAfterGrant).toBe(true);
});

it('returns false when plan is not free', () => {
const store = useBillingStore();
store.subscription = { plan: 'growth' };
store.extrasBalance = { balance: 0 };
store.extrasLedger = { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 };
expect(store.exhaustedAfterGrant).toBe(false);
});

it('returns false when balance is positive', () => {
const store = useBillingStore();
store.subscription = { plan: 'free' };
store.extrasBalance = { balance: 100 };
store.extrasLedger = { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 };
expect(store.exhaustedAfterGrant).toBe(false);
});

it('returns false when ledger has no signup_grant entry', () => {
const store = useBillingStore();
store.subscription = { plan: 'free' };
store.extrasBalance = { balance: 0 };
store.extrasLedger = { entries: [{ source: 'pack', amount: 500 }], total: 1, page: 1, limit: 20 };
expect(store.exhaustedAfterGrant).toBe(false);
});

it('returns false when ledger entries are empty', () => {
const store = useBillingStore();
store.subscription = { plan: 'free' };
store.extrasBalance = { balance: 0 };
store.extrasLedger = { entries: [], total: 0, page: 1, limit: 20 };
expect(store.exhaustedAfterGrant).toBe(false);
});

it('returns false when subscription is null', () => {
const store = useBillingStore();
store.subscription = null;
store.extrasBalance = { balance: 0 };
store.extrasLedger = { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 };
expect(store.exhaustedAfterGrant).toBe(false);
});

it('returns false when extrasBalance is null', () => {
const store = useBillingStore();
store.subscription = { plan: 'free' };
store.extrasBalance = null;
store.extrasLedger = { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 };
expect(store.exhaustedAfterGrant).toBe(false);
});

it('returns false when extrasLedger has no entries (null-safe)', () => {
const store = useBillingStore();
store.subscription = { plan: 'free' };
store.extrasBalance = { balance: 0 };
store.extrasLedger = { entries: null, total: 0, page: 1, limit: 20 };
expect(store.exhaustedAfterGrant).toBe(false);
});
});

describe('clearExtrasIntentId (per-pack)', () => {
beforeEach(() => {
sessionStorage.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,110 @@ describe('BillingUpgradePrompt', () => {
expect(wrapper.text()).toContain('This feature requires the');
expect(wrapper.text()).not.toContain("You've used");
});

describe('post-grant variant (exhaustedAfterGrant)', () => {
/**
* Mount with billing store pre-seeded with the given state fields.
* @param {Object} storeState Fields to set on the billingStore.
* @returns {import('@vue/test-utils').VueWrapper}
*/
const mountWithStore = (storeState) => {
const store = useBillingStore();
Object.assign(store, storeState);
return mount(BillingUpgradePrompt, {
props: { requiredPlan: 'growth', mode: 'meter' },
global: {
plugins: [vuetify, i18n],
stubs: { RouterLink: true },
},
});
};

it('renders post-grant copy when exhaustedAfterGrant is true', () => {
const wrapper = mountWithStore({
subscription: { plan: 'free' },
extrasBalance: { balance: 0 },
extrasLedger: { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 },
});
expect(wrapper.text()).toMatch(/signup grant.*depleted|free compute.*used up/i);
});

it('renders Boost pack CTA first (primary) in post-grant variant', () => {
const wrapper = mountWithStore({
subscription: { plan: 'free' },
extrasBalance: { balance: 0 },
extrasLedger: { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 },
});
const packBtn = wrapper.find('[data-test="cta-pack"]');
expect(packBtn.exists()).toBe(true);
expect(packBtn.text()).toMatch(/boost|pack/i);
});

it('renders secondary upgrade CTA in post-grant variant', () => {
const wrapper = mountWithStore({
subscription: { plan: 'free' },
extrasBalance: { balance: 0 },
extrasLedger: { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 },
});
const upgradeBtn = wrapper.find('[data-test="cta-upgrade"]');
expect(upgradeBtn.exists()).toBe(true);
expect(upgradeBtn.text()).toMatch(/upgrade/i);
});

it('does not render post-grant variant when plan is not free', () => {
const wrapper = mountWithStore({
subscription: { plan: 'growth' },
extrasBalance: { balance: 0 },
extrasLedger: { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 },
});
expect(wrapper.text()).not.toMatch(/signup grant.*depleted/i);
});

it('does not render post-grant variant when balance is positive', () => {
const wrapper = mountWithStore({
subscription: { plan: 'free' },
extrasBalance: { balance: 100 },
extrasLedger: { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 },
});
expect(wrapper.text()).not.toMatch(/signup grant.*depleted/i);
});

it('does not render post-grant variant when no signup_grant entry in ledger', () => {
const wrapper = mountWithStore({
subscription: { plan: 'free' },
extrasBalance: { balance: 0 },
extrasLedger: { entries: [{ source: 'pack', amount: 500 }], total: 1, page: 1, limit: 20 },
});
expect(wrapper.text()).not.toMatch(/signup grant.*depleted/i);
});

it('does not render post-grant variant when subscription is null', () => {
const wrapper = mountWithStore({
subscription: null,
extrasBalance: { balance: 0 },
extrasLedger: { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 },
});
expect(wrapper.text()).not.toMatch(/signup grant.*depleted/i);
});

it('does not render post-grant variant in subscription mode even if exhaustedAfterGrant is true', () => {
const store = useBillingStore();
Object.assign(store, {
subscription: { plan: 'free' },
extrasBalance: { balance: 0 },
extrasLedger: { entries: [{ source: 'signup_grant', amount: 500 }], total: 1, page: 1, limit: 20 },
});
// mode='subscription' (default) — post-grant branch must not show, regular prompt must show
const wrapper = mount(BillingUpgradePrompt, {
props: { requiredPlan: 'growth' }, // mode defaults to 'subscription'
global: {
plugins: [vuetify, i18n],
stubs: { RouterLink: true },
},
});
expect(wrapper.text()).not.toMatch(/signup grant.*depleted/i);
// Regular prompt still shows
expect(wrapper.text()).toMatch(/requires the|Upgrade/i);
});
});
});
Loading