Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bada8cbe87 | ||
|
|
62e6c2b76e | ||
|
|
e74d0c727a | ||
|
|
3690990379 | ||
|
|
1235aa2562 | ||
|
|
ff35dcb7ff | ||
|
|
06e15ff081 | ||
|
|
56d9b232d1 | ||
|
|
15f920c9af | ||
|
|
d191603620 | ||
|
|
20911a01dc | ||
|
|
462100cba1 | ||
|
|
aab8c848b6 | ||
|
|
d6c75586df | ||
|
|
bbd575d8fa | ||
|
|
4bf6a15c06 | ||
|
|
8995203473 | ||
|
|
4e33490676 | ||
|
|
0768ce70ab | ||
|
|
b38618b5d3 |
@@ -22,4 +22,16 @@ module BillingHelper
|
|||||||
def agents(account)
|
def agents(account)
|
||||||
account.users.count
|
account.users.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
|
||||||
|
def subscription_period_end(subscription)
|
||||||
|
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscription_ends_on(subscription)
|
||||||
|
period_end = subscription_period_end(subscription)
|
||||||
|
return if period_end.blank?
|
||||||
|
|
||||||
|
Time.zone.at(period_end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
return axios.post(`${this.url}subscription`);
|
return axios.post(`${this.url}subscription`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectBillingCurrency(currency) {
|
||||||
|
return axios.post(`${this.url}select_billing_currency`, { currency });
|
||||||
|
}
|
||||||
|
|
||||||
getLimits() {
|
getLimits() {
|
||||||
return axios.get(`${this.url}limits`);
|
return axios.get(`${this.url}limits`);
|
||||||
}
|
}
|
||||||
@@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
createTopupCheckout(credits) {
|
createTopupCheckout(credits) {
|
||||||
return axios.post(`${this.url}topup_checkout`, { credits });
|
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Topup packages for the account's billing currency.
|
||||||
|
getTopupOptions() {
|
||||||
|
return axios.get(`${this.url}topup_options`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Single source of truth for billing currencies on the frontend.
|
||||||
|
// Adding a currency = one entry in BILLING_CURRENCY_CONFIG, add the code to
|
||||||
|
// SUPPORTED_BILLING_CURRENCIES, and add its label key under
|
||||||
|
// BILLING_SETTINGS.CURRENCY.OPTIONS in the locale files.
|
||||||
|
|
||||||
|
export const DEFAULT_BILLING_CURRENCY = 'usd';
|
||||||
|
|
||||||
|
// Order here drives the order of the currency toggle in the UI.
|
||||||
|
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
|
||||||
|
|
||||||
|
export const BILLING_CURRENCY_CONFIG = {
|
||||||
|
usd: {
|
||||||
|
code: 'usd',
|
||||||
|
intlLocale: 'en-US',
|
||||||
|
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
|
||||||
|
},
|
||||||
|
brl: {
|
||||||
|
code: 'brl',
|
||||||
|
intlLocale: 'pt-BR',
|
||||||
|
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrencyConfig = code =>
|
||||||
|
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
|
||||||
|
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
|
||||||
|
|
||||||
|
export const formatCurrencyAmount = (amount, code, options = {}) => {
|
||||||
|
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
|
||||||
|
return new Intl.NumberFormat(intlLocale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currencyCode.toUpperCase(),
|
||||||
|
...options,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
@@ -465,7 +465,18 @@
|
|||||||
"TITLE": "Current Plan",
|
"TITLE": "Current Plan",
|
||||||
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
|
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
|
||||||
"SEAT_COUNT": "Number of seats",
|
"SEAT_COUNT": "Number of seats",
|
||||||
"RENEWS_ON": "Renews on"
|
"RENEWS_ON": "Renews on",
|
||||||
|
"CURRENCY": "Currency"
|
||||||
|
},
|
||||||
|
"CURRENCY": {
|
||||||
|
"SELECT": {
|
||||||
|
"TITLE": "Choose your billing currency",
|
||||||
|
"DESCRIPTION": "Select the currency you'd like to be billed in. This can't be changed once your subscription is created."
|
||||||
|
},
|
||||||
|
"OPTIONS": {
|
||||||
|
"USD": "US Dollar (USD)",
|
||||||
|
"BRL": "Brazilian Real (BRL)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"VIEW_PRICING": "View Pricing",
|
"VIEW_PRICING": "View Pricing",
|
||||||
"MANAGE_SUBSCRIPTION": {
|
"MANAGE_SUBSCRIPTION": {
|
||||||
@@ -501,6 +512,7 @@
|
|||||||
"PURCHASE": "Purchase Credits",
|
"PURCHASE": "Purchase Credits",
|
||||||
"LOADING": "Loading options...",
|
"LOADING": "Loading options...",
|
||||||
"FETCH_ERROR": "Failed to load credit options. Please try again.",
|
"FETCH_ERROR": "Failed to load credit options. Please try again.",
|
||||||
|
"RETRY": "Retry",
|
||||||
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
|
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
|
||||||
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
|
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
|
||||||
"CONFIRM": {
|
"CONFIRM": {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ 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';
|
||||||
|
import { getCurrencyConfig } from 'dashboard/constants/billing';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||||
@@ -29,6 +31,7 @@ const {
|
|||||||
|
|
||||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||||
|
|
||||||
@@ -36,6 +39,10 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
|||||||
const isWaitingForBilling = ref(false);
|
const isWaitingForBilling = ref(false);
|
||||||
const purchaseCreditsModalRef = ref(null);
|
const purchaseCreditsModalRef = ref(null);
|
||||||
|
|
||||||
|
// Currency selection shown to new accounts whose locale supports a non-USD currency.
|
||||||
|
const currencySelectionRequired = ref(false);
|
||||||
|
const currencyOptions = ref([]);
|
||||||
|
|
||||||
const customAttributes = computed(() => {
|
const customAttributes = computed(() => {
|
||||||
return currentAccount.value.custom_attributes || {};
|
return currentAccount.value.custom_attributes || {};
|
||||||
});
|
});
|
||||||
@@ -61,6 +68,13 @@ const subscribedQuantity = computed(() => {
|
|||||||
return customAttributes.value.subscribed_quantity;
|
return customAttributes.value.subscribed_quantity;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const billingCurrency = computed(() => {
|
||||||
|
if (!customAttributes.value.billing_currency) return '';
|
||||||
|
return t(
|
||||||
|
getCurrencyConfig(customAttributes.value.billing_currency).i18nLabelKey
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const subscriptionRenewsOn = computed(() => {
|
const subscriptionRenewsOn = computed(() => {
|
||||||
if (!customAttributes.value.subscription_ends_on) return '';
|
if (!customAttributes.value.subscription_ends_on) return '';
|
||||||
const endDate = new Date(customAttributes.value.subscription_ends_on);
|
const endDate = new Date(customAttributes.value.subscription_ends_on);
|
||||||
@@ -78,7 +92,9 @@ const hasABillingPlan = computed(() => {
|
|||||||
|
|
||||||
const fetchAccountDetails = async () => {
|
const fetchAccountDetails = async () => {
|
||||||
if (!hasABillingPlan.value) {
|
if (!hasABillingPlan.value) {
|
||||||
await store.dispatch('accounts/subscription');
|
const data = await store.dispatch('accounts/subscription');
|
||||||
|
currencySelectionRequired.value = !!data?.currency_selection_required;
|
||||||
|
currencyOptions.value = data?.currency_options || [];
|
||||||
}
|
}
|
||||||
// Always fetch limits for billing page to show credit usage
|
// Always fetch limits for billing page to show credit usage
|
||||||
fetchLimits();
|
fetchLimits();
|
||||||
@@ -97,6 +113,9 @@ const handleBillingPageLogic = async () => {
|
|||||||
// If cloud user, fetch account details first
|
// If cloud user, fetch account details first
|
||||||
await fetchAccountDetails();
|
await fetchAccountDetails();
|
||||||
|
|
||||||
|
// Waiting on the user to pick a billing currency — don't auto-refresh.
|
||||||
|
if (currencySelectionRequired.value) return;
|
||||||
|
|
||||||
// If still no billing plan after fetch
|
// If still no billing plan after fetch
|
||||||
if (!hasABillingPlan.value) {
|
if (!hasABillingPlan.value) {
|
||||||
// If we haven't attempted refresh yet, do it once
|
// If we haven't attempted refresh yet, do it once
|
||||||
@@ -118,6 +137,13 @@ const handleBillingPageLogic = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSelectCurrency = async code => {
|
||||||
|
await store.dispatch('accounts/selectBillingCurrency', code);
|
||||||
|
currencySelectionRequired.value = false;
|
||||||
|
// Currency stored and customer creation kicked off — resume the standard wait flow.
|
||||||
|
await handleBillingPageLogic();
|
||||||
|
};
|
||||||
|
|
||||||
const onClickBillingPortal = () => {
|
const onClickBillingPortal = () => {
|
||||||
store.dispatch('accounts/checkout');
|
store.dispatch('accounts/checkout');
|
||||||
};
|
};
|
||||||
@@ -148,7 +174,9 @@ onMounted(handleBillingPageLogic);
|
|||||||
? $t('BILLING_SETTINGS.NO_BILLING_USER')
|
? $t('BILLING_SETTINGS.NO_BILLING_USER')
|
||||||
: $t('ATTRIBUTES_MGMT.LOADING')
|
: $t('ATTRIBUTES_MGMT.LOADING')
|
||||||
"
|
"
|
||||||
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
|
:no-records-found="
|
||||||
|
!hasABillingPlan && !isWaitingForBilling && !currencySelectionRequired
|
||||||
|
"
|
||||||
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
|
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
@@ -160,7 +188,29 @@ onMounted(handleBillingPageLogic);
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #body>
|
<template #body>
|
||||||
<section class="grid gap-4">
|
<section v-if="currencySelectionRequired" class="grid gap-4">
|
||||||
|
<BillingCard
|
||||||
|
:title="$t('BILLING_SETTINGS.CURRENCY.SELECT.TITLE')"
|
||||||
|
:description="$t('BILLING_SETTINGS.CURRENCY.SELECT.DESCRIPTION')"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonV4
|
||||||
|
v-for="code in currencyOptions"
|
||||||
|
:key="code"
|
||||||
|
sm
|
||||||
|
solid
|
||||||
|
blue
|
||||||
|
:is-loading="uiFlags.isCheckoutInProcess"
|
||||||
|
@click="onSelectCurrency(code)"
|
||||||
|
>
|
||||||
|
{{ $t(getCurrencyConfig(code).i18nLabelKey) }}
|
||||||
|
</ButtonV4>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BillingCard>
|
||||||
|
</section>
|
||||||
|
<section v-else class="grid gap-4">
|
||||||
<BillingCard
|
<BillingCard
|
||||||
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
|
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
|
||||||
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
|
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
|
||||||
@@ -188,6 +238,11 @@ onMounted(handleBillingPageLogic);
|
|||||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
|
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
|
||||||
:value="subscriptionRenewsOn"
|
:value="subscriptionRenewsOn"
|
||||||
/>
|
/>
|
||||||
|
<DetailItem
|
||||||
|
v-if="billingCurrency"
|
||||||
|
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.CURRENCY')"
|
||||||
|
:value="billingCurrency"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BillingCard>
|
</BillingCard>
|
||||||
<BillingCard
|
<BillingCard
|
||||||
|
|||||||
+3
-5
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { formatCurrencyAmount } from 'dashboard/constants/billing';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
credits: {
|
credits: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -33,11 +35,7 @@ const formatCredits = credits => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatAmount = (amount, currency) => {
|
const formatAmount = (amount, currency) => {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return formatCurrencyAmount(amount, currency, { minimumFractionDigits: 0 });
|
||||||
style: 'currency',
|
|
||||||
currency: currency.toUpperCase(),
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
}).format(amount);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+91
-43
@@ -4,20 +4,18 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import CreditPackageCard from './CreditPackageCard.vue';
|
import CreditPackageCard from './CreditPackageCard.vue';
|
||||||
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
|
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
|
||||||
|
import {
|
||||||
|
formatCurrencyAmount,
|
||||||
|
DEFAULT_BILLING_CURRENCY,
|
||||||
|
} from 'dashboard/constants/billing';
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'success']);
|
const emit = defineEmits(['success']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
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 POPULAR_CREDITS_AMOUNT = 6000;
|
||||||
const STEP_SELECT = 'select';
|
const STEP_SELECT = 'select';
|
||||||
const STEP_CONFIRM = 'confirm';
|
const STEP_CONFIRM = 'confirm';
|
||||||
@@ -27,16 +25,20 @@ const selectedCredits = ref(null);
|
|||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const currentStep = ref(STEP_SELECT);
|
const currentStep = ref(STEP_SELECT);
|
||||||
|
|
||||||
|
// Topup packages come from the backend for the account's billing currency.
|
||||||
|
const topupOptions = ref([]);
|
||||||
|
const optionsCurrency = ref(DEFAULT_BILLING_CURRENCY);
|
||||||
|
const isFetchingOptions = ref(false);
|
||||||
|
const fetchError = ref(false);
|
||||||
|
|
||||||
const selectedOption = computed(() => {
|
const selectedOption = computed(() => {
|
||||||
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
|
return topupOptions.value.find(o => o.credits === selectedCredits.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedAmount = computed(() => {
|
const formattedAmount = computed(() => {
|
||||||
if (!selectedOption.value) return '';
|
if (!selectedOption.value) return '';
|
||||||
return new Intl.NumberFormat('en-US', {
|
const { amount, currency } = selectedOption.value;
|
||||||
style: 'currency',
|
return formatCurrencyAmount(amount, currency || optionsCurrency.value);
|
||||||
currency: selectedOption.value.currency.toUpperCase(),
|
|
||||||
}).format(selectedOption.value.amount);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedCredits = computed(() => {
|
const formattedCredits = computed(() => {
|
||||||
@@ -64,24 +66,44 @@ const handlePackageSelect = credits => {
|
|||||||
selectedCredits.value = credits;
|
selectedCredits.value = credits;
|
||||||
};
|
};
|
||||||
|
|
||||||
const open = () => {
|
const selectDefaultOption = () => {
|
||||||
const popularOption = TOPUP_OPTIONS.find(
|
const popularOption = topupOptions.value.find(
|
||||||
o => o.credits === POPULAR_CREDITS_AMOUNT
|
o => o.credits === POPULAR_CREDITS_AMOUNT
|
||||||
);
|
);
|
||||||
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
|
selectedCredits.value =
|
||||||
|
popularOption?.credits || topupOptions.value[0]?.credits || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOptions = async () => {
|
||||||
|
isFetchingOptions.value = true;
|
||||||
|
fetchError.value = false;
|
||||||
|
try {
|
||||||
|
const { data } = await EnterpriseAccountAPI.getTopupOptions();
|
||||||
|
topupOptions.value = data.options ?? [];
|
||||||
|
optionsCurrency.value = (
|
||||||
|
data.currency || DEFAULT_BILLING_CURRENCY
|
||||||
|
).toLowerCase();
|
||||||
|
selectDefaultOption();
|
||||||
|
} catch {
|
||||||
|
fetchError.value = true;
|
||||||
|
topupOptions.value = [];
|
||||||
|
} finally {
|
||||||
|
isFetchingOptions.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
currentStep.value = STEP_SELECT;
|
currentStep.value = STEP_SELECT;
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
selectedCredits.value = null;
|
||||||
dialogRef.value?.open();
|
dialogRef.value?.open();
|
||||||
|
fetchOptions();
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToConfirmStep = () => {
|
const goToConfirmStep = () => {
|
||||||
if (!selectedOption.value) return;
|
if (!selectedOption.value) return;
|
||||||
currentStep.value = STEP_CONFIRM;
|
currentStep.value = STEP_CONFIRM;
|
||||||
@@ -127,32 +149,58 @@ defineExpose({ open, close });
|
|||||||
:width="dialogWidth"
|
:width="dialogWidth"
|
||||||
:show-confirm-button="false"
|
:show-confirm-button="false"
|
||||||
:show-cancel-button="false"
|
:show-cancel-button="false"
|
||||||
@close="handleClose"
|
|
||||||
>
|
>
|
||||||
<!-- Step 1: Select Credits Package -->
|
<!-- Step 1: Select Credits Package -->
|
||||||
<template v-if="currentStep === 'select'">
|
<template v-if="currentStep === STEP_SELECT">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div
|
||||||
<CreditPackageCard
|
v-if="isFetchingOptions"
|
||||||
v-for="option in TOPUP_OPTIONS"
|
class="flex items-center justify-center gap-2 py-10"
|
||||||
:key="option.credits"
|
>
|
||||||
name="credit-package"
|
<Spinner />
|
||||||
:credits="option.credits"
|
<span class="text-sm text-n-slate-11">{{
|
||||||
:amount="option.amount"
|
$t('BILLING_SETTINGS.TOPUP.LOADING')
|
||||||
:currency="option.currency"
|
}}</span>
|
||||||
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
|
</div>
|
||||||
:is-selected="selectedCredits === option.credits"
|
|
||||||
@select="handlePackageSelect(option.credits)"
|
<div
|
||||||
|
v-else-if="fetchError"
|
||||||
|
class="flex flex-col items-center justify-center gap-3 py-10"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ $t('BILLING_SETTINGS.TOPUP.FETCH_ERROR') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
:label="$t('BILLING_SETTINGS.TOPUP.RETRY')"
|
||||||
|
@click="fetchOptions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
|
<template v-else>
|
||||||
<p class="text-sm text-n-slate-11">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<span class="font-semibold text-n-slate-12">{{
|
<CreditPackageCard
|
||||||
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
|
v-for="option in topupOptions"
|
||||||
}}</span>
|
:key="option.credits"
|
||||||
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
|
name="credit-package"
|
||||||
</p>
|
:credits="option.credits"
|
||||||
</div>
|
: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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Step 2: Confirm Purchase -->
|
<!-- Step 2: Confirm Purchase -->
|
||||||
@@ -178,7 +226,7 @@ defineExpose({ open, close });
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<!-- Step 1 Footer -->
|
<!-- Step 1 Footer -->
|
||||||
<div
|
<div
|
||||||
v-if="currentStep === 'select'"
|
v-if="currentStep === STEP_SELECT"
|
||||||
class="flex items-center justify-between w-full gap-3"
|
class="flex items-center justify-between w-full gap-3"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -192,7 +240,7 @@ defineExpose({ open, close });
|
|||||||
color="blue"
|
color="blue"
|
||||||
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="!selectedCredits"
|
:disabled="!selectedCredits || isFetchingOptions || fetchError"
|
||||||
@click="goToConfirmStep"
|
@click="goToConfirmStep"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -144,7 +144,20 @@ export const actions = {
|
|||||||
subscription: async ({ commit }) => {
|
subscription: async ({ commit }) => {
|
||||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
|
||||||
try {
|
try {
|
||||||
await EnterpriseAccountAPI.subscription();
|
const response = await EnterpriseAccountAPI.subscription();
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throwErrorMessage(error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectBillingCurrency: async ({ commit }, currency) => {
|
||||||
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
|
||||||
|
try {
|
||||||
|
await EnterpriseAccountAPI.selectBillingCurrency(currency);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throwErrorMessage(error);
|
throwErrorMessage(error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class AccountPolicy < ApplicationPolicy
|
|||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def select_billing_currency?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
def checkout?
|
def checkout?
|
||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
@@ -34,4 +38,8 @@ class AccountPolicy < ApplicationPolicy
|
|||||||
def topup_checkout?
|
def topup_checkout?
|
||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def topup_options?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ if resource.custom_attributes.present?
|
|||||||
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
|
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
|
||||||
json.subscription_status resource.custom_attributes['subscription_status']
|
json.subscription_status resource.custom_attributes['subscription_status']
|
||||||
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
|
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
|
||||||
|
json.billing_currency resource.billing_currency if resource.respond_to?(:billing_currency) && Enterprise::Billing::Currencies.enabled?
|
||||||
json.website resource.custom_attributes['website'] if resource.custom_attributes['website'].present?
|
json.website resource.custom_attributes['website'] if resource.custom_attributes['website'].present?
|
||||||
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
|
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
|
||||||
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
|
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
|
||||||
|
|||||||
@@ -253,6 +253,17 @@
|
|||||||
display_title: 'Cloud Plans'
|
display_title: 'Cloud Plans'
|
||||||
value:
|
value:
|
||||||
description: 'Config to store stripe plans for cloud'
|
description: 'Config to store stripe plans for cloud'
|
||||||
|
- name: CAPTAIN_TOPUP_OPTIONS
|
||||||
|
display_title: 'Captain Topup Options'
|
||||||
|
value: {}
|
||||||
|
description: 'Currency-keyed AI credit top-up packages, e.g. {"usd":[{"credits":1000,"amount":20.0}],"brl":[{"credits":1000,"amount":100.0}]}'
|
||||||
|
type: code
|
||||||
|
- name: ENABLE_MULTI_CURRENCY_BILLING
|
||||||
|
display_title: 'Enable Multi-currency Billing'
|
||||||
|
value: false
|
||||||
|
locked: false
|
||||||
|
description: 'Bill new accounts in their local currency (e.g. BRL) and show currency-aware credit top-ups; when off, everyone is billed in USD'
|
||||||
|
type: boolean
|
||||||
- name: CHATWOOT_CLOUD_PLAN_FEATURES
|
- name: CHATWOOT_CLOUD_PLAN_FEATURES
|
||||||
display_title: 'Planwise Features List'
|
display_title: 'Planwise Features List'
|
||||||
value:
|
value:
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ 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.
|
||||||
|
billing:
|
||||||
|
invalid_currency: Invalid billing currency
|
||||||
|
currency_locked: Billing currency cannot be changed after billing has been set up
|
||||||
topup:
|
topup:
|
||||||
credits_required: Credits amount is required
|
credits_required: Credits amount is required
|
||||||
invalid_credits: Invalid credits amount
|
invalid_credits: Invalid credits amount
|
||||||
|
|||||||
@@ -527,9 +527,11 @@ Rails.application.routes.draw do
|
|||||||
member do
|
member do
|
||||||
post :checkout
|
post :checkout
|
||||||
post :subscription
|
post :subscription
|
||||||
|
post :select_billing_currency
|
||||||
get :limits
|
get :limits
|
||||||
post :toggle_deletion
|
post :toggle_deletion
|
||||||
post :topup_checkout
|
post :topup_checkout
|
||||||
|
get :topup_options
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,13 +2,23 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
|||||||
include BillingHelper
|
include BillingHelper
|
||||||
before_action :fetch_account
|
before_action :fetch_account
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
|
before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options]
|
||||||
|
|
||||||
def subscription
|
def subscription
|
||||||
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
|
return render json: currency_selection_payload if @account.billing_currency_selection_required?
|
||||||
@account.update(custom_attributes: { is_creating_customer: true })
|
|
||||||
Enterprise::CreateStripeCustomerJob.perform_later(@account)
|
ensure_stripe_customer
|
||||||
end
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_billing_currency
|
||||||
|
return render_could_not_create_error(I18n.t('errors.billing.currency_locked')) if stripe_customer_id.present?
|
||||||
|
|
||||||
|
currency = Enterprise::Billing::Currencies.normalize(params[:currency])
|
||||||
|
return render_could_not_create_error(I18n.t('errors.billing.invalid_currency')) unless Enterprise::Billing::Currencies.supported?(currency)
|
||||||
|
|
||||||
|
@account.update!(custom_attributes: @account.custom_attributes.merge('billing_currency' => currency))
|
||||||
|
ensure_stripe_customer
|
||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -71,12 +81,32 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
|||||||
render_could_not_create_error(e.message)
|
render_could_not_create_error(e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def topup_options
|
||||||
|
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
|
||||||
|
render json: { id: @account.id, currency: @account.billing_currency, options: service.available_options }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_cloud_env
|
def check_cloud_env
|
||||||
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
|
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ensure_stripe_customer
|
||||||
|
return if stripe_customer_id.present? || @account.custom_attributes['is_creating_customer'].present?
|
||||||
|
|
||||||
|
@account.update!(custom_attributes: @account.custom_attributes.merge('is_creating_customer' => true))
|
||||||
|
Enterprise::CreateStripeCustomerJob.perform_later(@account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def currency_selection_payload
|
||||||
|
{
|
||||||
|
currency_selection_required: true,
|
||||||
|
currency_options: Enterprise::Billing::Currencies::SUPPORTED,
|
||||||
|
suggested_currency: Enterprise::Billing::Currencies.for_locale(@account.locale)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def default_limits
|
def default_limits
|
||||||
{
|
{
|
||||||
'conversation' => {},
|
'conversation' => {},
|
||||||
|
|||||||
@@ -68,6 +68,30 @@ module Enterprise::Account
|
|||||||
saml_settings&.saml_enabled? || false
|
saml_settings&.saml_enabled? || false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def billing_currency
|
||||||
|
# Feature off => everyone is billed in USD (legacy behaviour).
|
||||||
|
return Enterprise::Billing::Currencies::DEFAULT unless Enterprise::Billing::Currencies.enabled?
|
||||||
|
|
||||||
|
stored = custom_attributes&.dig('billing_currency')
|
||||||
|
return Enterprise::Billing::Currencies.normalize(stored) if Enterprise::Billing::Currencies.supported?(stored)
|
||||||
|
|
||||||
|
# Existing Stripe customers stay on USD (webhook backfills the real currency);
|
||||||
|
# only brand-new accounts infer from locale, so existing pt_BR users aren't charged BRL.
|
||||||
|
return Enterprise::Billing::Currencies::DEFAULT if custom_attributes&.dig('stripe_customer_id').present?
|
||||||
|
|
||||||
|
Enterprise::Billing::Currencies.for_locale(locale)
|
||||||
|
end
|
||||||
|
|
||||||
|
# New accounts whose locale maps to a non-USD currency get to pick USD or that
|
||||||
|
# currency before the Stripe customer is created; everyone else proceeds in USD.
|
||||||
|
def billing_currency_selection_required?
|
||||||
|
return false unless Enterprise::Billing::Currencies.enabled?
|
||||||
|
return false if custom_attributes&.dig('stripe_customer_id').present?
|
||||||
|
return false if Enterprise::Billing::Currencies.supported?(custom_attributes&.dig('billing_currency'))
|
||||||
|
|
||||||
|
Enterprise::Billing::Currencies.for_locale(locale) != Enterprise::Billing::Currencies::DEFAULT
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def sync_assignment_features
|
def sync_assignment_features
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Enterprise::Billing::CreateStripeCustomerService
|
class Enterprise::Billing::CreateStripeCustomerService
|
||||||
|
include BillingHelper
|
||||||
|
|
||||||
pattr_initialize [:account!]
|
pattr_initialize [:account!]
|
||||||
|
|
||||||
DEFAULT_QUANTITY = 2
|
DEFAULT_QUANTITY = 2
|
||||||
@@ -21,13 +23,22 @@ class Enterprise::Billing::CreateStripeCustomerService
|
|||||||
|
|
||||||
def prepare_customer_id
|
def prepare_customer_id
|
||||||
customer_id = account.custom_attributes['stripe_customer_id']
|
customer_id = account.custom_attributes['stripe_customer_id']
|
||||||
if customer_id.blank?
|
customer_id = Stripe::Customer.create(customer_params).id if customer_id.blank?
|
||||||
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
|
|
||||||
customer_id = customer.id
|
|
||||||
end
|
|
||||||
customer_id
|
customer_id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Only currencies that need a country override (e.g. BRL/PIX) set address/locale; usd keeps Stripe defaults.
|
||||||
|
def customer_params
|
||||||
|
params = { name: account.name, email: billing_email }
|
||||||
|
country = Enterprise::Billing::Currencies.country_for(account.billing_currency)
|
||||||
|
return params if country.blank?
|
||||||
|
|
||||||
|
params.merge(
|
||||||
|
address: { country: country },
|
||||||
|
preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(account.billing_currency)]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def default_quantity
|
def default_quantity
|
||||||
default_plan['default_quantity'] || DEFAULT_QUANTITY
|
default_plan['default_quantity'] || DEFAULT_QUANTITY
|
||||||
end
|
end
|
||||||
@@ -37,13 +48,11 @@ class Enterprise::Billing::CreateStripeCustomerService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_plan
|
def default_plan
|
||||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
@default_plan ||= Enterprise::Billing::PlanConfiguration.default_plan
|
||||||
@default_plan ||= installation_config.value.first
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def price_id
|
def price_id
|
||||||
price_ids = default_plan['price_ids']
|
Enterprise::Billing::PlanConfiguration.price_id_for(default_plan, account.billing_currency)
|
||||||
price_ids.first
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def active_subscription
|
def active_subscription
|
||||||
@@ -60,7 +69,7 @@ class Enterprise::Billing::CreateStripeCustomerService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_plan_subscription?(subscription)
|
def default_plan_subscription?(subscription)
|
||||||
default_plan['price_ids'].include?(subscription['plan']['id'])
|
Enterprise::Billing::PlanConfiguration.plan_contains_price_id?(default_plan, subscription['plan']['id'])
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_custom_attributes(customer_id, subscription)
|
def build_custom_attributes(customer_id, subscription)
|
||||||
@@ -71,14 +80,8 @@ class Enterprise::Billing::CreateStripeCustomerService
|
|||||||
'plan_name' => default_plan['name'],
|
'plan_name' => default_plan['name'],
|
||||||
'subscribed_quantity' => subscription['quantity'],
|
'subscribed_quantity' => subscription['quantity'],
|
||||||
'subscription_status' => subscription['status'],
|
'subscription_status' => subscription['status'],
|
||||||
'subscription_ends_on' => subscription_ends_on(subscription)
|
'subscription_ends_on' => subscription_ends_on(subscription),
|
||||||
|
'billing_currency' => account.billing_currency
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def subscription_ends_on(subscription)
|
|
||||||
period_end = subscription['current_period_end']
|
|
||||||
return if period_end.blank?
|
|
||||||
|
|
||||||
Time.zone.at(period_end)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Supported billing currencies and their Stripe/locale mappings.
|
||||||
|
module Enterprise::Billing::Currencies
|
||||||
|
DEFAULT = 'usd'.freeze
|
||||||
|
|
||||||
|
SUPPORTED = %w[usd brl].freeze
|
||||||
|
|
||||||
|
FEATURE_CONFIG = 'ENABLE_MULTI_CURRENCY_BILLING'.freeze
|
||||||
|
|
||||||
|
# Account locale label (e.g. 'pt_BR') => default currency; unlisted falls back to DEFAULT.
|
||||||
|
LOCALE_DEFAULTS = {
|
||||||
|
'pt_BR' => 'brl'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# Billing country override per currency; absent currencies (e.g. usd) keep Stripe's default.
|
||||||
|
COUNTRY_BY_CURRENCY = {
|
||||||
|
'brl' => 'BR'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# Preferred Stripe/checkout locale per currency; absent currencies keep Stripe's default.
|
||||||
|
PREFERRED_LOCALE_BY_CURRENCY = {
|
||||||
|
'brl' => 'pt-BR'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# Master switch for the whole multi-currency feature; off => everyone is billed in USD.
|
||||||
|
def enabled?
|
||||||
|
GlobalConfigService.load(FEATURE_CONFIG, 'false').to_s != 'false'
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize(code)
|
||||||
|
code.to_s.strip.downcase.presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def supported?(code)
|
||||||
|
SUPPORTED.include?(normalize(code))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Map arbitrary input to a supported code, else DEFAULT.
|
||||||
|
def to_supported(code)
|
||||||
|
supported?(code) ? normalize(code) : DEFAULT
|
||||||
|
end
|
||||||
|
|
||||||
|
def for_locale(locale)
|
||||||
|
LOCALE_DEFAULTS.fetch(locale.to_s, DEFAULT)
|
||||||
|
end
|
||||||
|
|
||||||
|
def country_for(code)
|
||||||
|
COUNTRY_BY_CURRENCY[to_supported(code)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def preferred_locale_for(code)
|
||||||
|
PREFERRED_LOCALE_BY_CURRENCY[to_supported(code)]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
class Enterprise::Billing::HandleStripeEventService
|
class Enterprise::Billing::HandleStripeEventService
|
||||||
|
include BillingHelper
|
||||||
|
|
||||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||||
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
|
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
|
||||||
|
|
||||||
@@ -61,11 +63,20 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
'plan_name' => plan['name'],
|
'plan_name' => plan['name'],
|
||||||
'subscribed_quantity' => subscription['quantity'],
|
'subscribed_quantity' => subscription['quantity'],
|
||||||
'subscription_status' => subscription['status'],
|
'subscription_status' => subscription['status'],
|
||||||
'subscription_ends_on' => Time.zone.at(subscription['current_period_end'])
|
'subscription_ends_on' => subscription_ends_on(subscription),
|
||||||
|
'billing_currency' => billing_currency_for(subscription, plan)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Paid subscriptions define the currency; the free/default plan keeps the stored preference.
|
||||||
|
def billing_currency_for(subscription, plan)
|
||||||
|
return account.billing_currency if plan['name'] == Enterprise::Billing::PlanConfiguration.default_plan&.dig('name')
|
||||||
|
|
||||||
|
_plan, inferred_currency = Enterprise::Billing::PlanConfiguration.find_plan_by_price_id(subscription['plan']['id'])
|
||||||
|
inferred_currency || account.billing_currency
|
||||||
|
end
|
||||||
|
|
||||||
def process_subscription_deleted
|
def process_subscription_deleted
|
||||||
# skipping self hosted plan events
|
# skipping self hosted plan events
|
||||||
return if account.blank?
|
return if account.blank?
|
||||||
@@ -140,8 +151,7 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
|
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_plan(plan_id)
|
def find_plan(product_id)
|
||||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(product_id)
|
||||||
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Resolves Stripe price ids from CHATWOOT_CLOUD_PLANS per currency.
|
||||||
|
# A plan's `price_ids` may be a currency-keyed Hash, or a legacy Array (treated as usd).
|
||||||
|
module Enterprise::Billing::PlanConfiguration
|
||||||
|
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def plans
|
||||||
|
InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_plan
|
||||||
|
plans.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def price_ids_by_currency(plan)
|
||||||
|
raw = plan && plan['price_ids']
|
||||||
|
case raw
|
||||||
|
when Hash then raw.transform_keys { |key| Enterprise::Billing::Currencies.normalize(key) }
|
||||||
|
when Array then { Enterprise::Billing::Currencies::DEFAULT => raw }
|
||||||
|
else {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Price id for `plan` in `currency`, falling back to usd then any configured price.
|
||||||
|
def price_id_for(plan, currency)
|
||||||
|
by_currency = price_ids_by_currency(plan)
|
||||||
|
code = Enterprise::Billing::Currencies.to_supported(currency)
|
||||||
|
|
||||||
|
(by_currency[code].presence ||
|
||||||
|
by_currency[Enterprise::Billing::Currencies::DEFAULT].presence ||
|
||||||
|
by_currency.values.flatten.compact).first
|
||||||
|
end
|
||||||
|
|
||||||
|
def plan_contains_price_id?(plan, price_id)
|
||||||
|
price_ids_by_currency(plan).values.flatten.compact.include?(price_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# [plan, currency] for a price id, else [nil, nil].
|
||||||
|
def find_plan_by_price_id(price_id)
|
||||||
|
plans.each do |plan|
|
||||||
|
price_ids_by_currency(plan).each do |currency, ids|
|
||||||
|
return [plan, currency] if ids.include?(price_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
[nil, nil]
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_plan_by_name(name)
|
||||||
|
plans.find { |plan| plan['name'] == name }
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_plan_by_product_id(product_id)
|
||||||
|
plans.find { |plan| Array(plan['product_id']).include?(product_id) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,15 +3,15 @@ class Enterprise::Billing::TopupCheckoutService
|
|||||||
|
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
TOPUP_OPTIONS = [
|
TOPUP_OPTIONS_CONFIG = 'CAPTAIN_TOPUP_OPTIONS'.freeze
|
||||||
{ 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!]
|
pattr_initialize [:account!]
|
||||||
|
|
||||||
|
# Topup packages for the account's billing currency (used by the controller).
|
||||||
|
def available_options
|
||||||
|
topup_options
|
||||||
|
end
|
||||||
|
|
||||||
def create_checkout_session(credits:)
|
def create_checkout_session(credits:)
|
||||||
topup_option = validate_and_find_topup_option(credits)
|
topup_option = validate_and_find_topup_option(credits)
|
||||||
charge_customer(topup_option, credits)
|
charge_customer(topup_option, credits)
|
||||||
@@ -100,6 +100,20 @@ class Enterprise::Billing::TopupCheckoutService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_topup_option(credits)
|
def find_topup_option(credits)
|
||||||
TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i }
|
topup_options.find { |opt| opt[:credits] == credits.to_i }
|
||||||
|
end
|
||||||
|
|
||||||
|
def topup_options
|
||||||
|
# Label rows with the currency they were configured under, so a DEFAULT fallback can't relabel USD amounts and undercharge.
|
||||||
|
options = configured_options
|
||||||
|
currency = options[account.billing_currency].present? ? account.billing_currency : Enterprise::Billing::Currencies::DEFAULT
|
||||||
|
rows = options[currency].presence || []
|
||||||
|
rows.map { |opt| { credits: opt['credits'].to_i, amount: opt['amount'].to_f, currency: currency } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def configured_options
|
||||||
|
config = InstallationConfig.find_by(name: TOPUP_OPTIONS_CONFIG)&.value
|
||||||
|
config = JSON.parse(config) if config.is_a?(String)
|
||||||
|
config || {}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -256,6 +256,14 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
|||||||
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
|
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
|
||||||
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
||||||
])
|
])
|
||||||
|
create(:installation_config, name: 'CAPTAIN_TOPUP_OPTIONS', value: {
|
||||||
|
'usd' => [
|
||||||
|
{ 'credits' => 1000, 'amount' => 20.0 },
|
||||||
|
{ 'credits' => 2500, 'amount' => 50.0 },
|
||||||
|
{ 'credits' => 6000, 'amount' => 100.0 },
|
||||||
|
{ 'credits' => 12_000, 'amount' => 200.0 }
|
||||||
|
]
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns unauthorized for unauthenticated user' do
|
it 'returns unauthorized for unauthenticated user' do
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
|||||||
subscribed_quantity: 2,
|
subscribed_quantity: 2,
|
||||||
plan_name: 'A Plan Name',
|
plan_name: 'A Plan Name',
|
||||||
subscription_status: 'active',
|
subscription_status: 'active',
|
||||||
subscription_ends_on: subscription_ends_on
|
subscription_ends_on: subscription_ends_on,
|
||||||
|
billing_currency: 'usd'
|
||||||
}.with_indifferent_access
|
}.with_indifferent_access
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -95,7 +96,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
|||||||
|
|
||||||
create_stripe_customer_service.new(account: account).perform
|
create_stripe_customer_service.new(account: account).perform
|
||||||
|
|
||||||
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
|
expect(Stripe::Customer).to have_received(:create).with(
|
||||||
|
{ name: account.name, email: admin1.email }
|
||||||
|
)
|
||||||
expect(Stripe::Subscription)
|
expect(Stripe::Subscription)
|
||||||
.to have_received(:create)
|
.to have_received(:create)
|
||||||
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
||||||
@@ -108,10 +111,27 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
|||||||
subscribed_quantity: 2,
|
subscribed_quantity: 2,
|
||||||
plan_name: 'A Plan Name',
|
plan_name: 'A Plan Name',
|
||||||
subscription_status: 'active',
|
subscription_status: 'active',
|
||||||
subscription_ends_on: subscription_ends_on
|
subscription_ends_on: subscription_ends_on,
|
||||||
|
billing_currency: 'usd'
|
||||||
}.with_indifferent_access
|
}.with_indifferent_access
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'sets the billing country override when the account currency requires it' do
|
||||||
|
with_modified_env ENABLE_MULTI_CURRENCY_BILLING: 'true' do
|
||||||
|
account.update!(custom_attributes: { billing_currency: 'brl' })
|
||||||
|
customer = double
|
||||||
|
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||||
|
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||||
|
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
|
||||||
|
|
||||||
|
create_stripe_customer_service.new(account: account).perform
|
||||||
|
|
||||||
|
expect(Stripe::Customer).to have_received(:create).with(
|
||||||
|
{ name: account.name, email: admin1.email, address: { country: 'BR' }, preferred_locales: ['pt-BR'] }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'when checking for existing subscriptions' do
|
describe 'when checking for existing subscriptions' do
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Enterprise::Billing::Currencies do
|
||||||
|
describe 'Brazilian Real (brl)' do
|
||||||
|
it 'is a supported currency' do
|
||||||
|
expect(described_class.supported?('brl')).to be(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'recognizes brl regardless of casing or surrounding whitespace' do
|
||||||
|
expect(described_class.supported?(' BRL ')).to be(true)
|
||||||
|
expect(described_class.normalize(' BRL ')).to eq('brl')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps brl when coercing to a supported code' do
|
||||||
|
expect(described_class.to_supported('BRL')).to eq('brl')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'defaults the pt_BR account locale to brl' do
|
||||||
|
expect(described_class.for_locale('pt_BR')).to eq('brl')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'maps brl to Brazil and the pt-BR checkout locale' do
|
||||||
|
expect(described_class.country_for('brl')).to eq('BR')
|
||||||
|
expect(described_class.preferred_locale_for('brl')).to eq('pt-BR')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to the usd default for unsupported input' do
|
||||||
|
expect(described_class.to_supported('eur')).to eq('usd')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not set a country override for usd customers' do
|
||||||
|
expect(described_class.country_for('usd')).to be_nil
|
||||||
|
expect(described_class.preferred_locale_for('usd')).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,6 +15,15 @@ describe Enterprise::Billing::TopupCheckoutService do
|
|||||||
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
create(:installation_config, name: 'CAPTAIN_TOPUP_OPTIONS', value: {
|
||||||
|
'usd' => [
|
||||||
|
{ 'credits' => 1000, 'amount' => 20.0 },
|
||||||
|
{ 'credits' => 2500, 'amount' => 50.0 },
|
||||||
|
{ 'credits' => 6000, 'amount' => 100.0 },
|
||||||
|
{ 'credits' => 12_000, 'amount' => 200.0 }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
account.update!(
|
account.update!(
|
||||||
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
|
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
|
||||||
limits: { 'captain_responses' => 500 }
|
limits: { 'captain_responses' => 500 }
|
||||||
|
|||||||
Reference in New Issue
Block a user