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:
Tanmay Deep Sharma
2026-07-06 16:35:38 +05:30
committed by GitHub
parent 8818d276b9
commit 11deffdd5d
24 changed files with 562 additions and 90 deletions
@@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}subscription`);
}
selectBillingCurrency(currency) {
return axios.post(`${this.url}select_billing_currency`, { currency });
}
getLimits() {
return axios.get(`${this.url}limits`);
}
@@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(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();
@@ -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);
};
@@ -467,7 +467,18 @@
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
"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",
"MANAGE_SUBSCRIPTION": {
@@ -503,6 +514,7 @@
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"RETRY": "Retry",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
@@ -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
@@ -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>
@@ -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>
@@ -144,7 +144,20 @@ export const actions = {
subscription: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
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) {
throwErrorMessage(error);
} finally {