feat: billing brl pix new users (#14617)
## Linear ticket - https://linear.app/chatwoot/issue/CW-7253/billing-brl-pix-new-users ## Description New accounts that sign up in Brazilian Portuguese are now billed in BRL instead of USD. Their Stripe customer is created with a Brazil address and Portuguese locale (so the Stripe portal offers Real prices and PIX), and the AI credit top-up flow shows packages priced in the account's billing currency. Currency support is config-driven, so adding another currency later is a configuration change rather than a code change. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - https://www.loom.com/share/c8d3d08c1b844ed6b820438d4209491a ## Screenshot <img width="904" height="440" alt="image" src="https://github.com/user-attachments/assets/6f19fad8-e6af-46ea-b99f-b0265bb9eeec" /> ## 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
This commit is contained in:
@@ -15,6 +15,8 @@ import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
import { getCurrencyConfig } from 'dashboard/constants/billing';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const router = useRouter();
|
||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||
@@ -29,6 +31,7 @@ const {
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
|
||||
@@ -36,6 +39,10 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
const isWaitingForBilling = ref(false);
|
||||
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(() => {
|
||||
return currentAccount.value.custom_attributes || {};
|
||||
});
|
||||
@@ -61,6 +68,13 @@ const subscribedQuantity = computed(() => {
|
||||
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(() => {
|
||||
if (!customAttributes.value.subscription_ends_on) return '';
|
||||
const endDate = new Date(customAttributes.value.subscription_ends_on);
|
||||
@@ -78,7 +92,9 @@ const hasABillingPlan = computed(() => {
|
||||
|
||||
const fetchAccountDetails = async () => {
|
||||
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
|
||||
fetchLimits();
|
||||
@@ -97,6 +113,9 @@ const handleBillingPageLogic = async () => {
|
||||
// If cloud user, fetch account details first
|
||||
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 (!hasABillingPlan.value) {
|
||||
// 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 = () => {
|
||||
store.dispatch('accounts/checkout');
|
||||
};
|
||||
@@ -148,7 +174,9 @@ onMounted(handleBillingPageLogic);
|
||||
? $t('BILLING_SETTINGS.NO_BILLING_USER')
|
||||
: $t('ATTRIBUTES_MGMT.LOADING')
|
||||
"
|
||||
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
|
||||
:no-records-found="
|
||||
!hasABillingPlan && !isWaitingForBilling && !currencySelectionRequired
|
||||
"
|
||||
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
|
||||
>
|
||||
<template #header>
|
||||
@@ -160,7 +188,30 @@ onMounted(handleBillingPageLogic);
|
||||
/>
|
||||
</template>
|
||||
<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"
|
||||
:disabled="uiFlags.isCheckoutInProcess"
|
||||
@click="onSelectCurrency(code)"
|
||||
>
|
||||
{{ $t(getCurrencyConfig(code).i18nLabelKey) }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</template>
|
||||
</BillingCard>
|
||||
</section>
|
||||
<section v-else class="grid gap-4">
|
||||
<BillingCard
|
||||
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
|
||||
@@ -188,6 +239,11 @@ onMounted(handleBillingPageLogic);
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
|
||||
:value="subscriptionRenewsOn"
|
||||
/>
|
||||
<DetailItem
|
||||
v-if="billingCurrency"
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.CURRENCY')"
|
||||
:value="billingCurrency"
|
||||
/>
|
||||
</div>
|
||||
</BillingCard>
|
||||
<BillingCard
|
||||
|
||||
+3
-5
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { formatCurrencyAmount } from 'dashboard/constants/billing';
|
||||
|
||||
defineProps({
|
||||
credits: {
|
||||
type: Number,
|
||||
@@ -33,11 +35,7 @@ const formatCredits = credits => {
|
||||
};
|
||||
|
||||
const formatAmount = (amount, currency) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
return formatCurrencyAmount(amount, currency, { minimumFractionDigits: 0 });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
+91
-43
@@ -4,20 +4,18 @@ 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 Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CreditPackageCard from './CreditPackageCard.vue';
|
||||
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 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 STEP_SELECT = 'select';
|
||||
const STEP_CONFIRM = 'confirm';
|
||||
@@ -27,16 +25,20 @@ const selectedCredits = ref(null);
|
||||
const isLoading = ref(false);
|
||||
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(() => {
|
||||
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
|
||||
return topupOptions.value.find(o => o.credits === selectedCredits.value);
|
||||
});
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
if (!selectedOption.value) return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: selectedOption.value.currency.toUpperCase(),
|
||||
}).format(selectedOption.value.amount);
|
||||
const { amount, currency } = selectedOption.value;
|
||||
return formatCurrencyAmount(amount, currency || optionsCurrency.value);
|
||||
});
|
||||
|
||||
const formattedCredits = computed(() => {
|
||||
@@ -64,24 +66,44 @@ const handlePackageSelect = credits => {
|
||||
selectedCredits.value = credits;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
const popularOption = TOPUP_OPTIONS.find(
|
||||
const selectDefaultOption = () => {
|
||||
const popularOption = topupOptions.value.find(
|
||||
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;
|
||||
isLoading.value = false;
|
||||
selectedCredits.value = null;
|
||||
dialogRef.value?.open();
|
||||
fetchOptions();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const goToConfirmStep = () => {
|
||||
if (!selectedOption.value) return;
|
||||
currentStep.value = STEP_CONFIRM;
|
||||
@@ -127,32 +149,58 @@ defineExpose({ open, close });
|
||||
:width="dialogWidth"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Step 1: Select Credits Package -->
|
||||
<template v-if="currentStep === 'select'">
|
||||
<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)"
|
||||
<template v-if="currentStep === STEP_SELECT">
|
||||
<div
|
||||
v-if="isFetchingOptions"
|
||||
class="flex items-center justify-center gap-2 py-10"
|
||||
>
|
||||
<Spinner />
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
$t('BILLING_SETTINGS.TOPUP.LOADING')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<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 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 v-else>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<CreditPackageCard
|
||||
v-for="option in topupOptions"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Confirm Purchase -->
|
||||
@@ -178,7 +226,7 @@ defineExpose({ open, close });
|
||||
<template #footer>
|
||||
<!-- Step 1 Footer -->
|
||||
<div
|
||||
v-if="currentStep === 'select'"
|
||||
v-if="currentStep === STEP_SELECT"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
@@ -192,7 +240,7 @@ defineExpose({ open, close });
|
||||
color="blue"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
||||
class="w-full"
|
||||
:disabled="!selectedCredits"
|
||||
:disabled="!selectedCredits || isFetchingOptions || fetchError"
|
||||
@click="goToConfirmStep"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user