feat(billing): let accounts switch billing currency with a confirmation note

This commit is contained in:
Tanmay Deep Sharma
2026-06-03 11:27:25 +05:30
parent bbd575d8fa
commit f1bc3208e9
13 changed files with 384 additions and 2 deletions
@@ -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();
@@ -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.",
@@ -1,22 +1,32 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAlert } from 'dashboard/composables';
import { format } from 'date-fns';
import sessionStorage from 'shared/helpers/sessionStorage';
import {
SUPPORTED_BILLING_CURRENCIES,
DEFAULT_BILLING_CURRENCY,
getCurrencyConfig,
} from 'dashboard/constants/billing';
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import SwitchCurrencyDialog from './components/SwitchCurrencyDialog.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ButtonV4 from 'next/button/Button.vue';
const router = useRouter();
const { t } = useI18n();
const { currentAccount, isOnChatwootCloud } = useAccount();
const {
captainEnabled,
@@ -118,6 +128,55 @@ const handleBillingPageLogic = async () => {
}
};
const isSwitchingCurrency = computed(() => uiFlags.value.isSwitchingCurrency);
// Currency switching is rolled out to Brazil (pt_BR) accounts only for now.
const showCurrencyToggle = computed(
() => currentAccount.value?.locale === 'pt_BR'
);
const currentBillingCurrency = computed(() =>
(
customAttributes.value.billing_currency || DEFAULT_BILLING_CURRENCY
).toLowerCase()
);
const currencyTabs = computed(() =>
SUPPORTED_BILLING_CURRENCIES.map(code => ({
label: t(getCurrencyConfig(code).i18nLabelKey),
value: code,
}))
);
const activeCurrencyIndex = computed(() => {
const index = SUPPORTED_BILLING_CURRENCIES.indexOf(
currentBillingCurrency.value
);
return index === -1 ? 0 : index;
});
const pendingCurrency = ref(null);
const switchCurrencyDialogRef = ref(null);
const onSelectCurrency = tab => {
if (!tab?.value || tab.value === currentBillingCurrency.value) return;
if (isSwitchingCurrency.value) return;
pendingCurrency.value = tab.value;
switchCurrencyDialogRef.value?.open();
};
const onConfirmSwitchCurrency = async () => {
try {
await store.dispatch('accounts/switchBillingCurrency', {
currency: pendingCurrency.value,
});
switchCurrencyDialogRef.value?.close();
useAlert(t('BILLING_SETTINGS.CURRENCY.SUCCESS'));
} catch (error) {
useAlert(error.message || t('BILLING_SETTINGS.CURRENCY.ERROR'));
}
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
@@ -190,6 +249,23 @@ onMounted(handleBillingPageLogic);
/>
</div>
</BillingCard>
<BillingCard
v-if="showCurrencyToggle"
:title="$t('BILLING_SETTINGS.CURRENCY.TITLE')"
:description="$t('BILLING_SETTINGS.CURRENCY.DESCRIPTION')"
>
<template #action>
<div
:class="{ 'pointer-events-none opacity-60': isSwitchingCurrency }"
>
<TabBar
:tabs="currencyTabs"
:initial-active-tab="activeCurrencyIndex"
@tab-changed="onSelectCurrency"
/>
</div>
</template>
</BillingCard>
<BillingCard
v-if="captainEnabled"
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
@@ -263,6 +339,12 @@ onMounted(handleBillingPageLogic);
ref="purchaseCreditsModalRef"
@success="handleTopupSuccess"
/>
<SwitchCurrencyDialog
ref="switchCurrencyDialogRef"
:target-currency="pendingCurrency"
:is-loading="isSwitchingCurrency"
@confirm="onConfirmSwitchCurrency"
/>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,57 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import { getCurrencyConfig } from 'dashboard/constants/billing';
const props = defineProps({
targetCurrency: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const currencyLabel = computed(() =>
t(getCurrencyConfig(props.targetCurrency).i18nLabelKey)
);
const open = () => dialogRef.value?.open();
const close = () => dialogRef.value?.close();
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="$t('BILLING_SETTINGS.CURRENCY.CONFIRM.TITLE')"
:description="
$t('BILLING_SETTINGS.CURRENCY.CONFIRM.DESCRIPTION', {
currency: currencyLabel,
})
"
:confirm-button-label="
$t('BILLING_SETTINGS.CURRENCY.CONFIRM.CONFIRM_BUTTON')
"
:cancel-button-label="$t('BILLING_SETTINGS.CURRENCY.CONFIRM.CANCEL_BUTTON')"
:is-loading="isLoading"
@confirm="emit('confirm')"
>
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
<p class="text-sm text-n-amber-11">
{{ $t('BILLING_SETTINGS.CURRENCY.CONFIRM.WARNING') }}
</p>
</div>
</Dialog>
</template>
@@ -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 {
+4
View File
@@ -38,4 +38,8 @@ class AccountPolicy < ApplicationPolicy
def topup_options?
@account_user.administrator?
end
def switch_currency?
@account_user.administrator?
end
end
@@ -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?
+8
View File
@@ -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:
+1
View File
@@ -524,6 +524,7 @@ Rails.application.routes.draw do
post :toggle_deletion
post :topup_checkout
get :topup_options
post :switch_currency
end
end
end
@@ -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
@@ -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
@@ -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
@@ -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