billing V2 frontend

This commit is contained in:
Tanmay Sharma
2025-10-27 14:18:25 +05:30
parent 3ba4340042
commit 45af6266bb
11 changed files with 1433 additions and 9 deletions
@@ -23,6 +23,35 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
getCreditBalance() {
return axios.get(`${this.url}credits_balance`);
}
getV2PricingPlans() {
return axios.get(`${this.url}v2_pricing_plans`);
}
getV2TopupOptions() {
return axios.get(`${this.url}v2_topup_options`);
}
purchaseTopup(credits) {
return axios.post(`${this.url}v2_topup`, { credits });
}
subscribeToV2Plan(pricing_plan_id, quantity = 1) {
return axios.post(`${this.url}v2_subscribe`, { pricing_plan_id, quantity });
}
cancelSubscription(options = {}) {
const { feedback = null, comment = null } = options;
return axios.post(`${this.url}cancel_subscription`, {
feedback,
comment,
});
}
}
export default new EnterpriseAccountAPI();
@@ -395,7 +395,92 @@
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"BUTTON_TXT": "Chat with us"
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
"V2_BILLING": {
"TITLE": "Usage-based billing",
"DESCRIPTION": "Choose the credit package that fits your team, manage usage-based billing, and keep an eye on your credit balance.",
"MANAGE_PAYMENT_METHOD": "Manage payment method",
"MANAGE_BILLING": "Manage billing",
"MANAGE_SUBSCRIPTION": "Manage subscription",
"CONTACT_SUPPORT": "Contact support",
"BACK_TO_BILLING": "Back to Billing",
"CURRENT_PLAN": "Current Plan",
"NO_PLANS_AVAILABLE": "Pricing plans are not available right now. Please try again in a moment or contact support.",
"PLAN_SECTION": {
"TITLE": "Pick the right usage plan",
"DESCRIPTION": "Switch plans anytime - changes take effect immediately and your credits update right away.",
"HELP_TEXT": "Usage beyond the monthly credits is billed at the same overage rate across all plans."
},
"PLAN_CARD": {
"DEFAULT_UNIT": "credits",
"SUMMARY_WITH_BASE": "{credits} {creditUnit} included per month, {baseFee} base fee, {overage} per additional {creditUnit}.",
"SUMMARY_FREE": "{credits} {creditUnit} included per month with no base fee. Pay {overage} per additional {creditUnit}.",
"HIGHLIGHT_INCLUDED": "{credits} {unit} included every month",
"HIGHLIGHT_OVERAGE": "{rate} per additional {unit}",
"HIGHLIGHT_BASE_FEE": "{amount} monthly base fee",
"HIGHLIGHT_NO_BASE_FEE": "No monthly base fee",
"PRICE_FREE": "Free",
"PER_MONTH": "per month",
"BASE_FEE_LABEL": "Base fee",
"BASE_FEE_VALUE": "{amount} / month",
"BASE_FEE_VALUE_FREE": "No base fee",
"MONTHLY_CREDITS_LABEL": "Monthly credits",
"OVERAGE_RATE_LABEL": "Overage rate",
"OVERAGE_RATE_VALUE": "{rate} / {unit}",
"CTA_SELECT": "Select plan",
"CTA_SWITCH": "Switch plan",
"CTA_CURRENT": "Current plan"
},
"PLAN_UPDATE": {
"SUCCESS": "You're now on the {plan} plan.",
"ERROR": "We couldn't update the plan to {plan}. Please try again."
},
"CHECKOUT_SUCCESS": "Subscription successful! Your plan has been updated.",
"CHECKOUT_CANCELLED": "Subscription cancelled. You can try again anytime.",
"ERRORS": {
"FETCH_PLANS": "Failed to load pricing plans. Please refresh and try again.",
"PORTAL": "Failed to open the payment portal. Please try again.",
"SUBSCRIPTION": "We couldn't update your plan. Please try again.",
"TOPUP_OPTIONS": "We couldn't load top-up options. Please try again later.",
"TOPUP": "Top-up failed. Please try again."
},
"CREDIT_BALANCE": {
"TITLE": "Credit balance",
"DESCRIPTION": "Track monthly and top-up credits available to your account.",
"PLAN_NAME": "Current plan",
"SUBSCRIBED_SEATS": "Subscribed seats",
"MONTHLY_CREDITS": "Monthly credits",
"TOPUP_CREDITS": "Top-up credits",
"TOTAL_AVAILABLE": "Total available",
"USAGE_THIS_MONTH": "Used this month",
"USAGE_TOTAL": "Total usage"
},
"TOPUP": {
"BUTTON": "Add credits",
"TITLE": "Add top-up credits",
"DESCRIPTION": "Choose a credit pack to increase your available balance immediately.",
"CREDITS_LABEL": "credits",
"INCLUDES": "{amount} billed immediately",
"CANCEL": "Cancel",
"CONFIRM": "Buy credits",
"SUCCESS": "{credits} credits added successfully.",
"ERROR": "We couldn't add {credits} credits. Please try again."
},
"CANCEL_SUBSCRIPTION": {
"BUTTON_TXT": "Cancel subscription",
"CONFIRM_TITLE": "Cancel subscription",
"CONFIRM_DESCRIPTION": "Are you sure you want to cancel your subscription? Your subscription will remain active until the end of the current billing period.",
"CONFIRM_LABEL": "Yes, cancel subscription",
"CANCEL_LABEL": "No, keep subscription",
"CONFIRM_MESSAGE": "Are you sure you want to cancel your subscription? Your subscription will remain active until the end of the current billing period.",
"CONFIRM_IMMEDIATE_MESSAGE": "Are you sure you want to cancel your subscription immediately? You will lose access to all features immediately.",
"CANCEL_AT_PERIOD_END": "Cancel at period end",
"CANCEL_IMMEDIATELY": "Cancel immediately",
"SUCCESS": "Subscription cancelled successfully. Your subscription will remain active until the end of the current billing period.",
"SUCCESS_WITH_DATE": "Subscription cancelled. You'll retain access until {date}.",
"ERROR": "Failed to cancel subscription"
}
}
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
@@ -1,21 +1,26 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { format } from 'date-fns';
import sessionStorage from 'shared/helpers/sessionStorage';
import { useAlert } from 'dashboard/composables';
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
import BillingTopupModal from './components/BillingTopupModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
const router = useRouter();
const { t } = useI18n();
const { currentAccount, isOnChatwootCloud } = useAccount();
const {
captainEnabled,
@@ -30,6 +35,9 @@ const store = useStore();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
const confirmationModal = ref(null);
const isTopupModalOpen = ref(false);
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
@@ -37,6 +45,14 @@ const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
/**
* Check if this is a V2 billing customer
* @returns {boolean}
*/
const isV2Billing = computed(() => {
return customAttributes.value.stripe_billing_version === 2;
});
/**
* Computed property for plan name
* @returns {string|undefined}
@@ -62,13 +78,36 @@ const subscriptionRenewsOn = computed(() => {
/**
* Computed property indicating if user has a billing plan
* For V2 billing, we consider billing setup complete if stripe_billing_version is set
* @returns {boolean}
*/
const hasABillingPlan = computed(() => {
// For V2 billing, account is considered set up if billing version is set
if (isV2Billing.value) {
return true;
}
// For V1 billing, need an actual plan name
return !!planName.value;
});
/**
* Check if subscription is active and cancellable
* @returns {boolean}
*/
const canCancelSubscription = computed(() => {
const status = customAttributes.value.subscription_status;
return isV2Billing.value && status === 'active';
});
const fetchAccountDetails = async () => {
// For V2 billing, don't call subscription endpoint (it's for V1 Stripe customer creation)
// V2 billing accounts are already set up with stripe_billing_version = 2
if (isV2Billing.value) {
fetchLimits();
return;
}
// For V1 billing, create Stripe customer if needed
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
fetchLimits();
@@ -109,17 +148,106 @@ const handleBillingPageLogic = async () => {
}
};
const creditBalance = ref(null);
const topupUiFlags = computed(() => store.getters['billingV2/uiFlags'] || {});
const isTopupProcessing = computed(() =>
Boolean(topupUiFlags.value?.isProcessing)
);
const topupOptions = computed(
() => store.getters['billingV2/topupOptions'] || []
);
const formatNumber = value =>
new Intl.NumberFormat().format(Number(value || 0));
const fetchCreditBalance = async () => {
if (isV2Billing.value) {
try {
const response = await store.dispatch('accounts/getCreditBalance');
creditBalance.value = response;
} catch (error) {
console.error('Failed to fetch credit balance:', error);
}
}
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
const fetchTopupOptions = async () => {
if (!isV2Billing.value) return;
try {
await store.dispatch('billingV2/fetchTopupOptions');
} catch (error) {
useAlert(
error?.message || t('BILLING_SETTINGS.V2_BILLING.ERRORS.TOPUP_OPTIONS')
);
}
};
const onOpenTopup = async () => {
if (!topupOptions.value.length) {
await fetchTopupOptions();
}
isTopupModalOpen.value = true;
};
const onToggleTopupModal = value => {
isTopupModalOpen.value = value;
};
const onPurchaseTopup = async credits => {
if (!credits || isTopupProcessing.value) return;
try {
await store.dispatch('billingV2/purchaseTopup', { credits });
useAlert(
t('BILLING_SETTINGS.V2_BILLING.TOPUP.SUCCESS', {
credits: formatNumber(credits),
})
);
await Promise.all([store.dispatch('accounts/get'), fetchCreditBalance()]);
} catch (error) {
useAlert(
error?.message ||
t('BILLING_SETTINGS.V2_BILLING.TOPUP.ERROR', {
credits: formatNumber(credits),
})
);
} finally {
isTopupModalOpen.value = false;
}
};
const onToggleChatWindow = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
onMounted(handleBillingPageLogic);
const onCancelSubscription = async () => {
try {
const confirmed = await confirmationModal.value.showConfirmation();
if (!confirmed) return;
// Redirect to Stripe billing portal for cancellation
await store.dispatch('accounts/cancelSubscription');
} catch (error) {
useAlert(
error.message ||
t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.ERROR')
);
}
};
onMounted(async () => {
await handleBillingPageLogic();
await fetchCreditBalance();
await fetchTopupOptions();
});
</script>
<template>
@@ -139,18 +267,75 @@ onMounted(handleBillingPageLogic);
:description="$t('BILLING_SETTINGS.DESCRIPTION')"
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
feature-name="billing"
/>
>
<template v-if="isV2Billing" #actions>
<ButtonV4
sm
solid
blue
icon="i-lucide-sparkles"
@click="
$router.push({
name: 'billing_settings_v2',
params: { accountId: currentAccount.id },
})
"
>
{{ $t('BILLING_SETTINGS.V2_BILLING.TITLE') }}
</ButtonV4>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<section class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
:description="
isV2Billing
? 'You are using usage-based billing with AI credits. The billing portal provides invoice history, payment method management, and subscription cancellation. To change your plan, please contact support.'
: $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')
"
>
<template #action>
<ButtonV4 sm solid blue @click="onClickBillingPortal">
{{ $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT') }}
</ButtonV4>
<div class="flex gap-2">
<ButtonV4
sm
solid
blue
icon="i-lucide-credit-card"
@click="onClickBillingPortal"
>
{{
isV2Billing
? $t('BILLING_SETTINGS.V2_BILLING.MANAGE_BILLING')
: $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT')
}}
</ButtonV4>
<ButtonV4
v-if="canCancelSubscription"
sm
faded
red
icon="i-lucide-x-circle"
@click="onCancelSubscription"
>
{{
$t(
'BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.BUTTON_TXT'
)
}}
</ButtonV4>
<ButtonV4
v-if="isV2Billing"
sm
faded
slate
icon="i-lucide-life-buoy"
@click="onToggleChatWindow"
>
{{ $t('BILLING_SETTINGS.V2_BILLING.CONTACT_SUPPORT') }}
</ButtonV4>
</div>
</template>
<div
v-if="planName || subscribedQuantity || subscriptionRenewsOn"
@@ -172,6 +357,51 @@ onMounted(handleBillingPageLogic);
/>
</div>
</BillingCard>
<BillingCard
v-if="isV2Billing && creditBalance"
:title="$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TITLE')"
:description="
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.DESCRIPTION')
"
>
<template #action>
<div class="flex gap-2">
<ButtonV4
sm
solid
blue
icon="i-lucide-plus"
:disabled="isTopupProcessing"
:is-loading="isTopupProcessing"
@click="onOpenTopup"
>
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.BUTTON') }}
</ButtonV4>
</div>
</template>
<div
class="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-2 divide-x divide-n-weak"
>
<DetailItem
:label="
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.MONTHLY_CREDITS')
"
:value="creditBalance.monthly_credits || 0"
/>
<DetailItem
:label="
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TOPUP_CREDITS')
"
:value="creditBalance.topup_credits || 0"
/>
<DetailItem
:label="
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TOTAL_AVAILABLE')
"
:value="creditBalance.total_credits || 0"
/>
</div>
</BillingCard>
<BillingCard
v-if="captainEnabled"
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
@@ -225,4 +455,20 @@ onMounted(handleBillingPageLogic);
</section>
</template>
</SettingsLayout>
<ConfirmationModal
ref="confirmationModal"
:title="$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CONFIRM_TITLE')"
description="You will be redirected to the Stripe billing portal where you can cancel your subscription. Your subscription will remain active until the end of the current billing period."
confirm-label="Continue to Billing Portal"
cancel-label="Cancel"
/>
<BillingTopupModal
:model-value="isTopupModalOpen"
:options="topupOptions"
:is-loading="isTopupProcessing"
@update:model-value="onToggleTopupModal"
@confirm="onPurchaseTopup"
/>
</template>
@@ -0,0 +1,790 @@
<script>
// Global cache keyed by account ID to survive remounts
const accountDataCache = {};
export default {
name: 'V2Billing',
};
</script>
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import BillingCard from './components/BillingCard.vue';
import DetailItem from './components/DetailItem.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import BillingTopupModal from './components/BillingTopupModal.vue';
const router = useRouter();
const { t } = useI18n();
const { currentAccount, isOnChatwootCloud } = useAccount();
const store = useStore();
const confirmationModal = ref(null);
const pricingPlans = ref([]);
const creditBalance = ref(null);
const subscribingPlanId = ref(null);
const isTopupModalOpen = ref(false);
const planQuantities = ref({});
const isLocallyFetching = ref(true); // Local loading state
const uiFlags = computed(() => store.getters['accounts/getUIFlags'] || {});
const isFetchingPlans = computed(() => Boolean(isLocallyFetching.value));
const isUpdating = computed(() => Boolean(uiFlags.value?.isUpdating));
const isCheckoutInProcess = computed(() =>
Boolean(uiFlags.value?.isCheckoutInProcess)
);
const topupUiFlags = computed(() => store.getters['billingV2/uiFlags'] || {});
const isTopupProcessing = computed(() =>
Boolean(topupUiFlags.value.isProcessing)
);
const topupOptions = computed(
() => store.getters['billingV2/topupOptions'] || []
);
const customAttributes = computed(
() => currentAccount.value.custom_attributes || {}
);
const isV2Billing = computed(
() => customAttributes.value.stripe_billing_version === 2
);
const currentPlanId = computed(
() => customAttributes.value.stripe_pricing_plan_id
);
const subscriptionStatus = computed(
() => customAttributes.value.subscription_status
);
const hasActivePlan = computed(() => {
// Must have both plan ID and active subscription status
return Boolean(currentPlanId.value) && subscriptionStatus.value === 'active';
});
const canCancelSubscription = computed(
() => isV2Billing.value && subscriptionStatus.value === 'active'
);
const planDisplayOrder = ['Hacker', 'Startup', 'Business', 'Enterprise'];
const orderedPlans = computed(() => {
const plans = Array.isArray(pricingPlans.value)
? [...pricingPlans.value]
: [];
return plans
.filter(plan => plan && plan.display_name)
.sort((a, b) => {
const indexA = planDisplayOrder.indexOf(a.display_name);
const indexB = planDisplayOrder.indexOf(b.display_name);
if (indexA === -1 && indexB === -1) {
return a.display_name.localeCompare(b.display_name);
}
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
});
const findComponent = (components, type) => {
if (!Array.isArray(components)) return null;
return components.find(component => component.type === type);
};
const extractCreditAmount = components => {
const serviceAction = findComponent(components, 'service_action');
return Number(serviceAction?.credit_amount) || 0;
};
const extractCreditUnit = components => {
const serviceAction = findComponent(components, 'service_action');
return (
serviceAction?.credit_unit ||
t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.DEFAULT_UNIT')
);
};
const extractBaseFee = components => {
const licenseFee = findComponent(components, 'license_fee');
return Number(licenseFee?.unit_amount) || 0;
};
const planCurrency = plan => plan?.currency || 'usd';
const formatCurrency = (amount, currency = 'usd') => {
const safeAmount = Number.isFinite(amount) ? amount : 0;
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: (currency || 'usd').toUpperCase(),
minimumFractionDigits: safeAmount % 1 === 0 ? 0 : 2,
maximumFractionDigits: 2,
}).format(safeAmount);
};
const formatNumber = amount => {
const safeAmount = Number.isFinite(amount) ? amount : 0;
return new Intl.NumberFormat().format(safeAmount);
};
const getPlanSummary = plan => {
const baseFee = extractBaseFee(plan.components);
const credits = formatNumber(extractCreditAmount(plan.components));
const creditUnit = extractCreditUnit(plan.components);
if (baseFee > 0) {
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.SUMMARY_WITH_BASE', {
credits,
creditUnit,
baseFee: formatCurrency(baseFee, planCurrency(plan)),
});
}
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.SUMMARY_FREE', {
credits,
creditUnit,
});
};
const getPlanHighlights = plan => {
const highlights = [];
const credits = extractCreditAmount(plan.components);
const creditUnit = extractCreditUnit(plan.components);
const baseFee = extractBaseFee(plan.components);
if (credits > 0) {
highlights.push(
t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.HIGHLIGHT_INCLUDED', {
credits: formatNumber(credits),
unit: creditUnit,
})
);
}
if (baseFee > 0) {
highlights.push(
t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.HIGHLIGHT_BASE_FEE', {
amount: formatCurrency(baseFee, planCurrency(plan)),
})
);
} else {
highlights.push(
t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.HIGHLIGHT_NO_BASE_FEE')
);
}
return highlights;
};
const planPriceLabel = plan => {
const baseFee = extractBaseFee(plan.components);
if (baseFee > 0) {
return formatCurrency(baseFee, planCurrency(plan));
}
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.PRICE_FREE');
};
const baseFeeDetailValue = plan => {
const baseFee = extractBaseFee(plan.components);
if (baseFee > 0) {
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.BASE_FEE_VALUE', {
amount: formatCurrency(baseFee, planCurrency(plan)),
});
}
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.BASE_FEE_VALUE_FREE');
};
const isCurrentPlan = plan => currentPlanId.value === plan.id;
const actionLabelForPlan = plan => {
if (isCurrentPlan(plan)) {
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.CTA_CURRENT');
}
if (hasActivePlan.value) {
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.CTA_SWITCH');
}
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.CTA_SELECT');
};
const isPlanLoading = plan => subscribingPlanId.value === plan.id;
const isPlanActionDisabled = plan => {
if (isCurrentPlan(plan)) return true;
if (subscribingPlanId.value && subscribingPlanId.value !== plan.id)
return true;
return isUpdating.value;
};
const getAccountCache = () => {
const accountId = currentAccount.value.id;
if (!accountDataCache[accountId]) {
accountDataCache[accountId] = {
dataFetched: false,
pricingPlans: [],
creditBalance: null,
};
}
return accountDataCache[accountId];
};
const fetchPricingPlans = async () => {
try {
const response = await store.dispatch('accounts/getV2PricingPlans');
const plans = response.pricing_plans || [];
const cache = getAccountCache();
cache.pricingPlans = plans;
pricingPlans.value = plans;
} catch (error) {
useAlert(
error?.message || t('BILLING_SETTINGS.V2_BILLING.ERRORS.FETCH_PLANS')
);
// eslint-disable-next-line no-console
console.error('Failed to fetch pricing plans:', error);
}
};
const fetchCreditBalance = async () => {
if (!isV2Billing.value) return;
try {
const response = await store.dispatch('accounts/getCreditBalance');
const cache = getAccountCache();
cache.creditBalance = response;
creditBalance.value = response;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch credit balance:', error);
}
};
const onOpenPortal = async () => {
try {
await store.dispatch('accounts/checkout');
} catch (error) {
useAlert(error?.message || t('BILLING_SETTINGS.V2_BILLING.ERRORS.PORTAL'));
}
};
const fetchTopupOptions = async () => {
if (!isV2Billing.value) return;
try {
await store.dispatch('billingV2/fetchTopupOptions');
} catch (error) {
useAlert(
error?.message || t('BILLING_SETTINGS.V2_BILLING.ERRORS.TOPUP_OPTIONS')
);
}
};
const onOpenTopup = async () => {
if (!topupOptions.value.length) {
await fetchTopupOptions();
}
isTopupModalOpen.value = true;
};
const onCloseTopup = () => {
isTopupModalOpen.value = false;
};
const onPurchaseTopup = async credits => {
if (!credits || isTopupProcessing.value) return;
try {
await store.dispatch('billingV2/purchaseTopup', { credits });
useAlert(
t('BILLING_SETTINGS.V2_BILLING.TOPUP.SUCCESS', {
credits: formatNumber(credits),
})
);
} catch (error) {
useAlert(
error?.message ||
t('BILLING_SETTINGS.V2_BILLING.TOPUP.ERROR', {
credits: formatNumber(credits),
})
);
} finally {
isTopupModalOpen.value = false;
await Promise.all([store.dispatch('accounts/get'), fetchCreditBalance()]);
}
};
const onToggleTopupModal = value => {
isTopupModalOpen.value = value;
};
const getPlanQuantity = plan => {
return planQuantities.value[plan.id] || 1;
};
const setPlanQuantity = (plan, quantity) => {
const qty = parseInt(quantity, 10);
if (qty > 0 && qty <= 100) {
planQuantities.value[plan.id] = qty;
}
};
const onSelectPlan = async plan => {
if (!plan || isPlanActionDisabled(plan)) return;
subscribingPlanId.value = plan.id;
const quantity = getPlanQuantity(plan);
try {
// Use v2_subscribe for both new subscriptions and plan changes
const response = await store.dispatch('accounts/subscribeToV2Plan', {
pricing_plan_id: plan.id,
quantity: quantity,
});
// Redirect to Stripe Checkout
if (response.redirect_url) {
window.location.href = response.redirect_url;
} else if (response.success) {
useAlert(
t('BILLING_SETTINGS.V2_BILLING.PLAN_UPDATE.SUCCESS', {
plan: plan.display_name,
})
);
await Promise.all([store.dispatch('accounts/get'), fetchCreditBalance()]);
}
} catch (error) {
useAlert(
error?.message ||
t('BILLING_SETTINGS.V2_BILLING.PLAN_UPDATE.ERROR', {
plan: plan.display_name,
})
);
subscribingPlanId.value = null;
}
};
const onCancelSubscription = async () => {
try {
const confirmed = await confirmationModal.value.showConfirmation();
if (!confirmed) return;
// Cancel at period end - subscription remains active until billing period ends
const response = await store.dispatch('accounts/cancelSubscription', {});
if (response.period_end) {
const periodEndDate = new Date(response.period_end).toLocaleDateString();
useAlert(
t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.SUCCESS_WITH_DATE', {
date: periodEndDate,
})
);
} else {
useAlert(t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.SUCCESS'));
}
await store.dispatch('accounts/get');
await fetchCreditBalance();
} catch (error) {
useAlert(
error?.message ||
t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.ERROR')
);
}
};
const onBackToBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: currentAccount.value.id },
});
};
onMounted(async () => {
// Set loading to false immediately for testing
isLocallyFetching.value = false;
// TODO: Temporarily disabled for local testing
// if (!isOnChatwootCloud.value) {
// router.push({ name: 'home' });
// return;
// }
try {
// Always refresh account data to ensure we have latest billing info with timeout
const accountTimeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Account fetch timeout')), 5000)
);
await Promise.race([store.dispatch('accounts/get'), accountTimeout]);
// Handle return from Stripe Checkout
const purchaseStatus = router.currentRoute.value.query.purchase;
if (purchaseStatus === 'success') {
useAlert(t('BILLING_SETTINGS.V2_BILLING.CHECKOUT_SUCCESS'));
// Remove query parameter
router.replace({
name: router.currentRoute.value.name,
params: router.currentRoute.value.params,
});
} else if (purchaseStatus === 'cancelled') {
useAlert(t('BILLING_SETTINGS.V2_BILLING.CHECKOUT_CANCELLED'));
// Remove query parameter
router.replace({
name: router.currentRoute.value.name,
params: router.currentRoute.value.params,
});
}
const cache = getAccountCache();
const shouldFetchPlans = !cache.dataFetched || !cache.pricingPlans?.length;
if (shouldFetchPlans) {
cache.dataFetched = true;
try {
// Add timeout to prevent indefinite hanging
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), 10000)
);
await Promise.race([
Promise.all([
fetchPricingPlans(),
fetchCreditBalance(),
fetchTopupOptions(),
]),
timeoutPromise
]);
} catch (error) {
cache.dataFetched = false;
// eslint-disable-next-line no-console
console.error('Failed to fetch billing data:', error);
}
} else {
pricingPlans.value = cache.pricingPlans;
creditBalance.value = cache.creditBalance;
await fetchTopupOptions();
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error in onMounted:', error);
} finally {
// Always set loading to false after mount completes
isLocallyFetching.value = false;
}
});
</script>
<template>
<SettingsLayout>
<template #header>
<BaseSettingsHeader
:title="$t('BILLING_SETTINGS.V2_BILLING.TITLE')"
:description="$t('BILLING_SETTINGS.V2_BILLING.DESCRIPTION')"
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
feature-name="billing"
/>
</template>
<template #body>
<section class="space-y-6">
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<ButtonV4
sm
faded
slate
icon="i-lucide-arrow-left"
@click="onBackToBilling"
>
{{ $t('BILLING_SETTINGS.V2_BILLING.BACK_TO_BILLING') }}
</ButtonV4>
<ButtonV4
sm
solid
blue
icon="i-lucide-credit-card"
:is-loading="isCheckoutInProcess"
:disabled="isCheckoutInProcess"
@click="onOpenPortal"
>
{{ $t('BILLING_SETTINGS.V2_BILLING.MANAGE_PAYMENT_METHOD') }}
</ButtonV4>
</div>
<BillingCard
v-if="isV2Billing"
:title="$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TITLE')"
:description="
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.DESCRIPTION')
"
>
<div v-if="isFetchingPlans" class="flex justify-center py-8">
<Spinner :size="24" class="text-n-brand" />
</div>
<div v-else class="space-y-4">
<div
v-if="hasActivePlan"
class="grid grid-cols-1 gap-2 divide-y divide-n-weak border-b border-n-weak pb-4 sm:grid-cols-2 sm:divide-y-0 sm:divide-x"
>
<DetailItem
:label="
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.PLAN_NAME')
"
:value="customAttributes.plan_name || 'No active plan'"
/>
<DetailItem
:label="
$t(
'BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.SUBSCRIBED_SEATS'
)
"
:value="formatNumber(customAttributes.subscribed_quantity || 1)"
/>
</div>
<div
class="grid grid-cols-1 gap-2 divide-y divide-n-weak sm:grid-cols-3 sm:divide-y-0 sm:divide-x"
>
<DetailItem
:label="
$t(
'BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.MONTHLY_CREDITS'
)
"
:value="formatNumber(creditBalance?.monthly_credits || 0)"
/>
<DetailItem
:label="
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TOPUP_CREDITS')
"
:value="formatNumber(creditBalance?.topup_credits || 0)"
/>
<DetailItem
:label="
$t(
'BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TOTAL_AVAILABLE'
)
"
:value="formatNumber(creditBalance?.total_credits || 0)"
/>
</div>
<div
v-if="creditBalance?.usage_this_month !== undefined || creditBalance?.usage_total !== undefined"
class="border-t border-n-weak pt-4"
>
<div
class="grid grid-cols-1 gap-2 divide-y divide-n-weak sm:grid-cols-2 sm:divide-y-0 sm:divide-x"
>
<DetailItem
:label="
$t(
'BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.USAGE_THIS_MONTH'
)
"
:value="formatNumber(creditBalance?.usage_this_month || 0)"
/>
<DetailItem
:label="
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.USAGE_TOTAL')
"
:value="formatNumber(creditBalance?.usage_total || 0)"
/>
</div>
</div>
</div>
<template #action>
<div class="flex gap-2">
<ButtonV4
sm
solid
blue
icon="i-lucide-plus"
:disabled="isTopupProcessing"
:is-loading="isTopupProcessing"
@click="onOpenTopup"
>
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.BUTTON') }}
</ButtonV4>
<ButtonV4
v-if="canCancelSubscription"
sm
faded
red
icon="i-lucide-x-circle"
:disabled="isUpdating"
:is-loading="isUpdating && !subscribingPlanId"
@click="onCancelSubscription"
>
{{
$t(
'BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.BUTTON_TXT'
)
}}
</ButtonV4>
</div>
</template>
</BillingCard>
<section class="space-y-4">
<div
class="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between"
>
<div>
<h2 class="text-lg font-semibold text-n-900 dark:text-n-100">
{{ $t('BILLING_SETTINGS.V2_BILLING.PLAN_SECTION.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11">
{{ $t('BILLING_SETTINGS.V2_BILLING.PLAN_SECTION.DESCRIPTION') }}
</p>
</div>
</div>
<p class="text-xs text-n-slate-10">
{{ $t('BILLING_SETTINGS.V2_BILLING.PLAN_SECTION.HELP_TEXT') }}
</p>
<div v-if="isFetchingPlans" class="flex justify-center py-16">
<Spinner :size="32" class="text-n-brand" />
</div>
<div
v-else-if="!orderedPlans.length"
class="rounded-2xl border border-dashed border-n-weak bg-n-solid-1 px-6 py-10 text-center text-sm text-n-slate-11"
>
{{ $t('BILLING_SETTINGS.V2_BILLING.NO_PLANS_AVAILABLE') }}
</div>
<div v-else class="grid gap-4 lg:grid-cols-2">
<div
v-for="plan in orderedPlans"
:key="plan.id"
class="transition-all duration-200"
:class="[
isCurrentPlan(plan)
? 'rounded-2xl ring-2 ring-n-brand shadow-lg'
: 'rounded-2xl hover:shadow-md',
]"
>
<BillingCard
:title="plan.display_name"
:description="getPlanSummary(plan)"
>
<template #action>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div
v-if="isCurrentPlan(plan)"
class="inline-flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-1.5 text-sm font-medium text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300"
>
<span class="i-lucide-check-circle text-base" />
{{ $t('BILLING_SETTINGS.V2_BILLING.CURRENT_PLAN') }}
</div>
<template v-else>
<div class="flex items-center gap-2">
<label
class="text-sm font-medium text-n-slate-11"
:for="`seats-${plan.id}`"
>
Seats:
</label>
<input
:id="`seats-${plan.id}`"
type="number"
min="1"
max="100"
:value="getPlanQuantity(plan)"
class="w-20 rounded-lg border border-n-weak bg-n-solid-1 px-3 py-1.5 text-sm text-n-slate-12 focus:border-n-brand focus:outline-none focus:ring-1 focus:ring-n-brand"
@input="e => setPlanQuantity(plan, e.target.value)"
/>
</div>
<ButtonV4
sm
solid
blue
icon="i-lucide-shopping-cart"
:disabled="isPlanActionDisabled(plan)"
:is-loading="isPlanLoading(plan)"
@click="onSelectPlan(plan)"
>
{{ actionLabelForPlan(plan) }}
</ButtonV4>
</template>
</div>
</template>
<div class="space-y-5 px-5 pb-5">
<div class="flex flex-wrap items-baseline gap-2">
<span class="text-3xl font-semibold text-n-slate-12">
{{ planPriceLabel(plan) }}
</span>
<span class="text-sm text-n-slate-11">
{{
$t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.PER_MONTH')
}}
</span>
</div>
<ul class="space-y-2 text-sm text-n-slate-12">
<li
v-for="(highlight, index) in getPlanHighlights(plan)"
:key="`${plan.id}-highlight-${index}`"
class="flex items-start gap-2"
>
<span
class="i-lucide-check mt-0.5 text-base text-n-brand"
aria-hidden="true"
/>
<span class="leading-5">
{{ highlight }}
</span>
</li>
</ul>
<div
class="grid gap-2 border-t border-n-weak pt-4 sm:grid-cols-2"
>
<DetailItem
:label="
$t(
'BILLING_SETTINGS.V2_BILLING.PLAN_CARD.BASE_FEE_LABEL'
)
"
:value="baseFeeDetailValue(plan)"
/>
<DetailItem
:label="
$t(
'BILLING_SETTINGS.V2_BILLING.PLAN_CARD.MONTHLY_CREDITS_LABEL'
)
"
:value="
formatNumber(extractCreditAmount(plan.components))
"
/>
</div>
</div>
</BillingCard>
</div>
</div>
</section>
</section>
</template>
</SettingsLayout>
<ConfirmationModal
ref="confirmationModal"
:title="$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CONFIRM_TITLE')"
:description="
$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CONFIRM_DESCRIPTION')
"
:confirm-label="
$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CONFIRM_LABEL')
"
:cancel-label="
$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CANCEL_LABEL')
"
/>
<BillingTopupModal
:model-value="isTopupModalOpen"
:options="topupOptions"
:is-loading="isTopupProcessing"
@update:model-value="onToggleTopupModal"
@confirm="onPurchaseTopup"
/>
</template>
@@ -2,6 +2,7 @@ import { frontendURL } from '../../../../helper/URLHelper';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
import V2Billing from './V2Billing.vue';
export default {
routes: [
@@ -27,6 +28,15 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'v2',
name: 'billing_settings_v2',
component: V2Billing,
meta: {
installationTypes: [INSTALLATION_TYPES.CLOUD],
permissions: ['administrator'],
},
},
],
},
],
@@ -0,0 +1,147 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ButtonV4 from 'next/button/Button.vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
options: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const { t } = useI18n();
const selectedCredits = ref(null);
watch(
() => props.modelValue,
value => {
if (!value) {
selectedCredits.value = null;
}
}
);
watch(
() => props.options,
options => {
if (
props.modelValue &&
(!selectedCredits.value ||
!options?.some(opt => opt.credits === selectedCredits.value))
) {
selectedCredits.value = options?.[0]?.credits || null;
}
},
{ immediate: true }
);
const formattedOptions = computed(() => {
return (props.options || []).map(option => ({
...option,
label: `${option.credits.toLocaleString()} ${t(
'BILLING_SETTINGS.V2_BILLING.TOPUP.CREDITS_LABEL'
)}`,
price: new Intl.NumberFormat(undefined, {
style: 'currency',
currency: (option.currency || 'usd').toUpperCase(),
minimumFractionDigits: 2,
}).format(option.amount),
}));
});
const onClose = () => {
emit('update:modelValue', false);
};
const onConfirm = () => {
if (!selectedCredits.value) return;
emit('confirm', selectedCredits.value);
};
</script>
<template>
<teleport to="body">
<transition name="fade">
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-n-alpha-7 p-4"
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-lg rounded-2xl bg-n-solid-1 shadow-xl">
<header class="px-6 pt-6 pb-4 border-b border-n-weak">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.TITLE') }}
</h3>
<p class="mt-1 text-sm text-n-slate-11">
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.DESCRIPTION') }}
</p>
</header>
<section class="px-6 py-4 space-y-4">
<div class="grid gap-3">
<label
v-for="option in formattedOptions"
:key="option.credits"
class="flex cursor-pointer items-center justify-between rounded-xl border px-4 py-3 transition-colors"
:class="
selectedCredits === option.credits
? 'border-n-brand bg-n-brand/5'
: 'border-n-weak hover:border-n-brand/60'
"
>
<div>
<p class="text-sm font-medium text-n-slate-12">
{{ option.label }}
</p>
<p class="text-xs text-n-slate-10">
{{
$t('BILLING_SETTINGS.V2_BILLING.TOPUP.INCLUDES', {
amount: option.price,
})
}}
</p>
</div>
<input
v-model="selectedCredits"
type="radio"
class="form-radio h-4 w-4 text-n-brand"
:value="option.credits"
/>
</label>
</div>
</section>
<footer
class="flex items-center justify-end gap-3 border-t border-n-weak px-6 py-4"
>
<ButtonV4 sm faded slate @click="onClose">
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.CANCEL') }}
</ButtonV4>
<ButtonV4
sm
solid
blue
:disabled="!selectedCredits"
:is-loading="isLoading"
@click="onConfirm"
>
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.CONFIRM') }}
</ButtonV4>
</footer>
</div>
</div>
</transition>
</teleport>
</template>
@@ -5,7 +5,7 @@ defineProps({
required: true,
},
value: {
type: String,
type: [String, Number],
required: true,
},
});
+2
View File
@@ -48,6 +48,7 @@ import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import billingV2 from './modules/billingV2';
import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
@@ -111,6 +112,7 @@ export default createStore({
teams,
userNotificationSettings,
webhooks,
billingV2,
captainAssistants,
captainDocuments,
captainResponses,
@@ -149,6 +149,53 @@ export const actions = {
}
},
getCreditBalance: async () => {
const response = await EnterpriseAccountAPI.getCreditBalance();
return response.data;
},
getV2PricingPlans: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
try {
const response = await EnterpriseAccountAPI.getV2PricingPlans();
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
return response.data;
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
throwErrorMessage(error);
throw error;
}
},
subscribeToV2Plan: async ({ commit }, { pricing_plan_id, quantity = 1 }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
const response = await EnterpriseAccountAPI.subscribeToV2Plan(
pricing_plan_id,
quantity
);
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
return response.data;
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
throwErrorMessage(error);
throw error;
}
},
cancelSubscription: async ({ commit }, options = {}) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
const response = await EnterpriseAccountAPI.cancelSubscription(options);
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
return response.data;
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
throwErrorMessage(error);
throw error;
}
},
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
@@ -0,0 +1,68 @@
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
import { throwErrorMessage } from '../utils/api';
const state = {
topupOptions: [],
uiFlags: {
isLoading: false,
isProcessing: false,
},
};
export const getters = {
topupOptions($state) {
return $state.topupOptions;
},
uiFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
async fetchTopupOptions({ commit }) {
commit('SET_UI_FLAG', { isLoading: true });
try {
const response = await EnterpriseAccountAPI.getV2TopupOptions();
commit('SET_TOPUP_OPTIONS', response.data.topup_options);
return response.data.topup_options;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit('SET_UI_FLAG', { isLoading: false });
}
},
async purchaseTopup({ commit }, { credits }) {
commit('SET_UI_FLAG', { isProcessing: true });
try {
const response = await EnterpriseAccountAPI.purchaseTopup(credits);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit('SET_UI_FLAG', { isProcessing: false });
}
},
};
export const mutations = {
SET_UI_FLAG($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
SET_TOPUP_OPTIONS($state, options) {
$state.topupOptions = options || [];
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};