diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 2810410bd..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`); } diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 0b74f7f13..0e3341ef9 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -454,6 +454,10 @@ "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)" diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue index 8a27096f6..4adcc6b59 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -39,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 || {}; }); @@ -88,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(); @@ -107,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 @@ -128,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'); }; @@ -158,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')" > @@ -170,7 +188,29 @@ onMounted(handleBillingPageLogic); /> - + + + + + + {{ $t(getCurrencyConfig(code).i18nLabelKey) }} + + + + + + { 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 { diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 46634b4af..18b8216f7 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -23,6 +23,10 @@ class AccountPolicy < ApplicationPolicy @account_user.administrator? end + def select_billing_currency? + @account_user.administrator? + end + def checkout? @account_user.administrator? end diff --git a/config/locales/en.yml b/config/locales/en.yml index 826894466..1ff99bf4c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -163,6 +163,8 @@ en: invalid_token: Invalid or expired MFA token invalid_credentials: Invalid credentials or verification code feature_unavailable: MFA feature is not available. Please configure encryption keys. + billing: + invalid_currency: Invalid billing currency topup: credits_required: Credits amount is required invalid_credits: Invalid credits amount diff --git a/config/routes.rb b/config/routes.rb index 616675317..c4d3806c1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -526,6 +526,7 @@ Rails.application.routes.draw do member do post :checkout post :subscription + post :select_billing_currency get :limits post :toggle_deletion post :topup_checkout diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index f7c6ca403..53f95c2a8 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -5,10 +5,18 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options] def subscription - if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? - @account.update(custom_attributes: { is_creating_customer: true }) - Enterprise::CreateStripeCustomerJob.perform_later(@account) - end + return render json: currency_selection_payload if @account.billing_currency_selection_required? + + ensure_stripe_customer + head :no_content + end + + def select_billing_currency + 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 end @@ -82,6 +90,21 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud? 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 { 'conversation' => {}, diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index fe03e2636..fcfe90777 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -82,6 +82,16 @@ module Enterprise::Account 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 def sync_assignment_features