refactor(billing): split currency switch into eligibility, price resolver, and executor
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user