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')" >