refactor(billing): split currency switch into eligibility, price resolver, and executor

This commit is contained in:
Tanmay Deep Sharma
2026-06-15 14:42:34 +05:30
parent b2dbdb908a
commit 552d598060
7 changed files with 219 additions and 142 deletions
@@ -38,11 +38,6 @@ module Enterprise::Billing::Currencies
LOCALE_DEFAULTS.fetch(locale.to_s, DEFAULT)
end
# Currency switching is rolled out only to locales with a non-default currency (e.g. pt_BR).
def rollout_enabled?(locale)
LOCALE_DEFAULTS.key?(locale.to_s)
end
def country_for(code)
COUNTRY_BY_CURRENCY[to_supported(code)]
end
@@ -0,0 +1,51 @@
# Validates that an account may switch billing currency and returns the single switchable
# subscription. Performs no Stripe mutations, so a rejected switch never touches billing state.
class Enterprise::Billing::CurrencySwitchEligibility
class Error < StandardError; end
# Stripe statuses that are done and can't reactivate — ignored when looking for the live subscription.
TERMINAL_STATUSES = %w[canceled incomplete_expired].freeze
# Healthy statuses that may switch currency; trialing covers a sub left trialing by a prior paid switch.
SWITCHABLE_STATUSES = %w[active trialing].freeze
pattr_initialize [:account!, :currency!]
# Returns the one live, switchable subscription (paid or default plan); raises otherwise.
def subscription!
validate!
eligible_subscription!
end
private
def validate!
raise Error, I18n.t('errors.billing.currency_switch_unavailable') unless account.feature_enabled?('billing_currency_switch')
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
# Exactly one live subscription in a switchable state. Anything else (pending or extra) is rejected
# up front so we never mutate Stripe for an edge case.
def eligible_subscription!
subscription = live_subscriptions.first
eligible = live_subscriptions.one? && SWITCHABLE_STATUSES.include?(subscription&.status)
raise Error, I18n.t('errors.billing.switch_requires_active_subscription') unless eligible
subscription
end
def target_currency
@target_currency ||= Enterprise::Billing::Currencies.normalize(currency)
end
def stripe_customer_id
account.custom_attributes['stripe_customer_id']
end
def live_subscriptions
@live_subscriptions ||= Stripe::Subscription.list(customer: stripe_customer_id, status: 'all', limit: 100)
.data.reject { |subscription| TERMINAL_STATUSES.include?(subscription.status) }
end
end
@@ -67,7 +67,8 @@ class Enterprise::Billing::HandleStripeEventService
'subscription_status' => subscription['status'],
'subscription_ends_on' => subscription_ends_on(subscription),
'billing_currency' => billing_currency_for(subscription, plan)
)
# Reconciling from Stripe is the final word on a currency switch — drop any in-flight marker.
).except(Enterprise::Billing::SwitchCurrencyService::PENDING_CURRENCY_KEY)
)
end
@@ -36,6 +36,10 @@ module Enterprise::Billing::PlanConfiguration
price_ids_by_currency(plan).values.flatten.compact.include?(price_id)
end
def default_price?(price_id)
plan_contains_price_id?(default_plan, price_id)
end
# [plan, currency] for a price id, else [nil, nil].
def find_plan_by_price_id(price_id)
plans.each do |plan|
@@ -0,0 +1,36 @@
# Resolves the current plan and the target-currency price id for a currency switch.
class Enterprise::Billing::PlanPriceResolver
class Error < StandardError; end
pattr_initialize [:subscription!, :target_currency!]
def plan
@plan ||= resolve_plan
end
def target_price_id
by_currency = Enterprise::Billing::PlanConfiguration.price_ids_by_currency(plan)
target_prices = by_currency[target_currency]
raise Error, I18n.t('errors.billing.currency_not_available_for_plan') if target_prices.blank?
# Map by the current price's position within its own currency, so monthly->monthly / annual->annual
# instead of always landing on the first configured price (which could change the customer's cadence).
source_prices = by_currency.values.find { |ids| ids.include?(current_price_id) } || []
index = source_prices.index(current_price_id) || 0
target_prices[index] || target_prices.first
end
private
def current_price_id
subscription['plan']['id']
end
def resolve_plan
plan, = Enterprise::Billing::PlanConfiguration.find_plan_by_price_id(current_price_id)
plan ||= Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(subscription['plan']['product'])
raise Error, I18n.t('errors.billing.unknown_plan') if plan.blank?
plan
end
end
@@ -0,0 +1,87 @@
# Performs the Stripe-side currency switch: sync the customer location, create the new-currency
# subscription, then cancel the old one. Stripe can't change a subscription's currency in place and
# can't prorate across currencies, so the switch is a cancel + recreate. Creating the new sub *before*
# cancelling the old one means any failure leaves the customer on their original subscription rather
# than with none — the whole operation self-reverts.
class Enterprise::Billing::StripeCurrencySwitchExecutor
class Error < StandardError; end
pattr_initialize [:account!, :target_currency!]
# Returns the newly-created Stripe subscription.
def execute(subscription:, change:)
validate_payment_method! unless Enterprise::Billing::PlanConfiguration.default_price?(subscription['plan']['id'])
previous_currency = account.billing_currency
sync_customer_location(target_currency)
begin
replace_subscription(subscription, change)
rescue StandardError
# The subscription swap reverted to the old currency — undo the customer location change too.
sync_customer_location(previous_currency)
raise
end
end
private
def replace_subscription(subscription, change)
new_subscription = create_currency_subscription(change[:new_price_id], change)
cancel_old_subscription(subscription, new_subscription)
new_subscription
rescue Stripe::StripeError => e
raise Error, e.message
end
def cancel_old_subscription(old_subscription, new_subscription)
cancel_subscription(old_subscription)
rescue Stripe::StripeError
# Couldn't retire the old sub: cancel the just-created one so the customer keeps a single subscription.
Stripe::Subscription.cancel(new_subscription.id, { prorate: false })
raise
end
def cancel_subscription(subscription)
Stripe::Subscription.update(subscription.id, metadata: { Enterprise::Billing::SwitchCurrencyService::SWITCH_METADATA_KEY => 'true' })
Stripe::Subscription.cancel(subscription.id, { prorate: false })
rescue Stripe::StripeError
# Clear the flag so a still-live sub isn't permanently skipped by the webhook guard.
Stripe::Subscription.update(subscription.id, metadata: { Enterprise::Billing::SwitchCurrencyService::SWITCH_METADATA_KEY => '' })
raise
end
def create_currency_subscription(price_id, change)
params = { customer: stripe_customer_id, items: [{ price: price_id, quantity: change[:quantity] }] }
# trial_end preserves the already-paid time so switching mid-cycle doesn't double-charge.
params[:trial_end] = change[:paid_through] if change[:paid_through].present? && change[:paid_through] > Time.current.to_i
Stripe::Subscription.create(params, { idempotency_key: "switch-#{account.id}-#{change[:key]}" })
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
# Only currencies that need a country override (e.g. BRL/PIX) push an address/locale to Stripe;
# usd keeps Stripe's defaults, matching how the customer is first created.
def sync_customer_location(currency_code)
country = Enterprise::Billing::Currencies.country_for(currency_code)
return if country.blank?
Stripe::Customer.update(
stripe_customer_id,
address: { country: country },
preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(currency_code)]
)
end
def stripe_customer_id
account.custom_attributes['stripe_customer_id']
end
end
@@ -1,3 +1,9 @@
# Orchestrates a billing currency switch:
# eligibility (no mutation) -> resolve target price -> mark pending -> Stripe swap (self-reverting)
# -> persist local state (last). Each concern lives in its own collaborator so this stays a thin
# coordinator. Any failure aborts before persisting, so Chatwoot is never left ahead of Stripe; the
# rare window where Stripe succeeds but the local persist fails is reconciled by the subscription
# webhook, which also clears the pending marker.
class Enterprise::Billing::SwitchCurrencyService
include BillingHelper
@@ -6,121 +12,55 @@ class Enterprise::Billing::SwitchCurrencyService
# Tags a cancelled sub so the deleted-webhook skips re-subscribing the default plan.
SWITCH_METADATA_KEY = 'chatwoot_currency_switch'.freeze
# Stripe statuses that are done and can't reactivate — ignored when checking switch eligibility.
TERMINAL_STATUSES = %w[canceled incomplete_expired].freeze
# Healthy statuses that may switch currency; trialing covers a sub left trialing by a prior paid switch.
SWITCHABLE_STATUSES = %w[active trialing].freeze
# Records the in-flight target currency so a crash mid-switch is visible; cleared on success or by
# the subscription webhook once it reconciles the final state from Stripe.
PENDING_CURRENCY_KEY = 'billing_currency_switch_pending'.freeze
pattr_initialize [:account!, :currency!]
# Only the simple happy path is allowed: exactly one active subscription (paid or default plan),
# nothing else pending. Everything else is rejected up front so we never mutate Stripe for an edge case.
# Order: validate (no mutation) -> idempotent customer sync -> subscription replacement (self-reverting)
# -> local DB persist (last, alone), so any failure aborts cleanly without leaving split state.
def perform
validate!
subscription = eligible_active_subscription!
plan = resolve_plan!(subscription)
change = change_for(subscription, plan)
subscription = eligibility.subscription!
resolver = Enterprise::Billing::PlanPriceResolver.new(subscription: subscription, target_currency: target_currency)
change = change_for(subscription, resolver.target_price_id)
# Default plan is free, so it needs no payment method; paid plans must have one to bill the new sub.
validate_payment_method! unless default_price?(subscription)
sync_stripe_customer_location
mark_pending
new_subscription = executor.execute(subscription: subscription, change: change)
begin
new_subscription = replace_subscription(subscription, change)
rescue StandardError
# Replacement failed and reverted to the old currency — undo the customer location change too.
restore_customer_location
raise
end
persist_currency(build_custom_attributes(new_subscription, plan))
persist_currency(build_custom_attributes(new_subscription, resolver.plan))
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
rescue Enterprise::Billing::CurrencySwitchEligibility::Error,
Enterprise::Billing::PlanPriceResolver::Error,
Enterprise::Billing::StripeCurrencySwitchExecutor::Error => e
# The Stripe swap self-reverted, so drop the pending marker and surface a single error type.
clear_pending
raise Error, e.message
end
private
def eligibility
@eligibility ||= Enterprise::Billing::CurrencySwitchEligibility.new(account: account, currency: currency)
end
def executor
@executor ||= Enterprise::Billing::StripeCurrencySwitchExecutor.new(account: account, target_currency: target_currency)
end
def target_currency
@target_currency ||= Enterprise::Billing::Currencies.normalize(currency)
end
def validate!
raise Error, I18n.t('errors.billing.currency_switch_unavailable') unless Enterprise::Billing::Currencies.rollout_enabled?(account.locale)
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
# Exactly one live subscription in a switchable state (paid or default plan). Anything else (pending or extra) is rejected.
def eligible_active_subscription!
subscription = live_subscriptions.first
eligible = live_subscriptions.one? && SWITCHABLE_STATUSES.include?(subscription.status)
raise Error, I18n.t('errors.billing.switch_requires_active_subscription') unless eligible
subscription
end
def resolve_plan!(subscription)
plan = current_plan(subscription)
raise Error, I18n.t('errors.billing.unknown_plan') if plan.blank?
plan
end
def change_for(subscription, plan)
def change_for(subscription, new_price_id)
{
new_price_id: resolve_new_price_id(plan),
original_price_id: subscription['plan']['id'],
new_price_id: new_price_id,
quantity: subscription['quantity'],
# Paid plans preserve paid-through (new sub trials until then); the free default plan switches
# immediately to an active sub, so a default-plan account can switch again any time.
paid_through: default_price?(subscription) ? nil : subscription_period_end(subscription),
paid_through: Enterprise::Billing::PlanConfiguration.default_price?(subscription['plan']['id']) ? nil : subscription_period_end(subscription),
key: subscription.id
}
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 the old sub, create the new-currency sub; revert to the original plan on failure.
# prorate:false (Stripe can't mix currencies); trial_end keeps the already-paid time.
def replace_subscription(subscription, change)
cancel_subscription(subscription)
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_subscription(subscription)
Stripe::Subscription.update(subscription.id, metadata: { SWITCH_METADATA_KEY => 'true' })
Stripe::Subscription.cancel(subscription.id, { prorate: false })
rescue Stripe::StripeError
# Clear the flag so a still-live sub isn't permanently skipped by the webhook guard.
Stripe::Subscription.update(subscription.id, metadata: { SWITCH_METADATA_KEY => '' })
raise
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,
@@ -133,52 +73,15 @@ class Enterprise::Billing::SwitchCurrencyService
)
end
def default_price?(subscription)
Enterprise::Billing::PlanConfiguration.plan_contains_price_id?(
Enterprise::Billing::PlanConfiguration.default_plan, subscription['plan']['id']
)
def mark_pending
account.update!(custom_attributes: account.custom_attributes.merge(PENDING_CURRENCY_KEY => target_currency))
end
def clear_pending
account.update!(custom_attributes: account.custom_attributes.except(PENDING_CURRENCY_KEY))
end
def persist_currency(custom_attributes)
account.update!(custom_attributes: custom_attributes)
end
def sync_stripe_customer_location
update_customer_location(target_currency)
end
# Revert the customer to its current (old) currency location; account.billing_currency is still the old one here.
def restore_customer_location
update_customer_location(account.billing_currency)
end
def update_customer_location(currency_code)
Stripe::Customer.update(
stripe_customer_id,
address: { country: Enterprise::Billing::Currencies.country_for(currency_code) },
preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(currency_code)]
)
end
def all_subscriptions
@all_subscriptions ||= Stripe::Subscription.list(customer: stripe_customer_id, status: 'all', limit: 100).data
end
def live_subscriptions
@live_subscriptions ||= all_subscriptions.reject { |subscription| TERMINAL_STATUSES.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']
account.update!(custom_attributes: custom_attributes.except(PENDING_CURRENCY_KEY))
end
end