From 11deffdd5de353c0112cceddede42f6c2ea849d7 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:35:38 +0530 Subject: [PATCH] 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 image ## 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 --- app/helpers/billing_helper.rb | 12 ++ .../dashboard/api/enterprise/account.js | 9 ++ app/javascript/dashboard/constants/billing.js | 35 +++++ .../dashboard/i18n/locale/en/settings.json | 14 +- .../dashboard/settings/billing/Index.vue | 62 +++++++- .../billing/components/CreditPackageCard.vue | 8 +- .../components/PurchaseCreditsModal.vue | 134 ++++++++++++------ .../dashboard/store/modules/accounts.js | 15 +- app/policies/account_policy.rb | 8 ++ .../api/v1/models/_account.json.jbuilder | 1 + config/installation_config.yml | 11 ++ config/locales/en.yml | 3 + config/routes.rb | 2 + .../enterprise/api/v1/accounts_controller.rb | 47 +++++- enterprise/app/models/enterprise/account.rb | 24 ++++ .../billing/create_stripe_customer_service.rb | 39 +++-- .../services/enterprise/billing/currencies.rb | 55 +++++++ .../billing/handle_stripe_event_service.rb | 20 ++- .../enterprise/billing/plan_configuration.rb | 46 ++++++ .../billing/topup_checkout_service.rb | 28 +++- .../api/v1/accounts_controller_spec.rb | 8 ++ .../create_stripe_customer_service_spec.rb | 26 +++- .../enterprise/billing/currencies_spec.rb | 36 +++++ .../billing/topup_checkout_service_spec.rb | 9 ++ 24 files changed, 562 insertions(+), 90 deletions(-) create mode 100644 app/javascript/dashboard/constants/billing.js create mode 100644 enterprise/app/services/enterprise/billing/currencies.rb create mode 100644 enterprise/app/services/enterprise/billing/plan_configuration.rb create mode 100644 spec/enterprise/services/enterprise/billing/currencies_spec.rb diff --git a/app/helpers/billing_helper.rb b/app/helpers/billing_helper.rb index e2ada7e86..7545b6d8f 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -22,4 +22,16 @@ module BillingHelper def agents(account) account.users.count 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 diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 9e6d40a62..03456a288 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -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(); diff --git a/app/javascript/dashboard/constants/billing.js b/app/javascript/dashboard/constants/billing.js new file mode 100644 index 000000000..d372330f8 --- /dev/null +++ b/app/javascript/dashboard/constants/billing.js @@ -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); +}; diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 640b9c506..4caca05fb 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -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": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue index bcfa46193..521fa654b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -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')" >