From 552d598060b14f1f270cc6022ce6481d5e0cc7e3 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 15 Jun 2026 14:42:34 +0530 Subject: [PATCH] refactor(billing): split currency switch into eligibility, price resolver, and executor --- .../services/enterprise/billing/currencies.rb | 5 - .../billing/currency_switch_eligibility.rb | 51 +++++ .../billing/handle_stripe_event_service.rb | 3 +- .../enterprise/billing/plan_configuration.rb | 4 + .../enterprise/billing/plan_price_resolver.rb | 36 ++++ .../stripe_currency_switch_executor.rb | 87 +++++++++ .../billing/switch_currency_service.rb | 175 ++++-------------- 7 files changed, 219 insertions(+), 142 deletions(-) create mode 100644 enterprise/app/services/enterprise/billing/currency_switch_eligibility.rb create mode 100644 enterprise/app/services/enterprise/billing/plan_price_resolver.rb create mode 100644 enterprise/app/services/enterprise/billing/stripe_currency_switch_executor.rb diff --git a/enterprise/app/services/enterprise/billing/currencies.rb b/enterprise/app/services/enterprise/billing/currencies.rb index a86fb64c6..50d60122e 100644 --- a/enterprise/app/services/enterprise/billing/currencies.rb +++ b/enterprise/app/services/enterprise/billing/currencies.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/currency_switch_eligibility.rb b/enterprise/app/services/enterprise/billing/currency_switch_eligibility.rb new file mode 100644 index 000000000..0c2bfe8b8 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/currency_switch_eligibility.rb @@ -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 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 46c78a5d8..c354e4223 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/plan_configuration.rb b/enterprise/app/services/enterprise/billing/plan_configuration.rb index 347fbc330..9334b9e14 100644 --- a/enterprise/app/services/enterprise/billing/plan_configuration.rb +++ b/enterprise/app/services/enterprise/billing/plan_configuration.rb @@ -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| diff --git a/enterprise/app/services/enterprise/billing/plan_price_resolver.rb b/enterprise/app/services/enterprise/billing/plan_price_resolver.rb new file mode 100644 index 000000000..03db18cf7 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/plan_price_resolver.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/stripe_currency_switch_executor.rb b/enterprise/app/services/enterprise/billing/stripe_currency_switch_executor.rb new file mode 100644 index 000000000..ff3bad9f7 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/stripe_currency_switch_executor.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/switch_currency_service.rb b/enterprise/app/services/enterprise/billing/switch_currency_service.rb index 6c8e67177..23fb7d607 100644 --- a/enterprise/app/services/enterprise/billing/switch_currency_service.rb +++ b/enterprise/app/services/enterprise/billing/switch_currency_service.rb @@ -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