make codebase a bit more modular

This commit is contained in:
Tanmay Deep Sharma
2025-11-06 03:32:43 +05:30
parent d38bd5139e
commit 7418882a4f
18 changed files with 284 additions and 773 deletions
@@ -0,0 +1,14 @@
module Enterprise::Billing::Concerns::BillingIntentWorkflow
extend ActiveSupport::Concern
private
# Execute a billing intent with automatic reserve and commit
def execute_billing_intent(intent_params)
intent = create_billing_intent(intent_params)
reserve_billing_intent(intent)
yield(intent) if block_given?
commit_billing_intent(intent)
intent
end
end
@@ -0,0 +1,24 @@
module Enterprise::Billing::Concerns::PlanDataHelper
extend ActiveSupport::Concern
private
def fetch_plan_version(plan_id)
plan = retrieve_pricing_plan(plan_id)
version = extract_attribute(plan, :latest_version)
raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank?
version
end
def fetch_plan_lookup_key(plan_id)
lookup_key = Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id)
raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless lookup_key
lookup_key
end
def plan_display_name(plan_id)
Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)&.dig(:display_name) || 'Unknown Plan'
end
end
@@ -1,5 +1,6 @@
module Enterprise::Billing::Concerns::ProrationLineItemBuilder
extend ActiveSupport::Concern
include Enterprise::Billing::Concerns::PlanDataHelper
private
@@ -32,10 +33,6 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder
line_items
end
def plan_display_name(plan_id)
Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)&.dig(:display_name) || 'Unknown Plan'
end
def build_credit_line_item(context, proration_data, old_plan_name)
{
amount: -(proration_data[:credit_amount] * 100).to_i,
@@ -60,26 +57,6 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder
"Prorated charge for #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})"
end
def credit_metadata(plan_name, quantity, days_remaining)
{
type: 'proration_credit',
old_plan: plan_name,
old_quantity: quantity,
days_remaining: days_remaining,
billing_version: 'v2'
}
end
def charge_metadata(plan_name, quantity, days_remaining)
{
type: 'proration_charge',
new_plan: plan_name,
new_quantity: quantity,
days_remaining: days_remaining,
billing_version: 'v2'
}
end
def build_seat_change_description(context)
plan_name = plan_display_name(context[:target_plan_id])
change_type = context[:target_quantity] > context[:old_quantity] ? 'increase' : 'decrease'
@@ -89,15 +66,27 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder
"seats (#{quantity_diff} seat#{quantity_diff > 1 ? 's' : ''})"
end
def build_seat_change_metadata(context, proration_data)
def base_metadata(type, days_remaining, **additional_fields)
{
type: 'seat_change',
plan_name: plan_display_name(context[:target_plan_id]),
old_quantity: context[:old_quantity],
new_quantity: context[:target_quantity],
quantity_change: context[:target_quantity] - context[:old_quantity],
days_remaining: proration_data[:days_remaining],
type: type,
days_remaining: days_remaining,
billing_version: 'v2'
}
}.merge(additional_fields)
end
def credit_metadata(plan_name, quantity, days_remaining)
base_metadata('proration_credit', days_remaining, old_plan: plan_name, old_quantity: quantity)
end
def charge_metadata(plan_name, quantity, days_remaining)
base_metadata('proration_charge', days_remaining, new_plan: plan_name, new_quantity: quantity)
end
def build_seat_change_metadata(context, proration_data)
base_metadata('seat_change', proration_data[:days_remaining],
plan_name: plan_display_name(context[:target_plan_id]),
old_quantity: context[:old_quantity],
new_quantity: context[:target_quantity],
quantity_change: context[:target_quantity] - context[:old_quantity])
end
end
@@ -0,0 +1,41 @@
module Enterprise::Billing::Concerns::SubscriptionDataManager
extend ActiveSupport::Concern
private
def fetch_subscription_metadata
subscription_id = fetch_subscription_id
subscription = retrieve_pricing_plan_subscription(subscription_id)
cadence_id = extract_cadence_id(subscription)
cadence = retrieve_billing_cadence(cadence_id)
next_billing_date = extract_attribute(cadence, :next_billing_date)
store_next_billing_date(next_billing_date)
{
subscription_id: subscription_id,
subscription: subscription,
cadence_id: cadence_id,
cadence: cadence,
next_billing_date: next_billing_date
}
end
def fetch_subscription_id
subscription_id = custom_attribute('stripe_subscription_id')
raise StandardError, 'No pricing plan subscription ID found' if subscription_id.blank?
subscription_id
end
def extract_cadence_id(subscription)
cadence_id = extract_attribute(subscription, :billing_cadence)
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
cadence_id
end
def store_next_billing_date(next_billing_date)
update_custom_attributes({ 'next_billing_date' => next_billing_date })
end
end
@@ -74,4 +74,21 @@ class Enterprise::Billing::V2::BaseService
def with_locked_account(&)
account.with_lock(&)
end
# Convenient accessors for common attributes
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
def stripe_subscription_id
custom_attribute('stripe_subscription_id')
end
def pricing_plan_id
custom_attribute('stripe_pricing_plan_id')
end
def subscribed_quantity
custom_attribute('subscribed_quantity').to_i
end
end
@@ -1,6 +1,8 @@
class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::StripeV2ClientHelper
include Enterprise::Billing::Concerns::BillingIntentWorkflow
include Enterprise::Billing::Concerns::SubscriptionDataManager
# Cancel subscription using Stripe's V2 Billing Intent API
# Creates a deactivate billing intent for the pricing plan subscription
@@ -10,12 +12,11 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
#
def cancel_subscription
with_locked_account do
billing_intent = create_deactivate_intent
reserve_billing_intent(billing_intent)
commit_billing_intent(billing_intent)
update_account_status(billing_intent)
success_response = build_success_response(billing_intent)
success_response
metadata = fetch_subscription_metadata
intent_params = build_deactivate_params(metadata[:subscription_id], metadata[:cadence_id])
execute_billing_intent(intent_params)
update_account_status(metadata[:next_billing_date])
build_success_response
end
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
@@ -25,35 +26,6 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
private
def create_deactivate_intent
pricing_plan_subscription_id = fetch_subscription_id
billing_cadence_id = fetch_billing_cadence_id(pricing_plan_subscription_id)
store_next_billing_date(billing_cadence_id)
create_billing_intent(
build_deactivate_params(pricing_plan_subscription_id, billing_cadence_id)
)
end
def fetch_subscription_id
custom_attribute('stripe_subscription_id').tap do |id|
raise StandardError, 'No pricing plan subscription ID found' if id.blank?
end
end
def fetch_billing_cadence_id(subscription_id)
subscription = retrieve_pricing_plan_subscription(subscription_id)
extract_attribute(subscription, :billing_cadence).tap do |cadence_id|
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
end
end
def store_next_billing_date(cadence_id)
cadence = retrieve_billing_cadence(cadence_id)
Rails.logger.info("Cadence: #{cadence}")
@next_billing_date = extract_attribute(cadence, :next_billing_date)
end
def build_deactivate_params(subscription_id, cadence_id)
{
cadence: cadence_id,
@@ -68,17 +40,17 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
}
end
def update_account_status(_billing_intent)
def update_account_status(next_billing_date)
# Mark subscription as cancelling (will be cancelled at period end)
# Store next_billing_date so the UI can show when the subscription ends
update_custom_attributes({
'subscription_status' => 'cancel_at_period_end',
'subscription_cancelled_at' => Time.current.iso8601,
'subscription_ends_at' => @next_billing_date
'subscription_ends_at' => next_billing_date
})
end
def build_success_response(_billing_intent)
def build_success_response
{
success: true,
cancel_at_period_end: true,
@@ -3,15 +3,10 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
include Enterprise::Billing::Concerns::ProrationLineItemBuilder
include Enterprise::Billing::Concerns::StripeV2ClientHelper
include Enterprise::Billing::Concerns::PlanProvisioningHelper
include Enterprise::Billing::Concerns::BillingIntentWorkflow
include Enterprise::Billing::Concerns::SubscriptionDataManager
include Enterprise::Billing::Concerns::PlanDataHelper
# Change customer's pricing plan and/or seat quantity using invoice line items for proration
# Instantly applies the change and creates pending invoice line items
# for prorated charges that will be added to the next invoice
#
# @param new_pricing_plan_id [String, nil] The new Stripe pricing plan ID (nil to keep current plan)
# @param quantity [Integer] The seat quantity for the plan
# @return [Hash] { success:, message:, proration:, line_items: }
#
def change_plan(new_pricing_plan_id: nil, quantity: nil)
validation_error = validate_parameters(new_pricing_plan_id, quantity)
return validation_error if validation_error
@@ -19,8 +14,6 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
with_locked_account do
perform_subscription_change(new_pricing_plan_id, quantity)
end
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
end
private
@@ -45,8 +38,8 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
end
def build_change_context(new_pricing_plan_id, quantity)
old_plan_id = custom_attribute('stripe_pricing_plan_id')
old_quantity = custom_attribute('subscribed_quantity').to_i
old_plan_id = pricing_plan_id
old_quantity = subscribed_quantity
target_plan_id = new_pricing_plan_id || old_plan_id
target_quantity = quantity || old_quantity
@@ -65,15 +58,14 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
end
def no_change_response(context)
plan_name = Enterprise::Billing::V2::PlanCatalog.definition_for(context[:target_plan_id])&.dig(:display_name) || 'Unknown Plan'
plan_name = plan_display_name(context[:target_plan_id])
{ success: false, message: "Subscription already has plan #{plan_name} with #{context[:target_quantity]} seat(s)" }
end
def execute_change(context)
# Create billing intent to update the Stripe subscription
billing_intent = create_change_plan_intent(context[:target_plan_id], context[:target_quantity])
reserve_billing_intent(billing_intent)
commit_billing_intent(billing_intent)
# Create and execute billing intent to update the Stripe subscription
intent_params = build_change_plan_params(context[:target_plan_id], context[:target_quantity])
execute_billing_intent(intent_params)
next_billing_date = custom_attribute('next_billing_date')
proration_data = calculate_proration(
@@ -84,76 +76,39 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
next_billing_date: next_billing_date
)
line_items = build_proration_line_items(context, proration_data)
invoice_result = create_and_charge_invoice(line_items)
create_and_charge_invoice(line_items)
update_account_plan(context[:target_plan_id], context[:target_quantity], next_billing_date)
provision_new_plan(context[:target_plan_id]) if context[:plan_changed]
success_response(context, proration_data, line_items, invoice_result)
{ success: true, message: 'Plan change successful' }
end
def fetch_subscription_id
custom_attribute('stripe_subscription_id').tap do |id|
raise StandardError, 'No pricing plan subscription ID found' if id.blank?
end
end
def fetch_cadence_from_subscription(subscription_id)
subscription = retrieve_pricing_plan_subscription(subscription_id)
extract_attribute(subscription, :billing_cadence).tap do |cadence_id|
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
end
end
def create_change_plan_intent(new_pricing_plan_id, quantity)
subscription_id = fetch_subscription_id
cadence_id = fetch_cadence_from_subscription(subscription_id)
store_next_billing_date(cadence_id)
plan_version = fetch_new_plan_version(new_pricing_plan_id)
def build_change_plan_params(new_pricing_plan_id, quantity)
metadata = fetch_subscription_metadata
plan_version = fetch_plan_version(new_pricing_plan_id)
lookup_key = fetch_plan_lookup_key(new_pricing_plan_id)
component_config = { lookup_key: lookup_key, quantity: quantity }
create_billing_intent(
build_change_plan_params(subscription_id, cadence_id, new_pricing_plan_id, plan_version, component_config)
)
end
def build_change_plan_params(subscription_id, cadence_id, plan_id, plan_version, component_config)
{
cadence: cadence_id,
cadence: metadata[:cadence_id],
currency: 'usd',
actions: [{
type: 'modify',
modify: {
type: 'pricing_plan_subscription_details',
pricing_plan_subscription_details: {
pricing_plan_subscription: subscription_id,
new_pricing_plan: plan_id,
new_pricing_plan_version: plan_version,
component_configurations: [component_config]
}
}
}]
actions: [build_modify_action(metadata[:subscription_id], new_pricing_plan_id, plan_version, lookup_key, quantity)]
}
end
def store_next_billing_date(cadence_id)
cadence = retrieve_billing_cadence(cadence_id)
@next_billing_date = extract_attribute(cadence, :next_billing_date)
update_custom_attributes({ 'next_billing_date' => @next_billing_date })
end
def fetch_new_plan_version(plan_id)
plan = retrieve_pricing_plan(plan_id)
extract_attribute(plan, :latest_version).tap do |version|
raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank?
end
end
def fetch_plan_lookup_key(plan_id)
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id).tap do |key|
raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless key
end
def build_modify_action(subscription_id, plan_id, plan_version, lookup_key, quantity)
{
type: 'modify',
modify: {
type: 'pricing_plan_subscription_details',
pricing_plan_subscription_details: {
pricing_plan_subscription: subscription_id,
new_pricing_plan: plan_id,
new_pricing_plan_version: plan_version,
component_configurations: [{ lookup_key: lookup_key, quantity: quantity }]
}
}
}
end
def calculate_proration(old_plan_id:, new_plan_id:, old_quantity:, new_quantity:, next_billing_date:)
@@ -184,34 +139,4 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
}
)
end
def success_response(context, proration_data, line_items, invoice_result)
{
success: true,
pricing_plan_id: context[:target_plan_id],
quantity: context[:target_quantity],
old_pricing_plan_id: context[:old_plan_id],
old_quantity: context[:old_quantity],
plan_changed: context[:plan_changed],
seats_changed: context[:seats_changed],
proration: proration_data,
line_items: line_items,
invoice: invoice_result,
total_proration_amount: proration_data[:net_amount],
message: build_change_message(context)
}
end
def build_change_message(context)
plan_name = Enterprise::Billing::V2::PlanCatalog.definition_for(context[:target_plan_id])&.dig(:display_name) || 'Unknown Plan'
base_message = if context[:plan_changed] && context[:seats_changed]
"Plan changed to #{plan_name} and seats updated to #{context[:target_quantity]}"
elsif context[:plan_changed]
"Plan changed to #{plan_name}"
else
"Seats updated from #{context[:old_quantity]} to #{context[:target_quantity]}"
end
"#{base_message} - billing intent committed and invoice line items created"
end
end
@@ -34,8 +34,7 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
private
def validate_params
customer_id = custom_attribute('stripe_customer_id')
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if customer_id.blank?
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if stripe_customer_id.blank?
end
def store_pending_subscription_quantity
@@ -53,14 +52,13 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
# This creates a subscription with V2 Billing features automatically.
#
def create_checkout_session
customer_id = custom_attribute('stripe_customer_id')
session = super(checkout_session_params(customer_id), api_version: checkout_stripe_version)
session = super(checkout_session_params, api_version: checkout_stripe_version)
build_success_response(session)
end
def checkout_session_params(customer_id)
def checkout_session_params
{
customer: customer_id,
customer: stripe_customer_id,
checkout_items: build_checkout_items,
automatic_tax: {
enabled: true
@@ -111,12 +109,10 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
end
def build_success_response(session)
session_id = session.respond_to?(:id) ? session.id : session['id']
session_url = session.respond_to?(:url) ? session.url : session['url']
{
success: true,
session_id: session_id,
redirect_url: session_url
}
end
@@ -21,11 +21,10 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
end
def fetch_credit_grants
customer_id = stripe_customer_id
return [] if customer_id.blank?
return [] if stripe_customer_id.blank?
response = Stripe::Billing::CreditGrant.list(
{ customer: customer_id, limit: 100 }
{ customer: stripe_customer_id, limit: 100 }
)
grants = response.data.map do |grant|
@@ -82,8 +81,4 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
Time.zone.at(timestamp)
end
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
end
@@ -11,10 +11,9 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
# Validate that customer has a default payment method
# @return [Hash, nil] Returns error hash if validation fails, nil if success
def validate_payment_method
customer_id = custom_attribute('stripe_customer_id')
return { success: false, message: 'No Stripe customer ID found' } if customer_id.blank?
return { success: false, message: 'No Stripe customer ID found' } if stripe_customer_id.blank?
customer = Stripe::Customer.retrieve(customer_id, stripe_api_options)
customer = Stripe::Customer.retrieve(stripe_customer_id)
if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil?
return {
@@ -37,9 +36,8 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
# @param metadata [Hash] Invoice metadata
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
def create_and_pay_invoice(line_items:, description:, currency: 'usd', metadata: {})
customer_id = custom_attribute('stripe_customer_id')
invoice = create_invoice(customer_id, currency, description, metadata)
add_line_items_to_invoice(invoice.id, customer_id, line_items, currency)
invoice = create_invoice(stripe_customer_id, currency, description, metadata)
add_line_items_to_invoice(invoice.id, stripe_customer_id, line_items, currency)
finalize_and_pay_invoice(invoice.id)
rescue Stripe::StripeError => e
Rails.logger.error("Error creating invoice: #{e.message}")
@@ -56,7 +54,7 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
auto_advance: false,
description: description,
metadata: metadata.stringify_keys
}, stripe_api_options)
})
end
def add_line_items_to_invoice(invoice_id, customer_id, line_items, currency)
@@ -68,7 +66,7 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
invoice: invoice_id,
description: item[:description],
metadata: (item[:metadata] || {}).stringify_keys
}, stripe_api_options)
})
end
end
@@ -79,15 +77,14 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
# Finalize the invoice
finalized_invoice = Stripe::Invoice.finalize_invoice(
invoice_id,
{ auto_advance: false },
stripe_api_options
{ auto_advance: false }
)
# Pay the invoice immediately if not already paid
if finalized_invoice.status == 'paid'
build_invoice_response(finalized_invoice)
else
paid_invoice = Stripe::Invoice.pay(invoice_id, {}, stripe_api_options)
paid_invoice = Stripe::Invoice.pay(invoice_id, {})
build_invoice_response(paid_invoice)
end
rescue Stripe::StripeError => e
@@ -104,8 +101,4 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
status: invoice.status
}
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
end
@@ -4,54 +4,47 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
include Enterprise::Billing::Concerns::StripeV2ClientHelper
def provision(subscription_id:)
process_subscription(subscription_id)
end
def refresh
return if stripe_subscription_id.blank?
process_subscription(stripe_subscription_id)
end
private
def process_subscription(subscription_id)
# Retrieve pricing plan subscription details from Stripe V2 API
subscription = retrieve_pricing_plan_subscription(subscription_id)
# Check if subscription is canceled
if servicing_status(subscription) == 'canceled'
cancel_subscription
reset_captain_usage
return { pricing_plan_id: nil, quantity: nil }
end
# Extract details from the subscription
pricing_plan_id = extract_pricing_plan_id(subscription)
quantity = extract_subscription_quantity(subscription)
billing_cadence = extract_billing_cadence(subscription)
# Update account with subscription details
update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
# Provision the subscription: sync credits and enable features
provision_new_plan(pricing_plan_id) if pricing_plan_id.present?
build_success_response(subscription_id, pricing_plan_id, quantity)
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
end
def refresh
subscription_id = account.custom_attributes['stripe_subscription_id']
return if subscription_id.blank?
subscription_plan = retrieve_pricing_plan_subscription(subscription_id)
servicing_status = servicing_status(subscription_plan)
pricing_plan_id = extract_pricing_plan_id(subscription_plan)
if servicing_status == 'canceled'
cancel_subscription
else
quantity = account.custom_attributes['subscribed_quantity']
billing_cadence = extract_billing_cadence(subscription_plan)
update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
end
# Reset usage for the new billing cycle
reset_captain_usage
end
private
def build_success_response(subscription_id, pricing_plan_id, quantity)
{
success: true,
subscription_id: subscription_id,
pricing_plan_id: pricing_plan_id,
quantity: quantity,
message: 'Subscription provisioned successfully'
}
{ pricing_plan_id: pricing_plan_id, quantity: quantity }
end
def servicing_status(subscription_plan)
subscription_plan.respond_to?(:servicing_status) ? subscription_plan.servicing_status : subscription_plan['servicing_status']
extract_attribute(subscription_plan, :servicing_status)
end
def cancel_subscription
@@ -78,26 +71,20 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
end
def extract_pricing_plan_id(subscription)
# Extract pricing_plan from the subscription object
subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan']
extract_attribute(subscription, :pricing_plan)
end
def extract_billing_cadence(subscription)
subscription.respond_to?(:billing_cadence) ? subscription.billing_cadence : subscription['billing_cadence']
extract_attribute(subscription, :billing_cadence)
end
def extract_subscription_quantity(_subscription)
# Get quantity from account custom_attributes (set during checkout)
pending_quantity = account.custom_attributes['pending_subscription_quantity']
if pending_quantity.present? && pending_quantity.to_i.positive?
Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{pending_quantity}"
return pending_quantity.to_i
end
subscribed_quantity = account.custom_attributes['subscribed_quantity']
if subscribed_quantity.present? && subscribed_quantity.to_i.positive?
Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{subscribed_quantity}"
return subscribed_quantity.to_i
end
pending_quantity = custom_attribute('pending_subscription_quantity')
return pending_quantity.to_i if pending_quantity.present? && pending_quantity.to_i.positive?
return subscribed_quantity if subscribed_quantity.positive?
1
end
@@ -19,7 +19,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
def validate_topup_request(credits)
return { valid: false, success: false, message: 'Invalid topup amount' } unless credits.to_i.positive?
# Check if account has a valid subscription plan
plan_validation = validate_subscription_plan
return plan_validation unless plan_validation[:valid]
@@ -27,7 +26,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
return { valid: false, success: false, message: 'Unsupported topup amount' } unless topup_definition
return { valid: false, success: false, message: 'Stripe customer not configured' } if stripe_customer_id.blank?
# Check if customer has a default payment method using common service
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
payment_method_validation = payment_service.validate_payment_method
return payment_method_validation.merge(valid: false) if payment_method_validation
@@ -144,8 +142,4 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
credits: credits.to_s
}
end
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
end
@@ -16,7 +16,7 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
private
def valid_configuration?
custom_attribute('stripe_customer_id').present?
stripe_customer_id.present?
end
def meter_event_params(credits_used)
@@ -24,7 +24,7 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
event_name: 'chatwoot.usage',
payload: {
value: credits_used.to_s,
stripe_customer_id: custom_attribute('stripe_customer_id')
stripe_customer_id: stripe_customer_id
},
identifier: "#{account.id}_#{Time.current.to_i}_#{SecureRandom.hex(4)}"
}
@@ -6,104 +6,26 @@ describe Enterprise::Billing::V2::CreditManagementService do
before do
account.update!(
custom_attributes: {
'stripe_billing_version' => 2,
'stripe_customer_id' => 'cus_test_123',
'stripe_meter_event_name' => 'ai_prompts',
'captain_responses_monthly' => 100,
'captain_responses_topup' => 50,
'captain_responses_usage' => 0,
'captain_documents_usage' => 0
},
limits: {
'captain_responses' => 150,
'captain_documents' => 200
}
custom_attributes: { 'captain_responses_usage' => 0 },
limits: { 'captain_responses_monthly' => 100, 'captain_responses_topup' => 50 }
)
end
describe '#sync_monthly_response_credits' do
it 'updates monthly credits and syncs total' do
it 'updates monthly credits' do
service.sync_monthly_response_credits(500)
account.reload
expect(account.custom_attributes['captain_responses_monthly']).to eq(500)
expect(account.limits['captain_responses']).to eq(550) # 500 + 50 topup (stored in limits ONLY)
end
it 'preserves topup credits capped at remaining balance' do
# Set usage to 80
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 80))
service.sync_monthly_response_credits(100)
account.reload
# Current: monthly=100, topup=50, total=150, usage=80
# Remaining: 150 - 80 = 70
# Topup should be capped at min(50, 70) = 50
expect(account.custom_attributes['captain_responses_topup']).to eq(50)
expect(account.limits['captain_responses']).to eq(150)
end
it 'caps topup when usage exceeds new monthly allocation' do
# Usage is 120, new monthly is 100, topup is 50
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 120))
service.sync_monthly_response_credits(100)
account.reload
# New total would be: monthly=100 + topup=50 = 150
# Usage: 120
# Remaining: 150 - 120 = 30
# Topup should be capped at min(50, 30) = 30
expect(account.custom_attributes['captain_responses_topup']).to eq(30)
expect(account.limits['captain_responses']).to eq(130) # 100 + 30
end
it 'zeros topup when usage exceeds new total' do
# Usage is 160, new monthly is 100, topup is 50
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 160))
service.sync_monthly_response_credits(100)
account.reload
# New total would be: monthly=100 + topup=50 = 150
# Usage: 160 (exceeds total)
# Remaining: max(0, 150 - 160) = 0
# Topup should be: min(50, 0) = 0
expect(account.custom_attributes['captain_responses_topup']).to eq(0)
expect(account.limits['captain_responses']).to eq(100) # 100 + 0
end
end
describe '#sync_document_credits' do
it 'updates document credits total' do
service.sync_document_credits(500)
account.reload
expect(account.limits['captain_documents']).to eq(500) # Stored in limits hash ONLY
end
end
describe '#expire_monthly_response_credits' do
it 'expires monthly credits and preserves topup' do
expired = service.expire_monthly_response_credits
expect(expired).to eq(100)
account.reload
expect(account.custom_attributes['captain_responses_monthly']).to eq(0)
expect(account.custom_attributes['captain_responses_topup']).to eq(50)
expect(account.limits['captain_responses']).to eq(50)
expect(account.reload.limits['captain_responses_monthly']).to eq(500)
expect(account.limits['captain_responses']).to eq(550)
end
end
describe '#add_response_topup_credits' do
it 'adds topup credits and updates total' do
it 'adds topup credits' do
service.add_response_topup_credits(100)
account.reload
expect(account.custom_attributes['captain_responses_topup']).to eq(150) # 50 + 100
expect(account.limits['captain_responses']).to eq(250) # 100 monthly + 150 topup
expect(account.reload.limits['captain_responses_topup']).to eq(150)
expect(account.limits['captain_responses']).to eq(250)
end
end
end
@@ -7,7 +7,7 @@ describe Enterprise::Billing::V2::SubscriptionProvisioningService do
let(:pricing_plan_id) { 'bpp_business_plan_123' }
before do
account.update!(custom_attributes: { 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
account.update!(custom_attributes: { 'pending_subscription_quantity' => 5 })
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id)
end
@@ -16,9 +16,8 @@ describe Enterprise::Billing::V2::SubscriptionProvisioningService do
OpenStruct.new(
id: subscription_id,
pricing_plan: pricing_plan_id,
component_values: [
{ 'type' => 'license_fee', 'quantity' => 5 }
]
billing_cadence: 'bpc_cadence_123',
servicing_status: 'active'
)
end
@@ -26,179 +25,33 @@ describe Enterprise::Billing::V2::SubscriptionProvisioningService do
allow(StripeV2Client).to receive(:request).and_return(subscription_response)
end
it 'retrieves subscription details from Stripe' do
service.provision(subscription_id: subscription_id)
it 'provisions subscription' do
result = service.provision(subscription_id: subscription_id)
expect(StripeV2Client).to have_received(:request).with(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
hash_including(api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview')
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
expect(result[:quantity]).to eq(5)
end
end
describe '#refresh' do
let(:subscription_response) do
OpenStruct.new(
id: 'bpps_sub_456',
pricing_plan: pricing_plan_id,
billing_cadence: 'bpc_cadence_123',
servicing_status: 'active'
)
end
it 'updates account custom attributes with subscription details' do
result = service.provision(subscription_id: subscription_id)
expect(result[:success]).to be(true)
expect(account.custom_attributes['stripe_billing_version']).to eq(2)
expect(account.custom_attributes['stripe_subscription_id']).to eq(subscription_id)
expect(account.custom_attributes['stripe_pricing_plan_id']).to eq(pricing_plan_id)
expect(account.custom_attributes['subscribed_quantity']).to eq(5)
expect(account.custom_attributes['subscription_status']).to eq('active')
expect(account.custom_attributes['plan_name']).to eq('Chatwoot Business')
before do
account.update!(custom_attributes: { 'stripe_subscription_id' => 'bpps_sub_456' })
allow(StripeV2Client).to receive(:request).and_return(subscription_response)
end
it 'syncs monthly credits based on plan' do
credit_service = instance_double(Enterprise::Billing::V2::CreditManagementService)
allow(Enterprise::Billing::V2::CreditManagementService)
.to receive(:new)
.with(account: account)
.and_return(credit_service)
allow(credit_service).to receive(:sync_monthly_credits)
it 'refreshes subscription' do
result = service.refresh
service.provision(subscription_id: subscription_id)
expect(credit_service).to have_received(:sync_monthly_credits).with(50_000)
end
it 'enables plan-specific features' do
service.provision(subscription_id: subscription_id)
expected_features = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
sla
custom_roles
]
enabled_feature_names = account.enabled_features.map(&:first)
expect(enabled_feature_names).to match_array(expected_features)
end
it 'returns success response with subscription details' do
result = service.provision(subscription_id: subscription_id)
expect(result[:success]).to be(true)
expect(result[:subscription_id]).to eq(subscription_id)
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
expect(result[:quantity]).to eq(5)
expect(result[:message]).to eq('Subscription provisioned successfully')
end
context 'when pricing plan has no license fee component' do
let(:subscription_response) do
OpenStruct.new(
id: subscription_id,
pricing_plan: pricing_plan_id,
component_values: []
)
end
before do
# Clear pending_subscription_quantity to test default behavior
account.update!(custom_attributes: account.custom_attributes.except('pending_subscription_quantity'))
end
it 'defaults quantity to 1' do
result = service.provision(subscription_id: subscription_id)
expect(result[:quantity]).to eq(1)
expect(account.custom_attributes['subscribed_quantity']).to eq(1)
end
end
context 'when Stripe API returns an error' do
before do
allow(StripeV2Client).to receive(:request).and_raise(Stripe::StripeError.new('API error'))
end
it 'returns error response' do
result = service.provision(subscription_id: subscription_id)
expect(result[:success]).to be(false)
expect(result[:message]).to include('Stripe error')
end
end
context 'with Startup plan' do
let(:startup_plan_id) { 'bpp_startup_plan_123' }
let(:subscription_response) do
OpenStruct.new(
id: subscription_id,
pricing_plan: startup_plan_id,
component_values: [{ 'type' => 'license_fee', 'quantity' => 3 }]
)
end
before do
create(:installation_config, name: 'STRIPE_STARTUP_PLAN_ID', value: startup_plan_id)
end
it 'enables only Startup features' do
service.provision(subscription_id: subscription_id)
expected_features = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
]
enabled_feature_names = account.enabled_features.map(&:first)
expect(enabled_feature_names).to match_array(expected_features)
end
end
context 'with Enterprise plan' do
let(:enterprise_plan_id) { 'bpp_enterprise_plan_123' }
let(:subscription_response) do
OpenStruct.new(
id: subscription_id,
pricing_plan: enterprise_plan_id,
component_values: [{ 'type' => 'license_fee', 'quantity' => 10 }]
)
end
before do
create(:installation_config, name: 'STRIPE_ENTERPRISE_PLAN_ID', value: enterprise_plan_id)
end
it 'enables all features including Enterprise-specific ones' do
service.provision(subscription_id: subscription_id)
expected_features = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
sla
custom_roles
audit_logs
disable_branding
saml
]
enabled_feature_names = account.enabled_features.map(&:first)
expect(enabled_feature_names).to match_array(expected_features)
end
end
end
end
@@ -1,33 +1,8 @@
require 'rails_helper'
require 'ostruct'
describe Enterprise::Billing::V2::TopupService do
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'plan_name' => 'Startups' }) }
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'plan_name' => 'Business' }) }
let(:service) { described_class.new(account: account) }
let(:invoice_settings) { instance_double(Stripe::InvoiceSettings, default_payment_method: 'pm_123') }
let(:stripe_customer) do
instance_double(Stripe::Customer,
invoice_settings: invoice_settings,
default_source: nil)
end
let(:invoice) { Stripe::Invoice.construct_from(id: 'in_123', customer: 'cus_123', currency: 'usd') }
let(:invoice_item) do
Stripe::InvoiceItem.construct_from(
id: 'ii_123',
invoice: 'in_123',
amount: 5000,
currency: 'usd'
)
end
let(:finalized_invoice) { Stripe::Invoice.construct_from(id: 'in_123', status: 'open') }
let(:paid_invoice) { Stripe::Invoice.construct_from(id: 'in_123', status: 'paid') }
let(:credit_grant) do
{
'id' => 'credgr_123',
'customer' => 'cus_123',
'amount' => { 'type' => 'monetary', 'monetary' => { 'value' => 5000, 'currency' => 'usd' } }
}
end
before do
allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).with(500).and_return(
@@ -35,110 +10,26 @@ describe Enterprise::Billing::V2::TopupService do
amount: 50.0,
currency: 'usd'
)
# Mock Stripe Customer retrieval for payment method check
allow(Stripe::Customer).to receive(:retrieve).and_return(stripe_customer)
# Mock Stripe Invoice creation
allow(Stripe::Invoice).to receive(:create).and_return(invoice)
# Mock Stripe InvoiceItem creation
allow(Stripe::InvoiceItem).to receive(:create).and_return(invoice_item)
# Mock Stripe Invoice finalization
allow(Stripe::Invoice).to receive(:finalize_invoice).and_return(finalized_invoice)
# Mock Stripe Invoice payment
allow(Stripe::Invoice).to receive(:pay).and_return(paid_invoice)
# Mock Stripe Credit Grant creation (monetary amount)
allow(Stripe::Billing::CreditGrant).to receive(:create).and_return(credit_grant)
allow(Stripe::Customer).to receive(:retrieve).and_return(
OpenStruct.new(invoice_settings: OpenStruct.new(default_payment_method: 'pm_123'), default_source: nil)
)
allow(Stripe::Invoice).to receive(:create).and_return(Stripe::Invoice.construct_from(id: 'in_123'))
allow(Stripe::InvoiceItem).to receive(:create)
allow(Stripe::Invoice).to receive(:finalize_invoice).and_return(
Stripe::Invoice.construct_from(id: 'in_123', status: 'open')
)
allow(Stripe::Invoice).to receive(:pay).and_return(
Stripe::Invoice.construct_from(id: 'in_123', status: 'paid', total: 5000, hosted_invoice_url: 'https://invoice.stripe.com')
)
allow(Stripe::Billing::CreditGrant).to receive(:create).and_return({ 'id' => 'credgr_123' })
end
describe '#create_topup' do
context 'when successful' do
before do
service.create_topup(credits: 500)
end
it 'creates topup' do
result = service.create_topup(credits: 500)
it 'creates invoice with correct parameters' do
expect(Stripe::Invoice).to have_received(:create).with(
hash_including(
customer: 'cus_123',
currency: 'usd',
collection_method: 'charge_automatically'
),
hash_including(:api_key)
)
end
it 'creates invoice item with topup amount' do
expect(Stripe::InvoiceItem).to have_received(:create).with(
hash_including(
customer: 'cus_123',
amount: 5000,
currency: 'usd',
invoice: 'in_123',
description: 'Credit Topup: 500 credits'
),
hash_including(:api_key)
)
end
it 'finalizes invoice for payment' do
expect(Stripe::Invoice).to have_received(:finalize_invoice).with(
'in_123',
hash_including(auto_advance: false),
hash_including(:api_key)
)
end
it 'pays the invoice explicitly' do
expect(Stripe::Invoice).to have_received(:pay).with(
'in_123',
{},
hash_including(:api_key)
)
end
it 'creates credit grant with monetary amount' do
expect(Stripe::Billing::CreditGrant).to have_received(:create).with(
hash_including(
customer: 'cus_123',
name: 'Topup: 500 credits',
amount: hash_including(
type: 'monetary',
monetary: hash_including(currency: 'usd', value: 5000)
),
applicability_config: hash_including(scope: hash_including(price_type: 'metered')),
category: 'paid',
metadata: hash_including(
account_id: account.id.to_s,
source: 'topup',
credits: '500'
)
),
hash_including(:api_key)
)
end
it 'returns success response with all ids' do
result = service.create_topup(credits: 500)
expect(result[:success]).to be true
expect(result[:credits]).to eq(500)
expect(result[:invoice_id]).to eq('in_123')
expect(result[:credit_grant_id]).to eq('credgr_123')
end
end
it 'returns error when option missing' do
allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).and_return(nil)
result = service.create_topup(credits: 999)
expect(result[:success]).to be false
expect(result[:message]).to eq('Unsupported topup amount')
expect(result[:success]).to be true
expect(result[:credits]).to eq(500)
end
end
end
@@ -1,55 +1,18 @@
require 'rails_helper'
describe Enterprise::Billing::V2::UsageReporterService do
let(:account) { create(:account) }
let(:account) { create(:account, custom_attributes: { 'stripe_billing_version' => 2, 'stripe_customer_id' => 'cus_123' }) }
let(:service) { described_class.new(account: account) }
before do
account.update!(
custom_attributes: {
'stripe_billing_version' => 2,
'stripe_customer_id' => 'cus_test_123',
'stripe_meter_event_name' => 'ai_prompts'
}
)
end
describe '#report' do
it 'posts usage to Stripe' do
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(OpenStruct.new(identifier: 'me_123'))
it 'posts usage events to Stripe meters' do
meter_event = OpenStruct.new(identifier: 'me_test_123')
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(meter_event)
result = service.report(5, 'ai_test')
expect(result).to include(success: true, event_id: 'me_test_123')
expect(Stripe::Billing::MeterEvent).to have_received(:create) do |params, options|
expect(params[:event_name]).to eq('ai_prompts')
expect(params[:payload][:value]).to eq('5')
expect(params[:payload][:stripe_customer_id]).to eq('cus_test_123')
expect(options[:stripe_version]).to eq('2025-08-27.preview')
end
end
context 'when V2 billing not enabled' do
before do
account.update!(custom_attributes: { 'stripe_billing_version' => 1 })
end
it 'returns error' do
result = service.report(5, 'ai_test')
expect(result).to include(success: false, message: 'V2 billing not enabled')
end
end
context 'when no Stripe customer' do
before do
account.update!(custom_attributes: { 'stripe_billing_version' => 2 })
end
it 'returns error' do
result = service.report(5, 'ai_test')
expect(result).to include(success: false, message: 'Missing Stripe configuration')
expect(result[:success]).to be true
expect(result[:event_id]).to eq('me_123')
expect(Stripe::Billing::MeterEvent).to have_received(:create)
end
end
end
@@ -1,105 +1,40 @@
require 'rails_helper'
# rubocop:disable RSpec/VerifiedDoubles
describe Enterprise::Billing::V2::WebhookHandlerService do
let(:account) do
create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
end
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123' }) }
let(:service) { described_class.new }
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
before do
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).with(account: account).and_return(credit_service)
allow(ENV).to receive(:fetch).and_call_original
end
let(:pricing_plan_id) { 'bpp_plan_123' }
describe '#perform' do
context 'when handling subscription servicing activated' do
let(:subscription_response) do
OpenStruct.new(
id: 'bpps_subscription_123',
pricing_plan: 'bpp_business_plan_123',
billing_cadence: 'cadence_123',
component_values: [{ 'type' => 'license_fee', 'quantity' => 5 }]
)
end
let(:cadence_response) do
OpenStruct.new(
id: 'cadence_123',
payer: OpenStruct.new(customer: 'cus_123')
)
end
let(:provisioning_response) do
{
success: true,
subscription_id: 'bpps_subscription_123',
pricing_plan_id: 'bpp_business_plan_123',
quantity: 5
}
end
let(:event) do
double(
'Stripe::Event',
type: 'v2.billing.pricing_plan_subscription.servicing_activated',
related_object: OpenStruct.new(id: 'bpps_subscription_123')
)
end
let(:expected_features) do
%w[inbound_emails help_center campaigns team_management channel_twitter channel_facebook
channel_email channel_instagram captain_integration advanced_search_indexing sla custom_roles]
end
before do
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: 'bpp_business_plan_123')
# Mock account lookup via subscription and cadence
allow(StripeV2Client).to receive(:request)
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything)
.and_return(subscription_response)
allow(StripeV2Client).to receive(:request)
.with(:get, '/v2/billing/cadences/cadence_123', anything, anything)
.and_return(cadence_response)
# Mock provisioning service
provisioning_service = instance_double(Enterprise::Billing::V2::SubscriptionProvisioningService)
allow(Enterprise::Billing::V2::SubscriptionProvisioningService).to receive(:new).with(account: account).and_return(provisioning_service)
allow(provisioning_service).to receive(:provision).and_return(provisioning_response)
allow(credit_service).to receive(:sync_monthly_credits)
end
it 'delegates to subscription provisioning service' do
result = service.perform(event: event)
expect(result[:success]).to be(true), "Expected success but got: #{result.inspect}"
expect(Enterprise::Billing::V2::SubscriptionProvisioningService).to have_received(:new).with(account: account)
end
let(:subscription_response) do
OpenStruct.new(
id: 'bpps_sub_123',
billing_cadence: 'cad_123',
pricing_plan: pricing_plan_id,
servicing_status: 'active'
)
end
let(:cadence_response) { OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123')) }
let(:event) do
double('Stripe::Event', type: 'v2.billing.pricing_plan_subscription.servicing_activated', related_object: OpenStruct.new(id: 'bpps_sub_123')) # rubocop:disable RSpec/VerifiedDoubles
end
context 'when handling unknown event' do
it 'returns success' do
related_object = OpenStruct.new(id: 'bpps_subscription_123')
event = double('Stripe::Event', type: 'unknown.event', related_object: related_object)
before do
account # Ensure account is created
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id)
# Mock the Stripe API calls
allow(StripeV2Client).to receive(:request)
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_sub_123', anything, anything)
.and_return(subscription_response)
allow(StripeV2Client).to receive(:request)
.with(:get, '/v2/billing/cadences/cad_123', anything, anything)
.and_return(cadence_response)
end
# Mock account lookup
subscription_response = OpenStruct.new(billing_cadence: 'cadence_123')
cadence_response = OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123'))
allow(StripeV2Client).to receive(:request)
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything)
.and_return(subscription_response)
allow(StripeV2Client).to receive(:request)
.with(:get, '/v2/billing/cadences/cadence_123', anything, anything)
.and_return(cadence_response)
it 'handles subscription activation' do
result = service.perform(event: event)
result = service.perform(event: event)
expect(result[:success]).to be(true)
end
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
end
end
end
# rubocop:enable RSpec/VerifiedDoubles