feat: Add AI credit topup flow for Stripe (#12988)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
committed by
GitHub
parent
1df5fd513a
commit
b269cca0bf
2
Gemfile
2
Gemfile
@@ -162,7 +162,7 @@ gem 'working_hours'
|
|||||||
gem 'pg_search'
|
gem 'pg_search'
|
||||||
|
|
||||||
# Subscriptions, Billing
|
# Subscriptions, Billing
|
||||||
gem 'stripe'
|
gem 'stripe', '~> 18.0'
|
||||||
|
|
||||||
## - helper gems --##
|
## - helper gems --##
|
||||||
## to populate db with sample data
|
## to populate db with sample data
|
||||||
|
|||||||
@@ -928,7 +928,7 @@ GEM
|
|||||||
squasher (0.7.2)
|
squasher (0.7.2)
|
||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (8.5.0)
|
stripe (18.0.1)
|
||||||
telephone_number (1.4.20)
|
telephone_number (1.4.20)
|
||||||
test-prof (1.2.1)
|
test-prof (1.2.1)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
@@ -1139,7 +1139,7 @@ DEPENDENCIES
|
|||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
stackprof
|
stackprof
|
||||||
stripe
|
stripe (~> 18.0)
|
||||||
telephone_number
|
telephone_number
|
||||||
test-prof
|
test-prof
|
||||||
tidewave
|
tidewave
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
action_type: action,
|
action_type: action,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTopupCheckout(credits) {
|
||||||
|
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useStore } from 'dashboard/composables/store.js';
|
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
@@ -9,6 +9,7 @@ export function useCaptain() {
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
||||||
const { isEnterprise } = useConfig();
|
const { isEnterprise } = useConfig();
|
||||||
|
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||||
|
|
||||||
const captainEnabled = computed(() => {
|
const captainEnabled = computed(() => {
|
||||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
||||||
@@ -34,6 +35,8 @@ export function useCaptain() {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
|
||||||
|
|
||||||
const fetchLimits = () => {
|
const fetchLimits = () => {
|
||||||
if (isEnterprise) {
|
if (isEnterprise) {
|
||||||
store.dispatch('accounts/limits');
|
store.dispatch('accounts/limits');
|
||||||
@@ -46,5 +49,6 @@ export function useCaptain() {
|
|||||||
documentLimits,
|
documentLimits,
|
||||||
responseLimits,
|
responseLimits,
|
||||||
fetchLimits,
|
fetchLimits,
|
||||||
|
isFetchingLimits,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,15 +399,31 @@
|
|||||||
"DESCRIPTION": "Manage usage and credits for Captain AI.",
|
"DESCRIPTION": "Manage usage and credits for Captain AI.",
|
||||||
"BUTTON_TXT": "Buy more credits",
|
"BUTTON_TXT": "Buy more credits",
|
||||||
"DOCUMENTS": "Documents",
|
"DOCUMENTS": "Documents",
|
||||||
"RESPONSES": "Responses",
|
"RESPONSES": "Credits",
|
||||||
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
|
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
|
||||||
|
"REFRESH_CREDITS": "Refresh"
|
||||||
},
|
},
|
||||||
"CHAT_WITH_US": {
|
"CHAT_WITH_US": {
|
||||||
"TITLE": "Need help?",
|
"TITLE": "Need help?",
|
||||||
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
||||||
"BUTTON_TXT": "Chat with us"
|
"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.",
|
||||||
|
"TOPUP": {
|
||||||
|
"BUY_CREDITS": "Buy more credits",
|
||||||
|
"MODAL_TITLE": "Buy AI Credits",
|
||||||
|
"MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
|
||||||
|
"CREDITS": "CREDITS",
|
||||||
|
"ONE_TIME": "one-time",
|
||||||
|
"POPULAR": "Most Popular",
|
||||||
|
"NOTE_TITLE": "Note:",
|
||||||
|
"NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"PURCHASE": "Purchase Credits",
|
||||||
|
"LOADING": "Loading options...",
|
||||||
|
"FETCH_ERROR": "Failed to load credit options. Please try again.",
|
||||||
|
"PURCHASE_ERROR": "Failed to process purchase. Please try again."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"SECURITY_SETTINGS": {
|
"SECURITY_SETTINGS": {
|
||||||
"TITLE": "Security",
|
"TITLE": "Security",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import BillingMeter from './components/BillingMeter.vue';
|
|||||||
import BillingCard from './components/BillingCard.vue';
|
import BillingCard from './components/BillingCard.vue';
|
||||||
import BillingHeader from './components/BillingHeader.vue';
|
import BillingHeader from './components/BillingHeader.vue';
|
||||||
import DetailItem from './components/DetailItem.vue';
|
import DetailItem from './components/DetailItem.vue';
|
||||||
|
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
|
||||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
import SettingsLayout from '../SettingsLayout.vue';
|
import SettingsLayout from '../SettingsLayout.vue';
|
||||||
import ButtonV4 from 'next/button/Button.vue';
|
import ButtonV4 from 'next/button/Button.vue';
|
||||||
@@ -23,6 +24,7 @@ const {
|
|||||||
documentLimits,
|
documentLimits,
|
||||||
responseLimits,
|
responseLimits,
|
||||||
fetchLimits,
|
fetchLimits,
|
||||||
|
isFetchingLimits,
|
||||||
} = useCaptain();
|
} = useCaptain();
|
||||||
|
|
||||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||||
@@ -32,6 +34,7 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
|||||||
|
|
||||||
// State for handling refresh attempts and loading
|
// State for handling refresh attempts and loading
|
||||||
const isWaitingForBilling = ref(false);
|
const isWaitingForBilling = ref(false);
|
||||||
|
const purchaseCreditsModalRef = ref(null);
|
||||||
|
|
||||||
const customAttributes = computed(() => {
|
const customAttributes = computed(() => {
|
||||||
return currentAccount.value.custom_attributes || {};
|
return currentAccount.value.custom_attributes || {};
|
||||||
@@ -45,6 +48,11 @@ const planName = computed(() => {
|
|||||||
return customAttributes.value.plan_name;
|
return customAttributes.value.plan_name;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canPurchaseCredits = computed(() => {
|
||||||
|
const plan = planName.value?.toLowerCase();
|
||||||
|
return plan && plan !== 'hacker';
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed property for subscribed quantity
|
* Computed property for subscribed quantity
|
||||||
* @returns {number|undefined}
|
* @returns {number|undefined}
|
||||||
@@ -71,8 +79,9 @@ const hasABillingPlan = computed(() => {
|
|||||||
const fetchAccountDetails = async () => {
|
const fetchAccountDetails = async () => {
|
||||||
if (!hasABillingPlan.value) {
|
if (!hasABillingPlan.value) {
|
||||||
await store.dispatch('accounts/subscription');
|
await store.dispatch('accounts/subscription');
|
||||||
fetchLimits();
|
|
||||||
}
|
}
|
||||||
|
// Always fetch limits for billing page to show credit usage
|
||||||
|
fetchLimits();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBillingPageLogic = async () => {
|
const handleBillingPageLogic = async () => {
|
||||||
@@ -119,6 +128,10 @@ const onToggleChatWindow = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPurchaseCreditsModal = () => {
|
||||||
|
purchaseCreditsModalRef.value?.open();
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(handleBillingPageLogic);
|
onMounted(handleBillingPageLogic);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -178,9 +191,27 @@ onMounted(handleBillingPageLogic);
|
|||||||
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
|
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
|
||||||
>
|
>
|
||||||
<template #action>
|
<template #action>
|
||||||
<ButtonV4 sm faded slate disabled>
|
<div class="flex gap-2">
|
||||||
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
|
<ButtonV4
|
||||||
|
sm
|
||||||
|
flushed
|
||||||
|
slate
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:is-loading="isFetchingLimits"
|
||||||
|
@click="fetchLimits"
|
||||||
|
>
|
||||||
|
{{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
|
||||||
</ButtonV4>
|
</ButtonV4>
|
||||||
|
<ButtonV4
|
||||||
|
v-if="canPurchaseCredits"
|
||||||
|
sm
|
||||||
|
solid
|
||||||
|
blue
|
||||||
|
@click="openPurchaseCreditsModal"
|
||||||
|
>
|
||||||
|
{{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
|
||||||
|
</ButtonV4>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="captainLimits && responseLimits" class="px-5">
|
<div v-if="captainLimits && responseLimits" class="px-5">
|
||||||
<BillingMeter
|
<BillingMeter
|
||||||
@@ -223,6 +254,7 @@ onMounted(handleBillingPageLogic);
|
|||||||
</ButtonV4>
|
</ButtonV4>
|
||||||
</BillingHeader>
|
</BillingHeader>
|
||||||
</section>
|
</section>
|
||||||
|
<PurchaseCreditsModal ref="purchaseCreditsModalRef" />
|
||||||
</template>
|
</template>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
credits: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: String,
|
||||||
|
default: 'usd',
|
||||||
|
},
|
||||||
|
isSelected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isPopular: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select']);
|
||||||
|
|
||||||
|
const formatCredits = credits => {
|
||||||
|
return credits.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount, currency) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label
|
||||||
|
class="relative flex flex-col p-6 border-2 rounded-xl transition-all cursor-pointer bg-n-solid-1 hover:bg-n-solid-2"
|
||||||
|
:class="[
|
||||||
|
isSelected ? 'border-woot-500' : 'border-n-weak hover:border-n-strong',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:name="name"
|
||||||
|
:value="credits"
|
||||||
|
:checked="isSelected"
|
||||||
|
class="sr-only"
|
||||||
|
@change="emit('select')"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="isPopular"
|
||||||
|
class="absolute -top-3 left-4 px-3 py-1 text-xs font-medium rounded"
|
||||||
|
:class="
|
||||||
|
isSelected ? 'bg-woot-500 text-white' : 'bg-n-solid-3 text-n-slate-11'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ $t('BILLING_SETTINGS.TOPUP.POPULAR') }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="isSelected"
|
||||||
|
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 rounded-full bg-woot-500"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-3xl font-normal text-n-slate-12 mb-2 tracking-tighter">
|
||||||
|
{{ formatCredits(credits) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-xs font-normal text-n-slate-11 uppercase tracking-tight mb-6"
|
||||||
|
>
|
||||||
|
{{ $t('BILLING_SETTINGS.TOPUP.CREDITS') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-2xl font-normal text-n-slate-12 tracking-tight">
|
||||||
|
{{ formatAmount(amount, currency) }}
|
||||||
|
<span class="text-sm text-n-slate-11 ml-0.5">{{
|
||||||
|
$t('BILLING_SETTINGS.TOPUP.ONE_TIME')
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import CreditPackageCard from './CreditPackageCard.vue';
|
||||||
|
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const TOPUP_OPTIONS = [
|
||||||
|
{ credits: 1000, amount: 20.0, currency: 'usd' },
|
||||||
|
{ credits: 2500, amount: 50.0, currency: 'usd' },
|
||||||
|
{ credits: 6000, amount: 100.0, currency: 'usd' },
|
||||||
|
{ credits: 12000, amount: 200.0, currency: 'usd' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const POPULAR_CREDITS_AMOUNT = 6000;
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
const selectedCredits = ref(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
const selectedOption = computed(() => {
|
||||||
|
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePackageSelect = credits => {
|
||||||
|
selectedCredits.value = credits;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePurchase = async () => {
|
||||||
|
if (!selectedOption.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await EnterpriseAccountAPI.createTopupCheckout(
|
||||||
|
selectedOption.value.credits
|
||||||
|
);
|
||||||
|
if (response.data.redirect_url) {
|
||||||
|
window.location.href = response.data.redirect_url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
|
||||||
|
useAlert(errorMessage);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
// Pre-select the most popular option
|
||||||
|
const popularOption = TOPUP_OPTIONS.find(
|
||||||
|
o => o.credits === POPULAR_CREDITS_AMOUNT
|
||||||
|
);
|
||||||
|
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
|
||||||
|
dialogRef.value?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
dialogRef.value?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ open, close });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
:title="$t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')"
|
||||||
|
:description="$t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')"
|
||||||
|
width="xl"
|
||||||
|
:show-confirm-button="false"
|
||||||
|
:show-cancel-button="false"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<CreditPackageCard
|
||||||
|
v-for="option in TOPUP_OPTIONS"
|
||||||
|
:key="option.credits"
|
||||||
|
name="credit-package"
|
||||||
|
:credits="option.credits"
|
||||||
|
:amount="option.amount"
|
||||||
|
:currency="option.currency"
|
||||||
|
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
|
||||||
|
:is-selected="selectedCredits === option.credits"
|
||||||
|
@select="handlePackageSelect(option.credits)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
<span class="font-semibold text-n-slate-12">{{
|
||||||
|
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
|
||||||
|
}}</span>
|
||||||
|
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
|
<Button
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
:label="$t('BILLING_SETTINGS.TOPUP.CANCEL')"
|
||||||
|
class="w-full"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!selectedCredits"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
@click="handlePurchase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -18,6 +18,7 @@ const state = {
|
|||||||
isFetchingItem: false,
|
isFetchingItem: false,
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
isCheckoutInProcess: false,
|
isCheckoutInProcess: false,
|
||||||
|
isFetchingLimits: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,11 +142,14 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
limits: async ({ commit }) => {
|
limits: async ({ commit }) => {
|
||||||
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
|
||||||
try {
|
try {
|
||||||
const response = await EnterpriseAccountAPI.getLimits();
|
const response = await EnterpriseAccountAPI.getLimits();
|
||||||
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
|
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// silent error
|
// silent error
|
||||||
|
} finally {
|
||||||
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,8 @@ class AccountPolicy < ApplicationPolicy
|
|||||||
def toggle_deletion?
|
def toggle_deletion?
|
||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def topup_checkout?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ en:
|
|||||||
invalid_token: Invalid or expired MFA token
|
invalid_token: Invalid or expired MFA token
|
||||||
invalid_credentials: Invalid credentials or verification code
|
invalid_credentials: Invalid credentials or verification code
|
||||||
feature_unavailable: MFA feature is not available. Please configure encryption keys.
|
feature_unavailable: MFA feature is not available. Please configure encryption keys.
|
||||||
|
topup:
|
||||||
|
credits_required: Credits amount is required
|
||||||
|
invalid_credits: Invalid credits amount
|
||||||
|
invalid_option: Invalid topup option
|
||||||
|
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
|
||||||
profile:
|
profile:
|
||||||
mfa:
|
mfa:
|
||||||
enabled: MFA enabled successfully
|
enabled: MFA enabled successfully
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Rails.application.routes.draw do
|
|||||||
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
|
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
|
||||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
|
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
|
||||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
|
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
|
||||||
|
get '/app/accounts/:account_id/settings/billing', to: 'dashboard#index', as: 'app_account_billing_settings'
|
||||||
|
|
||||||
resource :widget, only: [:show]
|
resource :widget, only: [:show]
|
||||||
namespace :survey do
|
namespace :survey do
|
||||||
@@ -438,6 +439,7 @@ Rails.application.routes.draw do
|
|||||||
post :subscription
|
post :subscription
|
||||||
get :limits
|
get :limits
|
||||||
post :toggle_deletion
|
post :toggle_deletion
|
||||||
|
post :topup_checkout
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def topup_checkout
|
||||||
|
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
|
||||||
|
|
||||||
|
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
|
||||||
|
redirect_url = service.create_checkout_session(credits: params[:credits].to_i)
|
||||||
|
render json: { redirect_url: redirect_url }
|
||||||
|
rescue Enterprise::Billing::TopupCheckoutService::Error => e
|
||||||
|
Rails.logger.error("Topup checkout failed for account #{@account.id}: #{e.message}")
|
||||||
|
render_could_not_create_error(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_cloud_env
|
def check_cloud_env
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class Enterprise::Billing::HandleStripeEventService
|
class Enterprise::Billing::HandleStripeEventService
|
||||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||||
|
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
|
||||||
|
|
||||||
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
||||||
# Each higher tier includes all features from the lower tiers
|
# Each higher tier includes all features from the lower tiers
|
||||||
@@ -33,6 +34,8 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
process_subscription_updated
|
process_subscription_updated
|
||||||
when 'customer.subscription.deleted'
|
when 'customer.subscription.deleted'
|
||||||
process_subscription_deleted
|
process_subscription_deleted
|
||||||
|
when 'checkout.session.completed'
|
||||||
|
process_checkout_session_completed
|
||||||
else
|
else
|
||||||
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
||||||
end
|
end
|
||||||
@@ -46,9 +49,25 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
# skipping self hosted plan events
|
# skipping self hosted plan events
|
||||||
return if plan.blank? || account.blank?
|
return if plan.blank? || account.blank?
|
||||||
|
|
||||||
|
previous_usage = capture_previous_usage
|
||||||
update_account_attributes(subscription, plan)
|
update_account_attributes(subscription, plan)
|
||||||
update_plan_features
|
update_plan_features
|
||||||
reset_captain_usage
|
handle_subscription_credits(plan, previous_usage)
|
||||||
|
account.reset_response_usage
|
||||||
|
end
|
||||||
|
|
||||||
|
def capture_previous_usage
|
||||||
|
{
|
||||||
|
responses: account.custom_attributes['captain_responses_usage'].to_i,
|
||||||
|
monthly: current_plan_credits[:responses]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_plan_credits
|
||||||
|
plan_name = account.custom_attributes['plan_name']
|
||||||
|
return { responses: 0, documents: 0 } if plan_name.blank?
|
||||||
|
|
||||||
|
get_plan_credits(plan_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_account_attributes(subscription, plan)
|
def update_account_attributes(subscription, plan)
|
||||||
@@ -73,6 +92,31 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_checkout_session_completed
|
||||||
|
session = @event.data.object
|
||||||
|
metadata = session.metadata
|
||||||
|
|
||||||
|
# Only process topup checkout sessions
|
||||||
|
return unless metadata.present? && metadata['topup'] == 'true'
|
||||||
|
|
||||||
|
topup_account = Account.find_by(id: metadata['account_id'])
|
||||||
|
return if topup_account.blank?
|
||||||
|
|
||||||
|
credits = metadata['credits'].to_i
|
||||||
|
amount_cents = metadata['amount_cents'].to_i
|
||||||
|
currency = metadata['currency'] || 'usd'
|
||||||
|
|
||||||
|
Rails.logger.info("Processing topup for account #{topup_account.id}: #{credits} credits, #{amount_cents} cents")
|
||||||
|
Enterprise::Billing::TopupFulfillmentService.new(account: topup_account).fulfill(
|
||||||
|
credits: credits,
|
||||||
|
amount_cents: amount_cents,
|
||||||
|
currency: currency
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
ChatwootExceptionTracker.new(e, account: topup_account).capture_exception
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
def update_plan_features
|
def update_plan_features
|
||||||
if default_plan?
|
if default_plan?
|
||||||
disable_all_premium_features
|
disable_all_premium_features
|
||||||
@@ -101,8 +145,23 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
enable_plan_specific_features
|
enable_plan_specific_features
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset_captain_usage
|
def handle_subscription_credits(plan, previous_usage)
|
||||||
account.reset_response_usage
|
current_limits = account.limits || {}
|
||||||
|
|
||||||
|
current_credits = current_limits['captain_responses'].to_i
|
||||||
|
new_plan_credits = get_plan_credits(plan['name'])[:responses]
|
||||||
|
|
||||||
|
consumed_topup_credits = [previous_usage[:responses] - previous_usage[:monthly], 0].max
|
||||||
|
updated_credits = current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits
|
||||||
|
|
||||||
|
Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
|
||||||
|
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_plan_credits(plan_name)
|
||||||
|
config = InstallationConfig.find_by(name: CAPTAIN_CLOUD_PLAN_LIMITS).value
|
||||||
|
config = JSON.parse(config) if config.is_a?(String)
|
||||||
|
config[plan_name.downcase]&.symbolize_keys
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable_plan_specific_features
|
def enable_plan_specific_features
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
class Enterprise::Billing::TopupCheckoutService
|
||||||
|
include BillingHelper
|
||||||
|
include Rails.application.routes.url_helpers
|
||||||
|
|
||||||
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
TOPUP_OPTIONS = [
|
||||||
|
{ credits: 1000, amount: 20.0, currency: 'usd' },
|
||||||
|
{ credits: 2500, amount: 50.0, currency: 'usd' },
|
||||||
|
{ credits: 6000, amount: 100.0, currency: 'usd' },
|
||||||
|
{ credits: 12_000, amount: 200.0, currency: 'usd' }
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
pattr_initialize [:account!]
|
||||||
|
|
||||||
|
def create_checkout_session(credits:)
|
||||||
|
topup_option = validate_and_find_topup_option(credits)
|
||||||
|
session = create_stripe_session(topup_option, credits)
|
||||||
|
session.url
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_and_find_topup_option(credits)
|
||||||
|
raise Error, I18n.t('errors.topup.invalid_credits') unless credits.to_i.positive?
|
||||||
|
raise Error, I18n.t('errors.topup.plan_not_eligible') if default_plan?(account)
|
||||||
|
|
||||||
|
topup_option = find_topup_option(credits)
|
||||||
|
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
|
||||||
|
|
||||||
|
topup_option
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_stripe_session(topup_option, credits)
|
||||||
|
Stripe::Checkout::Session.create(
|
||||||
|
customer: stripe_customer_id,
|
||||||
|
mode: 'payment',
|
||||||
|
line_items: [build_line_item(topup_option, credits)],
|
||||||
|
success_url: success_url,
|
||||||
|
cancel_url: cancel_url,
|
||||||
|
metadata: session_metadata(credits, topup_option),
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
# Show saved payment methods and allow saving new ones
|
||||||
|
saved_payment_method_options: {
|
||||||
|
payment_method_save: 'enabled',
|
||||||
|
allow_redisplay_filters: %w[always limited]
|
||||||
|
},
|
||||||
|
# Create invoice for this payment so it appears in customer portal
|
||||||
|
invoice_creation: build_invoice_creation_data(credits, topup_option)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_invoice_creation_data(credits, topup_option)
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
invoice_data: {
|
||||||
|
description: "AI Credits Topup: #{credits} credits",
|
||||||
|
metadata: session_metadata(credits, topup_option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_line_item(topup_option, credits)
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: topup_option[:currency],
|
||||||
|
unit_amount: (topup_option[:amount] * 100).to_i,
|
||||||
|
product_data: { name: "AI Credits Topup: #{credits} credits" }
|
||||||
|
},
|
||||||
|
quantity: 1
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def session_metadata(credits, topup_option)
|
||||||
|
{
|
||||||
|
account_id: account.id.to_s,
|
||||||
|
credits: credits.to_s,
|
||||||
|
amount_cents: (topup_option[:amount] * 100).to_s,
|
||||||
|
currency: topup_option[:currency],
|
||||||
|
topup: 'true'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def success_url
|
||||||
|
app_account_billing_settings_url(account_id: account.id, topup: 'success')
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel_url
|
||||||
|
app_account_billing_settings_url(account_id: account.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def stripe_customer_id
|
||||||
|
account.custom_attributes['stripe_customer_id']
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_topup_option(credits)
|
||||||
|
TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
class Enterprise::Billing::TopupFulfillmentService
|
||||||
|
pattr_initialize [:account!]
|
||||||
|
|
||||||
|
def fulfill(credits:, amount_cents:, currency:)
|
||||||
|
account.with_lock do
|
||||||
|
create_stripe_credit_grant(credits, amount_cents, currency)
|
||||||
|
update_account_credits(credits)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_stripe_credit_grant(credits, amount_cents, currency)
|
||||||
|
Stripe::Billing::CreditGrant.create(
|
||||||
|
customer: stripe_customer_id,
|
||||||
|
name: "Topup: #{credits} credits",
|
||||||
|
amount: {
|
||||||
|
type: 'monetary',
|
||||||
|
monetary: { currency: currency, value: amount_cents }
|
||||||
|
},
|
||||||
|
applicability_config: {
|
||||||
|
scope: { price_type: 'metered' }
|
||||||
|
},
|
||||||
|
category: 'paid',
|
||||||
|
expires_at: 6.months.from_now.to_i,
|
||||||
|
metadata: {
|
||||||
|
account_id: account.id.to_s,
|
||||||
|
source: 'topup',
|
||||||
|
credits: credits.to_s
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_account_credits(credits)
|
||||||
|
current_limits = account.limits || {}
|
||||||
|
current_total = current_limits['captain_responses'].to_i
|
||||||
|
new_total = current_total + credits
|
||||||
|
|
||||||
|
account.update!(
|
||||||
|
limits: current_limits.merge(
|
||||||
|
'captain_responses' => new_total
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def stripe_customer_id
|
||||||
|
account.custom_attributes['stripe_customer_id']
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -19,6 +19,16 @@ describe Enterprise::Billing::HandleStripeEventService do
|
|||||||
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
|
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
create(:installation_config, {
|
||||||
|
name: 'CAPTAIN_CLOUD_PLAN_LIMITS',
|
||||||
|
value: {
|
||||||
|
'hacker' => { 'responses' => 0 },
|
||||||
|
'startups' => { 'responses' => 300 },
|
||||||
|
'business' => { 'responses' => 500 },
|
||||||
|
'enterprise' => { 'responses' => 800 }
|
||||||
|
}
|
||||||
|
})
|
||||||
# Setup common subscription mocks
|
# Setup common subscription mocks
|
||||||
allow(event).to receive(:data).and_return(data)
|
allow(event).to receive(:data).and_return(data)
|
||||||
allow(data).to receive(:object).and_return(subscription)
|
allow(data).to receive(:object).and_return(subscription)
|
||||||
|
|||||||
Reference in New Issue
Block a user