## Description - Replaces Stripe Checkout session flow with direct card charging for AI credit top-ups - Adds a two-step confirmation modal (select package → confirm purchase) for better UX - Creates Stripe invoice directly and charges the customer's default payment method immediately ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Using the specs - UI manual test cases <img width="945" height="580" alt="image" src="https://github.com/user-attachments/assets/52bdad46-cd0e-4927-b13f-54c6b6353bcc" /> <img width="945" height="580" alt="image" src="https://github.com/user-attachments/assets/231bc7e9-41ac-440d-a93d-cba45a4d3e3e" /> ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
269 lines
8.1 KiB
Vue
269 lines
8.1 KiB
Vue
<script setup>
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
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 BillingMeter from './components/BillingMeter.vue';
|
|
import BillingCard from './components/BillingCard.vue';
|
|
import BillingHeader from './components/BillingHeader.vue';
|
|
import DetailItem from './components/DetailItem.vue';
|
|
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
|
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
|
import SettingsLayout from '../SettingsLayout.vue';
|
|
import ButtonV4 from 'next/button/Button.vue';
|
|
|
|
const router = useRouter();
|
|
const { currentAccount, isOnChatwootCloud } = useAccount();
|
|
const {
|
|
captainEnabled,
|
|
captainLimits,
|
|
documentLimits,
|
|
responseLimits,
|
|
fetchLimits,
|
|
isFetchingLimits,
|
|
} = useCaptain();
|
|
|
|
const uiFlags = useMapGetter('accounts/getUIFlags');
|
|
const store = useStore();
|
|
|
|
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
|
|
|
// State for handling refresh attempts and loading
|
|
const isWaitingForBilling = ref(false);
|
|
const purchaseCreditsModalRef = ref(null);
|
|
|
|
const customAttributes = computed(() => {
|
|
return currentAccount.value.custom_attributes || {};
|
|
});
|
|
|
|
/**
|
|
* Computed property for plan name
|
|
* @returns {string|undefined}
|
|
*/
|
|
const planName = computed(() => {
|
|
return customAttributes.value.plan_name;
|
|
});
|
|
|
|
const canPurchaseCredits = computed(() => {
|
|
const plan = planName.value?.toLowerCase();
|
|
return plan && plan !== 'hacker';
|
|
});
|
|
|
|
/**
|
|
* Computed property for subscribed quantity
|
|
* @returns {number|undefined}
|
|
*/
|
|
const subscribedQuantity = computed(() => {
|
|
return customAttributes.value.subscribed_quantity;
|
|
});
|
|
|
|
const subscriptionRenewsOn = computed(() => {
|
|
if (!customAttributes.value.subscription_ends_on) return '';
|
|
const endDate = new Date(customAttributes.value.subscription_ends_on);
|
|
// return date as 12 Jan, 2034
|
|
return format(endDate, 'dd MMM, yyyy');
|
|
});
|
|
|
|
/**
|
|
* Computed property indicating if user has a billing plan
|
|
* @returns {boolean}
|
|
*/
|
|
const hasABillingPlan = computed(() => {
|
|
return !!planName.value;
|
|
});
|
|
|
|
const fetchAccountDetails = async () => {
|
|
if (!hasABillingPlan.value) {
|
|
await store.dispatch('accounts/subscription');
|
|
}
|
|
// Always fetch limits for billing page to show credit usage
|
|
fetchLimits();
|
|
};
|
|
|
|
const handleBillingPageLogic = async () => {
|
|
// If self-hosted, redirect to dashboard
|
|
if (!isOnChatwootCloud.value) {
|
|
router.push({ name: 'home' });
|
|
return;
|
|
}
|
|
|
|
// Check if we've already attempted a refresh for billing setup
|
|
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
|
|
|
|
// If cloud user, fetch account details first
|
|
await fetchAccountDetails();
|
|
|
|
// If still no billing plan after fetch
|
|
if (!hasABillingPlan.value) {
|
|
// If we haven't attempted refresh yet, do it once
|
|
if (!billingRefreshAttempted) {
|
|
isWaitingForBilling.value = true;
|
|
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
|
|
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 5000);
|
|
} else {
|
|
// We've already tried refreshing, so just show the no billing message
|
|
// Clear the flag for future visits
|
|
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
|
}
|
|
} else {
|
|
// Billing plan found, clear any existing refresh flag
|
|
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
|
}
|
|
};
|
|
|
|
const onClickBillingPortal = () => {
|
|
store.dispatch('accounts/checkout');
|
|
};
|
|
|
|
const onToggleChatWindow = () => {
|
|
if (window.$chatwoot) {
|
|
window.$chatwoot.toggle();
|
|
}
|
|
};
|
|
|
|
const openPurchaseCreditsModal = () => {
|
|
purchaseCreditsModalRef.value?.open();
|
|
};
|
|
|
|
const handleTopupSuccess = () => {
|
|
// Refresh limits to show updated credit balance
|
|
fetchLimits();
|
|
};
|
|
|
|
onMounted(handleBillingPageLogic);
|
|
</script>
|
|
|
|
<template>
|
|
<SettingsLayout
|
|
:is-loading="uiFlags.isFetchingItem || isWaitingForBilling"
|
|
:loading-message="
|
|
isWaitingForBilling
|
|
? $t('BILLING_SETTINGS.NO_BILLING_USER')
|
|
: $t('ATTRIBUTES_MGMT.LOADING')
|
|
"
|
|
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
|
|
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
|
|
>
|
|
<template #header>
|
|
<BaseSettingsHeader
|
|
:title="$t('BILLING_SETTINGS.TITLE')"
|
|
:description="$t('BILLING_SETTINGS.DESCRIPTION')"
|
|
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
|
|
feature-name="billing"
|
|
/>
|
|
</template>
|
|
<template #body>
|
|
<section class="grid gap-4">
|
|
<BillingCard
|
|
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
|
|
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
|
|
>
|
|
<template #action>
|
|
<ButtonV4 sm solid blue @click="onClickBillingPortal">
|
|
{{ $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT') }}
|
|
</ButtonV4>
|
|
</template>
|
|
<div
|
|
v-if="planName || subscribedQuantity || subscriptionRenewsOn"
|
|
class="grid lg:grid-cols-4 sm:grid-cols-3 grid-cols-1 gap-2 divide-x divide-n-weak"
|
|
>
|
|
<DetailItem
|
|
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.TITLE')"
|
|
:value="planName"
|
|
/>
|
|
<DetailItem
|
|
v-if="subscribedQuantity"
|
|
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.SEAT_COUNT')"
|
|
:value="subscribedQuantity"
|
|
/>
|
|
<DetailItem
|
|
v-if="subscriptionRenewsOn"
|
|
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
|
|
:value="subscriptionRenewsOn"
|
|
/>
|
|
</div>
|
|
</BillingCard>
|
|
<BillingCard
|
|
v-if="captainEnabled"
|
|
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
|
|
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
|
|
>
|
|
<template #action>
|
|
<div class="flex gap-2">
|
|
<ButtonV4
|
|
sm
|
|
flushed
|
|
slate
|
|
icon="i-lucide-refresh-cw"
|
|
:is-loading="isFetchingLimits"
|
|
@click="fetchLimits"
|
|
>
|
|
{{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
|
|
</ButtonV4>
|
|
<ButtonV4
|
|
v-if="canPurchaseCredits"
|
|
sm
|
|
solid
|
|
blue
|
|
@click="openPurchaseCreditsModal"
|
|
>
|
|
{{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
|
|
</ButtonV4>
|
|
</div>
|
|
</template>
|
|
<div v-if="captainLimits && responseLimits" class="px-5">
|
|
<BillingMeter
|
|
:title="$t('BILLING_SETTINGS.CAPTAIN.RESPONSES')"
|
|
v-bind="responseLimits"
|
|
/>
|
|
</div>
|
|
<div v-if="captainLimits && documentLimits" class="px-5">
|
|
<BillingMeter
|
|
:title="$t('BILLING_SETTINGS.CAPTAIN.DOCUMENTS')"
|
|
v-bind="documentLimits"
|
|
/>
|
|
</div>
|
|
</BillingCard>
|
|
<BillingCard
|
|
v-else
|
|
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
|
|
:description="$t('BILLING_SETTINGS.CAPTAIN.UPGRADE')"
|
|
>
|
|
<template #action>
|
|
<ButtonV4 sm solid slate @click="onClickBillingPortal">
|
|
{{ $t('CAPTAIN.PAYWALL.UPGRADE_NOW') }}
|
|
</ButtonV4>
|
|
</template>
|
|
</BillingCard>
|
|
|
|
<BillingHeader
|
|
class="px-1 mt-5"
|
|
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
|
|
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
|
|
>
|
|
<ButtonV4
|
|
sm
|
|
solid
|
|
slate
|
|
icon="i-lucide-life-buoy"
|
|
@click="onToggleChatWindow"
|
|
>
|
|
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
|
|
</ButtonV4>
|
|
</BillingHeader>
|
|
</section>
|
|
<PurchaseCreditsModal
|
|
ref="purchaseCreditsModalRef"
|
|
@success="handleTopupSuccess"
|
|
/>
|
|
</template>
|
|
</SettingsLayout>
|
|
</template>
|