From f1bc3208e9981a0b9351ab59da4453711a1ad689 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Tue, 2 Jun 2026 15:54:34 +0530 Subject: [PATCH] feat(billing): let accounts switch billing currency with a confirmation note --- .../dashboard/api/enterprise/account.js | 4 + .../dashboard/i18n/locale/en/settings.json | 17 ++ .../dashboard/settings/billing/Index.vue | 82 +++++++++ .../components/SwitchCurrencyDialog.vue | 57 ++++++ .../dashboard/store/modules/accounts.js | 15 ++ app/policies/account_policy.rb | 4 + .../api/v1/models/_account.json.jbuilder | 1 + config/locales/en.yml | 8 + config/routes.rb | 1 + .../enterprise/api/v1/accounts_controller.rb | 13 +- .../billing/handle_stripe_event_service.rb | 14 +- .../billing/switch_currency_service.rb | 169 ++++++++++++++++++ .../handle_stripe_event_service_spec.rb | 1 + 13 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/components/SwitchCurrencyDialog.vue create mode 100644 enterprise/app/services/enterprise/billing/switch_currency_service.rb diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 2810410bd..294f03435 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -32,6 +32,10 @@ class EnterpriseAccountAPI extends ApiClient { getTopupOptions() { return axios.get(`${this.url}topup_options`); } + + switchCurrency(currency) { + return axios.post(`${this.url}switch_currency`, { currency }); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 4ab02b635..e9dd6ce8b 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -447,6 +447,23 @@ "DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.", "BUTTON_TXT": "Go to the billing portal" }, + "CURRENCY": { + "TITLE": "Billing currency", + "DESCRIPTION": "Choose the currency used for your subscription and credit purchases.", + "OPTIONS": { + "USD": "US Dollar (USD)", + "BRL": "Brazilian Real (BRL)" + }, + "SUCCESS": "Billing currency updated successfully.", + "ERROR": "Failed to update billing currency. Please try again.", + "CONFIRM": { + "TITLE": "Switch billing currency?", + "DESCRIPTION": "You are about to switch your billing currency to {currency}.", + "WARNING": "Your current subscription will be cancelled and a new one will be started in the selected currency. Pricing and any remaining credits may differ. This cannot be undone automatically.", + "CONFIRM_BUTTON": "Switch currency", + "CANCEL_BUTTON": "Cancel" + } + }, "CAPTAIN": { "TITLE": "Captain", "DESCRIPTION": "Manage usage and credits for Captain AI.", diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue index bcfa46193..1637a54d1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -1,22 +1,32 @@ + + diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index 52e7b8213..f6e06f91e 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -19,6 +19,7 @@ const state = { isUpdating: false, isCheckoutInProcess: false, isFetchingLimits: false, + isSwitchingCurrency: false, }, }; @@ -142,6 +143,20 @@ export const actions = { } }, + switchBillingCurrency: async ({ commit, dispatch }, { currency }) => { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isSwitchingCurrency: true }); + try { + await EnterpriseAccountAPI.switchCurrency(currency); + // Refresh the account so custom_attributes.billing_currency and the + // subscription details reflect the new currency. + await dispatch('get', { silent: true }); + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isSwitchingCurrency: false }); + } + }, + limits: async ({ commit }) => { commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true }); try { diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 46634b4af..47b73b9a4 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -38,4 +38,8 @@ class AccountPolicy < ApplicationPolicy def topup_options? @account_user.administrator? end + + def switch_currency? + @account_user.administrator? + end end diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index 7506592ba..f3b7e0394 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -6,6 +6,7 @@ if resource.custom_attributes.present? json.subscribed_quantity resource.custom_attributes['subscribed_quantity'] json.subscription_status resource.custom_attributes['subscription_status'] json.subscription_ends_on resource.custom_attributes['subscription_ends_on'] + json.billing_currency resource.custom_attributes['billing_currency'] if resource.custom_attributes['billing_currency'].present? json.website resource.custom_attributes['website'] if resource.custom_attributes['website'].present? json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present? json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present? diff --git a/config/locales/en.yml b/config/locales/en.yml index b11a4fdae..e09d9481b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -166,6 +166,14 @@ en: plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first. stripe_customer_not_configured: Stripe customer not configured no_payment_method: No payment methods found. Please add a payment method before making a purchase. + billing: + currency_required: Currency is required + unsupported_currency: This currency is not supported + same_currency: This account is already billed in the selected currency + stripe_customer_not_configured: Stripe customer not configured + unknown_plan: Could not determine the current plan + currency_not_available_for_plan: The selected currency is not available for your current plan + no_payment_method: No payment methods found. Please add a payment method before switching currency. reports: date_range_too_long: Date range cannot exceed 6 months profile: diff --git a/config/routes.rb b/config/routes.rb index 65005e9a3..4fa950002 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -524,6 +524,7 @@ Rails.application.routes.draw do post :toggle_deletion post :topup_checkout get :topup_options + post :switch_currency end end end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index f7c6ca403..824c8b53e 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -2,7 +2,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController include BillingHelper before_action :fetch_account before_action :check_authorization - before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options] + before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options, :switch_currency] def subscription if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? @@ -76,6 +76,17 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController render json: { id: @account.id, currency: @account.billing_currency, options: service.available_options } end + def switch_currency + return render json: { error: I18n.t('errors.billing.currency_required') }, status: :unprocessable_entity if params[:currency].blank? + + Enterprise::Billing::SwitchCurrencyService.new(account: @account, currency: params[:currency]).perform + + @account.reload + render json: { id: @account.id, limits: @account.limits, custom_attributes: @account.custom_attributes } + rescue Enterprise::Billing::SwitchCurrencyService::Error, Stripe::StripeError => e + render_could_not_create_error(e.message) + end + private def check_cloud_env diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 927374c37..631847d50 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -12,7 +12,7 @@ class Enterprise::Billing::HandleStripeEventService @event = event case @event.type - when 'customer.subscription.updated' + when 'customer.subscription.created', 'customer.subscription.updated' process_subscription_updated when 'customer.subscription.deleted' process_subscription_deleted @@ -28,6 +28,10 @@ class Enterprise::Billing::HandleStripeEventService # skipping self hosted plan events return if plan.blank? || account.blank? + # A subscription tagged for a currency switch is being cancelled by + # SwitchCurrencyService, which writes the final state itself — ignore its + # interim webhook events so they don't overwrite the new currency. + return if currency_switch_cancellation? previous_usage = capture_previous_usage update_account_attributes(subscription, plan) @@ -77,9 +81,17 @@ class Enterprise::Billing::HandleStripeEventService inferred_currency || account.billing_currency end + def currency_switch_cancellation? + subscription['metadata'][Enterprise::Billing::SwitchCurrencyService::SWITCH_METADATA_KEY] == 'true' + end + def process_subscription_deleted # skipping self hosted plan events return if account.blank? + # A currency switch cancels the old subscription itself and creates the new + # one. Don't re-subscribe the default plan here — it would create a stray + # default-plan sub and block the new currency ("cannot combine currencies"). + return if currency_switch_cancellation? previous_monthly_credits = current_plan_credits[:responses] return unless Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform diff --git a/enterprise/app/services/enterprise/billing/switch_currency_service.rb b/enterprise/app/services/enterprise/billing/switch_currency_service.rb new file mode 100644 index 000000000..79d2d0d63 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/switch_currency_service.rb @@ -0,0 +1,169 @@ +class Enterprise::Billing::SwitchCurrencyService + include BillingHelper + + class Error < StandardError; end + + # Tagged on a subscription right before we cancel it for a currency switch, so + # the customer.subscription.deleted webhook knows NOT to re-subscribe the + # default plan (which would create a stray Hacker sub and block the new + # currency). Read by HandleStripeEventService#process_subscription_deleted. + SWITCH_METADATA_KEY = 'chatwoot_currency_switch'.freeze + + pattr_initialize [:account!, :currency!] + + def perform + validate! + + subscriptions = live_subscriptions + paid_subscription = subscriptions.find { |subscription| !default_price?(subscription) } + + if paid_subscription + switch_paid_plan(subscriptions, paid_subscription) + else + switch_free_plan + end + end + + private + + def target_currency + @target_currency ||= Enterprise::Billing::Currencies.normalize(currency) + end + + def validate! + raise Error, I18n.t('errors.billing.unsupported_currency') unless Enterprise::Billing::Currencies.supported?(currency) + raise Error, I18n.t('errors.billing.same_currency') if target_currency == account.billing_currency + raise Error, I18n.t('errors.billing.stripe_customer_not_configured') if stripe_customer_id.blank? + end + + # Free plan ($0): no subscription churn — just record the preference and keep + # the Stripe customer location/currency in sync for future upgrades/top-ups. + def switch_free_plan + sync_stripe_customer_location + persist_currency(account.custom_attributes.merge('billing_currency' => target_currency)) + end + + # Cancel every live subscription and create ONE subscription for the current + # paid plan in the target currency, preserving the seat count and the + # already-paid-through date. Decisions are based on the actual Stripe state + # (not stored attributes) so a messy/duplicated state converges to a single + # correct subscription. + def switch_paid_plan(subscriptions, paid_subscription) + validate_payment_method! + + plan = current_plan(paid_subscription) + raise Error, I18n.t('errors.billing.unknown_plan') if plan.blank? + + change = { + new_price_id: resolve_new_price_id(plan), + original_price_id: paid_subscription['plan']['id'], + quantity: paid_subscription['quantity'], + paid_through: subscriptions.filter_map { |subscription| subscription_period_end(subscription) }.max, + key: paid_subscription.id + } + + sync_stripe_customer_location + new_subscription = replace_subscriptions(subscriptions, change) + + persist_currency(build_custom_attributes(new_subscription, plan)) + Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform + end + + def resolve_new_price_id(plan) + target_prices = Enterprise::Billing::PlanConfiguration.price_ids_by_currency(plan)[target_currency] + raise Error, I18n.t('errors.billing.currency_not_available_for_plan') if target_prices.blank? + + target_prices.first + end + + def current_plan(subscription) + plan, = Enterprise::Billing::PlanConfiguration.find_plan_by_price_id(subscription['plan']['id']) + plan || Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(subscription['plan']['product']) + end + + # Cancel all current subscriptions (tagged so the deleted-webhook leaves them + # alone) and create the new-currency subscription. On failure, restore the + # original paid plan/currency so the account is never left without one. + # + # Stripe constraints (verified in test mode): + # - prorate:false — currencies can't combine on one customer; a proration + # item in the old currency would block the new sub. + # - trial_end = current paid-through date — preserves already-paid time (no + # money lost) and makes switching back and forth before the next cycle free + # (each switch just re-creates the sub trialing until the same date). + def replace_subscriptions(subscriptions, change) + cancel_subscriptions(subscriptions) + + begin + create_currency_subscription(change[:new_price_id], change, 'switch') + rescue Stripe::StripeError => e + create_currency_subscription(change[:original_price_id], change, 'switch-revert') + raise Error, e.message + end + end + + def cancel_subscriptions(subscriptions) + subscriptions.each do |subscription| + Stripe::Subscription.update(subscription.id, metadata: { SWITCH_METADATA_KEY => 'true' }) + Stripe::Subscription.cancel(subscription.id, { prorate: false }) + end + end + + def create_currency_subscription(price_id, change, key_prefix) + params = { customer: stripe_customer_id, items: [{ price: price_id, quantity: change[:quantity] }] } + params[:trial_end] = change[:paid_through] if change[:paid_through].present? && change[:paid_through] > Time.current.to_i + Stripe::Subscription.create(params, { idempotency_key: "#{key_prefix}-#{account.id}-#{change[:key]}" }) + end + + def build_custom_attributes(subscription, plan) + account.custom_attributes.merge( + 'billing_currency' => target_currency, + 'stripe_price_id' => subscription['plan']['id'], + 'stripe_product_id' => subscription['plan']['product'], + 'plan_name' => plan['name'], + 'subscribed_quantity' => subscription['quantity'], + 'subscription_status' => subscription['status'], + 'subscription_ends_on' => subscription_ends_on(subscription) + ) + end + + def default_price?(subscription) + Enterprise::Billing::PlanConfiguration.plan_contains_price_id?( + Enterprise::Billing::PlanConfiguration.default_plan, subscription['plan']['id'] + ) + end + + def persist_currency(custom_attributes) + account.update!(custom_attributes: custom_attributes) + end + + def sync_stripe_customer_location + Stripe::Customer.update( + stripe_customer_id, + address: { country: Enterprise::Billing::Currencies.country_for(target_currency) }, + preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(target_currency)] + ) + end + + # Active and trialing subscriptions. A prior currency switch leaves the new + # sub trialing until the carried-over paid-through date, so trialing must be + # included to switch again from that state. + def live_subscriptions + Stripe::Subscription.list(customer: stripe_customer_id, status: 'all', limit: 100).data + .select { |subscription| %w[active trialing past_due].include?(subscription.status) } + end + + def validate_payment_method! + customer = Stripe::Customer.retrieve(stripe_customer_id) + return if customer.invoice_settings.default_payment_method.present? || customer.default_source.present? + + payment_methods = Stripe::PaymentMethod.list(customer: stripe_customer_id, limit: 1) + raise Error, I18n.t('errors.billing.no_payment_method') if payment_methods.data.empty? + + Stripe::Customer.update(stripe_customer_id, invoice_settings: { default_payment_method: payment_methods.data.first.id }) + end + + def stripe_customer_id + account.custom_attributes['stripe_customer_id'] + end +end diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index f9b550ef8..b8040a78b 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -36,6 +36,7 @@ describe Enterprise::Billing::HandleStripeEventService do allow(subscription).to receive(:[]).with('quantity').and_return('10') allow(subscription).to receive(:[]).with('status').and_return('active') allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520) + allow(subscription).to receive(:[]).with('metadata').and_return({}) allow(subscription).to receive(:customer).and_return('cus_123') allow(event).to receive(:type).and_return('customer.subscription.updated') end