create and pay an invoice on subscription change

This commit is contained in:
Tanmay Deep Sharma
2025-10-30 15:51:51 +05:30
parent 65559b47fd
commit 32e0915a20
14 changed files with 701 additions and 369 deletions
@@ -0,0 +1,44 @@
module Enterprise::Billing::Concerns::PlanProvisioningHelper
extend ActiveSupport::Concern
private
def provision_new_plan(new_pricing_plan_id)
sync_plan_credits(new_pricing_plan_id)
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
if plan_definition
plan_name = extract_plan_name(plan_definition)
enable_plan_specific_features(plan_name) if plan_name.present?
end
reset_captain_usage
end
def sync_plan_credits(pricing_plan_id)
plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id)
Enterprise::Billing::V2::CreditManagementService
.new(account: account)
.sync_monthly_credits(plan_credits.to_i)
end
def extract_plan_name(plan_definition)
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
end
def update_account_plan(new_pricing_plan_id, quantity, next_billing_date)
attributes = {
'stripe_pricing_plan_id' => new_pricing_plan_id,
'pending_stripe_pricing_plan_id' => nil,
'pending_subscription_quantity' => nil,
'subscribed_quantity' => quantity,
'next_billing_date' => next_billing_date
}
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
attributes['plan_name'] = plan_definition[:display_name] if plan_definition
update_custom_attributes(attributes)
end
end
@@ -0,0 +1,103 @@
module Enterprise::Billing::Concerns::ProrationLineItemBuilder
extend ActiveSupport::Concern
private
def build_proration_line_items(context, proration_data)
return build_seat_change_line_items(context, proration_data) if seat_only_change?(context)
build_plan_change_line_items(context, proration_data)
end
def seat_only_change?(context)
!context[:plan_changed] && context[:seats_changed]
end
def build_seat_change_line_items(context, proration_data)
[{
amount: (proration_data[:net_amount] * 100).to_i,
description: build_seat_change_description(context),
metadata: build_seat_change_metadata(context, proration_data)
}]
end
def build_plan_change_line_items(context, proration_data)
line_items = []
old_plan_name = plan_display_name(context[:old_plan_id])
new_plan_name = plan_display_name(context[:target_plan_id])
line_items << build_credit_line_item(context, proration_data, old_plan_name) if proration_data[:credit_amount].positive?
line_items << build_charge_line_item(context, proration_data, new_plan_name) if proration_data[:charge_amount].positive?
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,
description: credit_description(old_plan_name, context[:old_quantity]),
metadata: credit_metadata(old_plan_name, context[:old_quantity], proration_data[:days_remaining])
}
end
def build_charge_line_item(context, proration_data, new_plan_name)
{
amount: (proration_data[:charge_amount] * 100).to_i,
description: charge_description(new_plan_name, context[:target_quantity]),
metadata: charge_metadata(new_plan_name, context[:target_quantity], proration_data[:days_remaining])
}
end
def credit_description(plan_name, quantity)
"Credit for unused time on #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})"
end
def charge_description(plan_name, quantity)
"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'
quantity_diff = (context[:target_quantity] - context[:old_quantity]).abs
"Seat #{change_type} for #{plan_name}: #{context[:old_quantity]}#{context[:target_quantity]} " \
"seats (#{quantity_diff} seat#{quantity_diff > 1 ? 's' : ''})"
end
def build_seat_change_metadata(context, proration_data)
{
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],
billing_version: 'v2'
}
end
end
@@ -0,0 +1,120 @@
module Enterprise::Billing::Concerns::StripeV2ClientHelper
extend ActiveSupport::Concern
private
# Generic Stripe V2 API request wrapper
def stripe_v2_request(method, path, params = {}, api_version: nil)
StripeV2Client.request(method, path, params, stripe_api_options(api_version))
end
# Pricing Plan Subscriptions
def retrieve_pricing_plan_subscription(subscription_id)
stripe_v2_request(:get, "/v2/billing/pricing_plan_subscriptions/#{subscription_id}")
end
# Pricing Plans
def retrieve_pricing_plan(pricing_plan_id)
stripe_v2_request(:get, "/v2/billing/pricing_plans/#{pricing_plan_id}")
end
def create_pricing_plan(params)
stripe_v2_request(:post, '/v2/billing/pricing_plans', params)
end
def update_pricing_plan(plan_id, params)
stripe_v2_request(:post, "/v2/billing/pricing_plans/#{plan_id}", params)
end
# Billing Cadences
def retrieve_billing_cadence(cadence_id)
stripe_v2_request(:get, "/v2/billing/cadences/#{cadence_id}")
end
# Billing Intents
def create_billing_intent(params)
stripe_v2_request(:post, '/v2/billing/intents', params)
end
def reserve_billing_intent(billing_intent)
stripe_v2_request(:post, "/v2/billing/intents/#{billing_intent.id}/reserve")
end
def commit_billing_intent(billing_intent)
stripe_v2_request(:post, "/v2/billing/intents/#{billing_intent.id}/commit")
end
# Custom Pricing Units
def create_custom_pricing_unit(params)
stripe_v2_request(:post, '/v2/billing/custom_pricing_units', params)
end
# Licensed Items
def create_licensed_item(params)
stripe_v2_request(:post, '/v2/billing/licensed_items', params)
end
# License Fees
def create_license_fee(params)
stripe_v2_request(:post, '/v2/billing/license_fees', params)
end
# Service Actions
def create_service_action(params)
stripe_v2_request(:post, '/v2/billing/service_actions', params)
end
# Rate Cards
def create_rate_card(params)
stripe_v2_request(:post, '/v2/billing/rate_cards', params)
end
def add_rate_to_card(card_id, params)
stripe_v2_request(:post, "/v2/billing/rate_cards/#{card_id}/rates", params)
end
# Metered Items
def create_metered_item(params)
stripe_v2_request(:post, '/v2/billing/metered_items', params)
end
# Meters (V1 API but used with V2)
def create_meter(params)
stripe_v2_request(:post, '/v1/billing/meters', params)
end
# Pricing Plan Components
def add_pricing_plan_component(plan_id, params)
stripe_v2_request(:post, "/v2/billing/pricing_plans/#{plan_id}/components", params)
end
# Checkout Sessions (V1 API but used with V2 plans)
def create_checkout_session(params, api_version: nil)
stripe_v2_request(:post, '/v1/checkout/sessions', params, api_version: api_version)
end
# Credit Grants (V1 API but used with V2)
def retrieve_credit_grant(grant_id)
stripe_v2_request(:get, "/v1/billing/credit_grants/#{grant_id}")
end
# API Options with support for custom versions
def stripe_api_options(custom_version = nil)
{
api_key: ENV.fetch('STRIPE_SECRET_KEY', nil),
stripe_version: custom_version || default_stripe_version
}
end
def default_stripe_version
'2025-08-27.preview'
end
def checkout_stripe_version
'2025-08-27.preview;checkout_product_catalog_preview=v1'
end
def extract_attribute(object, key)
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
end
end
@@ -1,5 +1,6 @@
class Enterprise::Billing::HandleStripeEventService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::StripeV2ClientHelper
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
@@ -124,10 +125,6 @@ class Enterprise::Billing::HandleStripeEventService
grant_object.respond_to?(:id) ? grant_object.id : grant_object['id']
end
def retrieve_credit_grant(grant_id)
StripeV2Client.request(:get, "/v1/billing/credit_grants/#{grant_id}")
end
def extract_credit_amount(grant)
# First, try to get credits from metadata
metadata = extract_attribute(grant, :metadata)
@@ -1,5 +1,6 @@
class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::StripeV2ClientHelper
# Cancel subscription using Stripe's V2 Billing Intent API
# Creates a deactivate billing intent for the pricing plan subscription
@@ -29,11 +30,8 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
billing_cadence_id = fetch_billing_cadence_id(pricing_plan_subscription_id)
store_next_billing_date(billing_cadence_id)
StripeV2Client.request(
:post,
'/v2/billing/intents',
build_deactivate_params(pricing_plan_subscription_id, billing_cadence_id),
stripe_api_options
create_billing_intent(
build_deactivate_params(pricing_plan_subscription_id, billing_cadence_id)
)
end
@@ -70,42 +68,6 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
}
end
def reserve_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/reserve",
{},
stripe_api_options
)
end
def commit_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/commit",
{},
stripe_api_options
)
end
def retrieve_pricing_plan_subscription(subscription_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
stripe_api_options
)
end
def retrieve_billing_cadence(cadence_id)
StripeV2Client.request(
:get,
"/v2/billing/cadences/#{cadence_id}",
{},
stripe_api_options
)
end
def update_account_status(_billing_intent)
# Mark subscription as cancelling (will be cancelled at period end)
# Store next_billing_date so the UI can show when the subscription ends
@@ -123,12 +85,4 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
message: 'Subscription cancellation initiated. It will be deactivated at the end of the current billing period.'
}
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
def extract_attribute(object, key)
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
end
end
@@ -1,22 +1,23 @@
class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::ProrationLineItemBuilder
include Enterprise::Billing::Concerns::StripeV2ClientHelper
include Enterprise::Billing::Concerns::PlanProvisioningHelper
# Change customer's pricing plan using Stripe's V2 Billing Intent API
# Creates a modify billing intent to migrate to a new pricing plan
# 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] The new Stripe pricing plan ID
# @param quantity [Integer] The quantity for the new plan
# @return [Hash] { success:, message: }
# @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:, quantity: 1)
return { success: false, message: 'Invalid quantity' } unless quantity.positive?
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
with_locked_account do
billing_intent = create_change_plan_intent(new_pricing_plan_id, quantity)
reserve_billing_intent(billing_intent)
commit_billing_intent(billing_intent)
update_account_plan(new_pricing_plan_id, quantity)
success_response(new_pricing_plan_id, quantity)
perform_subscription_change(new_pricing_plan_id, quantity)
end
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
@@ -24,20 +25,71 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
private
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)
lookup_key = fetch_plan_lookup_key(new_pricing_plan_id)
component_config = { lookup_key: lookup_key, quantity: quantity }
def validate_parameters(new_pricing_plan_id, quantity)
return { success: false, message: 'Must specify either new_pricing_plan_id or quantity' } if new_pricing_plan_id.nil? && quantity.nil?
return { success: false, message: 'Invalid quantity' } if quantity && !quantity.positive?
StripeV2Client.request(
:post,
'/v2/billing/intents',
build_change_plan_params(subscription_id, cadence_id, new_pricing_plan_id, plan_version, component_config),
stripe_api_options
# Validate 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 unless payment_method_validation.nil?
nil
end
def perform_subscription_change(new_pricing_plan_id, quantity)
change_context = build_change_context(new_pricing_plan_id, quantity)
return no_change_response(change_context) unless change_required?(change_context)
execute_change(change_context)
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
target_plan_id = new_pricing_plan_id || old_plan_id
target_quantity = quantity || old_quantity
{
old_plan_id: old_plan_id,
old_quantity: old_quantity,
target_plan_id: target_plan_id,
target_quantity: target_quantity,
plan_changed: old_plan_id != target_plan_id,
seats_changed: old_quantity != target_quantity
}
end
def change_required?(context)
context[:plan_changed] || context[:seats_changed]
end
def no_change_response(context)
plan_name = Enterprise::Billing::V2::PlanCatalog.definition_for(context[:target_plan_id])&.dig(:display_name) || 'Unknown Plan'
{ 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)
next_billing_date = custom_attribute('next_billing_date')
proration_data = calculate_proration(
old_plan_id: context[:old_plan_id],
new_plan_id: context[:target_plan_id],
old_quantity: context[:old_quantity],
new_quantity: context[:target_quantity],
next_billing_date: next_billing_date
)
line_items = build_proration_line_items(context, proration_data)
invoice_result = 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)
end
def fetch_subscription_id
@@ -53,17 +105,17 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
end
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 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)
lookup_key = fetch_plan_lookup_key(new_pricing_plan_id)
component_config = { lookup_key: lookup_key, quantity: quantity }
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
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)
@@ -85,89 +137,81 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
}
end
def reserve_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/reserve",
{},
stripe_api_options
)
end
def commit_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/commit",
{},
stripe_api_options
)
end
def retrieve_pricing_plan_subscription(subscription_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
stripe_api_options
)
end
def retrieve_pricing_plan(pricing_plan_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plans/#{pricing_plan_id}",
{},
stripe_api_options
)
end
def retrieve_billing_cadence(cadence_id)
StripeV2Client.request(
:get,
"/v2/billing/cadences/#{cadence_id}",
{},
stripe_api_options
)
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 update_account_plan(new_pricing_plan_id, quantity)
attributes = {
'pending_stripe_pricing_plan_id' => new_pricing_plan_id,
'pending_subscription_quantity' => quantity,
'next_billing_date' => @next_billing_date
}
# Add plan name from catalog
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
attributes['plan_name'] = plan_definition[:display_name] if plan_definition
update_custom_attributes(attributes)
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 extract_plan_name(plan_definition)
# Extract plan name like "Startup", "Business", or "Enterprise" from display_name
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
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
end
def success_response(new_pricing_plan_id, quantity)
def calculate_proration(old_plan_id:, new_plan_id:, old_quantity:, new_quantity:, next_billing_date:)
old_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(old_plan_id)&.dig(:base_fee) || 0.0
new_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(new_plan_id)&.dig(:base_fee) || 0.0
Enterprise::Billing::V2::ProrationCalculator.calculate(
old_plan_price: old_plan_price,
new_plan_price: new_plan_price,
old_quantity: old_quantity,
new_quantity: new_quantity,
next_billing_date: next_billing_date
)
end
def create_and_charge_invoice(line_items)
# Return success with no invoice if no line items (negligible proration)
return { success: true, amount: 0.0, message: 'No charges due to negligible proration' } if line_items.empty?
# Use common invoice payment service
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
payment_service.create_and_pay_invoice(
line_items: line_items,
description: 'Proration charges for plan/seat changes',
metadata: {
account_id: account.id.to_s,
type: 'proration'
}
)
end
def success_response(context, proration_data, line_items, invoice_result)
{
success: true,
pricing_plan_id: new_pricing_plan_id,
quantity: quantity,
message: 'Pricing plan changed successfully'
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 stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
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
def extract_attribute(object, key)
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
"#{base_message} - billing intent committed and invoice line items created"
end
end
@@ -5,6 +5,7 @@
#
class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::StripeV2ClientHelper
# Create a subscription checkout session
#
@@ -53,14 +54,7 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
#
def create_checkout_session
customer_id = custom_attribute('stripe_customer_id')
session = StripeV2Client.request(
:post,
'/v1/checkout/sessions',
checkout_session_params(customer_id),
stripe_api_options
)
session = super(checkout_session_params(customer_id), api_version: checkout_stripe_version)
build_success_response(session)
end
@@ -126,11 +120,4 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
redirect_url: session_url
}
end
def stripe_api_options
{
api_key: ENV.fetch('STRIPE_SECRET_KEY', nil),
stripe_version: '2025-08-27.preview;checkout_product_catalog_preview=v1'
}
end
end
@@ -0,0 +1,111 @@
class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::BaseService
# Common service for creating invoices with line items and charging immediately
# Used by both TopupService and ChangePlanService
#
# @param line_items [Array<Hash>] Array of line items: [{ amount:, description:, metadata: }]
# @param description [String] Description for the invoice
# @param currency [String] Currency code (default: 'usd')
# @param metadata [Hash] Metadata for the invoice
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
# 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?
customer = Stripe::Customer.retrieve(customer_id, stripe_api_options)
if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil?
return {
success: false,
message: 'No default payment method found. Please add a default payment method before making a purchase.'
}
end
nil
rescue Stripe::StripeError => e
Rails.logger.error("Failed to check payment method: #{e.message}")
{ success: false, message: "Error validating payment method: #{e.message}" }
end
# Create invoice with line items and charge immediately
#
# @param line_items [Array<Hash>] Line items: [{ amount: (cents), description:, metadata: }]
# @param description [String] Invoice description
# @param currency [String] Currency (default: 'usd')
# @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)
finalize_and_pay_invoice(invoice.id)
rescue Stripe::StripeError => e
Rails.logger.error("Error creating invoice: #{e.message}")
{ success: false, message: "Error creating invoice: #{e.message}" }
end
private
def create_invoice(customer_id, currency, description, metadata)
Stripe::Invoice.create({
customer: customer_id,
currency: currency,
collection_method: 'charge_automatically',
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)
line_items.each do |item|
Stripe::InvoiceItem.create({
customer: customer_id,
amount: item[:amount],
currency: currency,
invoice: invoice_id,
description: item[:description],
metadata: (item[:metadata] || {}).stringify_keys
}, stripe_api_options)
end
end
# Finalize invoice and pay it immediately
# @param invoice_id [String] Stripe invoice ID
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
def finalize_and_pay_invoice(invoice_id)
# Finalize the invoice
finalized_invoice = Stripe::Invoice.finalize_invoice(
invoice_id,
{ auto_advance: false },
stripe_api_options
)
# 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)
build_invoice_response(paid_invoice)
end
rescue Stripe::StripeError => e
Rails.logger.error("Error finalizing/paying invoice: #{e.message}")
{ success: false, message: "Error processing payment: #{e.message}" }
end
def build_invoice_response(invoice)
{
success: true,
invoice_id: invoice.id,
invoice_url: invoice.hosted_invoice_url,
amount: invoice.total / 100.0,
status: invoice.status
}
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
end
@@ -1,4 +1,6 @@
class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::StripeV2ClientHelper
def add_license_fee_component(plan, config)
licensed_item = create_licensed_item(
display_name: config[:licensed_item_display_name],
@@ -62,39 +64,24 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing
private
def create_licensed_item(display_name:, lookup_key:, unit_label:)
StripeV2Client.request(
:post,
'/v2/billing/licensed_items',
{ display_name: display_name, lookup_key: lookup_key, unit_label: unit_label },
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
super({ display_name: display_name, lookup_key: lookup_key, unit_label: unit_label })
end
def create_license_fee(display_name:, unit_amount:, licensed_item_id:, lookup_key:)
StripeV2Client.request(
:post,
'/v2/billing/license_fees',
{
display_name: display_name,
currency: 'usd',
service_interval: 'month',
service_interval_count: 1,
tax_behavior: 'exclusive',
unit_amount: unit_amount.to_s,
licensed_item: licensed_item_id,
lookup_key: lookup_key
},
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
super({
display_name: display_name,
currency: 'usd',
service_interval: 'month',
service_interval_count: 1,
tax_behavior: 'exclusive',
unit_amount: unit_amount.to_s,
licensed_item: licensed_item_id,
lookup_key: lookup_key
})
end
def create_service_action(lookup_key:, credit_amount:, cpu_id:)
StripeV2Client.request(
:post,
'/v2/billing/service_actions',
service_action_params(lookup_key, credit_amount, cpu_id),
stripe_api_options
)
super(service_action_params(lookup_key, credit_amount, cpu_id))
end
def service_action_params(lookup_key, credit_amount, cpu_id)
@@ -119,44 +106,25 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing
}
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
def create_rate_card(display_name:)
StripeV2Client.request(
:post,
'/v2/billing/rate_cards',
{
display_name: display_name,
currency: 'usd',
service_interval: 'month',
service_interval_count: 1,
tax_behavior: 'exclusive'
},
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
super({
display_name: display_name,
currency: 'usd',
service_interval: 'month',
service_interval_count: 1,
tax_behavior: 'exclusive'
})
end
def create_metered_item(display_name:, lookup_key:, meter_id:)
StripeV2Client.request(
:post,
'/v2/billing/metered_items',
{ display_name: display_name, lookup_key: lookup_key, meter: meter_id },
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
super({ display_name: display_name, lookup_key: lookup_key, meter: meter_id })
end
def add_rate(card_id:, item_id:, cpu_id:, value:)
StripeV2Client.request(
:post,
"/v2/billing/rate_cards/#{card_id}/rates",
{
metered_item: item_id,
custom_pricing_unit_amount: { id: cpu_id, value: value.to_s }
},
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
add_rate_to_card(card_id, {
metered_item: item_id,
custom_pricing_unit_amount: { id: cpu_id, value: value.to_s }
})
end
def add_component(plan_id:, type:, data:, lookup_key:)
@@ -169,11 +137,6 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing
{ type: 'rate_card', rate_card: data, lookup_key: lookup_key }
end
StripeV2Client.request(
:post,
"/v2/billing/pricing_plans/#{plan_id}/components",
params,
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
add_pricing_plan_component(plan_id, params)
end
end
@@ -1,11 +1,8 @@
class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::StripeV2ClientHelper
def create_custom_pricing_unit(display_name:, lookup_key:)
StripeV2Client.request(
:post,
'/v2/billing/custom_pricing_units',
{ display_name: display_name, lookup_key: lookup_key },
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
super({ display_name: display_name, lookup_key: lookup_key })
end
def create_meter(display_name:, event_name:)
@@ -17,21 +14,11 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas
'customer_mapping[event_payload_key]' => 'stripe_customer_id',
'value_settings[event_payload_key]' => 'value'
}
StripeV2Client.request(
:post,
'/v1/billing/meters',
params,
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
super(params)
end
def create_pricing_plan(display_name:, lookup_key:, currency: 'usd', tax_behavior: 'exclusive')
StripeV2Client.request(
:post,
'/v2/billing/pricing_plans',
{ display_name: display_name, currency: currency, tax_behavior: tax_behavior, lookup_key: lookup_key },
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
super({ display_name: display_name, currency: currency, tax_behavior: tax_behavior, lookup_key: lookup_key })
end
def create_complete_pricing_plan(config)
@@ -103,12 +90,7 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas
end
def make_plan_version_live(plan_id)
StripeV2Client.request(
:post,
"/v2/billing/pricing_plans/#{plan_id}",
{ live_version: 'latest' },
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
update_pricing_plan(plan_id, { live_version: 'latest' })
end
def build_plan_result(plan, cpu, meter)
@@ -0,0 +1,99 @@
# rubocop:disable Style/ClassAndModuleChildren
module Enterprise
module Billing
module V2
class ProrationCalculator
# Calculate prorated amounts for subscription changes
#
# @param old_plan_price [Float] Price per unit of the old plan
# @param new_plan_price [Float] Price per unit of the new plan
# @param old_quantity [Integer] Old quantity/seats
# @param new_quantity [Integer] New quantity/seats
# @param next_billing_date [String/Time] Next billing date (ISO 8601)
# @return [Hash] { credit_amount:, charge_amount:, net_amount:, days_remaining:, total_days: }
#
def self.calculate(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:)
new(
old_plan_price: old_plan_price,
new_plan_price: new_plan_price,
old_quantity: old_quantity,
new_quantity: new_quantity,
next_billing_date: next_billing_date
).calculate
end
def initialize(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:)
@old_plan_price = old_plan_price.to_f
@new_plan_price = new_plan_price.to_f
@old_quantity = old_quantity.to_i
@new_quantity = new_quantity.to_i
@next_billing_date = parse_date(next_billing_date)
@current_date = Time.zone.now
end
def calculate
{
credit_amount: credit_amount,
charge_amount: charge_amount,
net_amount: net_amount,
days_remaining: days_remaining,
total_days: total_days,
proration_factor: proration_factor
}
end
private
def parse_date(date)
return date if date.is_a?(Time) || date.is_a?(DateTime)
Time.zone.parse(date.to_s)
rescue StandardError
raise ArgumentError, "Invalid next_billing_date format: #{date}"
end
# Credit from unused time on old plan
def credit_amount
@credit_amount ||= (@old_plan_price * @old_quantity * proration_factor).round(2)
end
# Charge for new plan for remaining time
def charge_amount
@charge_amount ||= (@new_plan_price * @new_quantity * proration_factor).round(2)
end
# Net amount to charge (positive) or credit (negative)
def net_amount
@net_amount ||= (charge_amount - credit_amount).round(2)
end
# Number of days remaining in current billing period
def days_remaining
@days_remaining ||= (@next_billing_date.to_date - @current_date.to_date).to_i
end
# Total days in the actual billing period (calculate from current cycle)
# This ensures proration factor never exceeds 1.0
def total_days
@total_days ||= begin
# Calculate the total days in this billing cycle
# Assume billing started one month ago from next_billing_date
billing_start = @next_billing_date - 1.month
((@next_billing_date.to_date - billing_start.to_date).to_i)
end
end
# Fraction of billing period remaining
# This value should always be between 0.0 and 1.0
def proration_factor
@proration_factor ||= begin
factor = days_remaining.to_f / total_days
# Ensure factor never exceeds 1.0
[factor, 1.0].min.round(4)
end
end
end
end
end
end
# rubocop:enable Style/ClassAndModuleChildren
@@ -1,5 +1,6 @@
class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::StripeV2ClientHelper
def provision(subscription_id:)
# Retrieve pricing plan subscription details from Stripe V2 API
@@ -77,15 +78,6 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
reset_captain_usage
end
def retrieve_pricing_plan_subscription(subscription_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
stripe_api_options
)
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']
@@ -163,8 +155,4 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
# e.g., "Chatwoot Startup" -> "Startup"
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
end
@@ -27,11 +27,10 @@ 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
unless customer_has_payment_method?
return { valid: false, success: false,
message: 'No default payment method found. Please add a default payment method before making a purchase.' }
end
# 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
{ valid: true, topup_definition: topup_definition }
end
@@ -51,33 +50,40 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
{ valid: true }
end
def customer_has_payment_method?
customer = Stripe::Customer.retrieve(stripe_customer_id, stripe_api_options)
# Check if customer has a default payment method or any payment methods attached
customer.invoice_settings&.default_payment_method.present? || customer.default_source.present?
rescue Stripe::StripeError => e
Rails.logger.error("Failed to check payment method: #{e.message}")
false
end
def process_topup_transaction(credits, amount_cents, currency, amount)
invoice = create_topup_invoice(currency)
return { success: false, message: 'Failed to create invoice' } unless invoice
invoice_item = create_topup_invoice_item(invoice.id, amount_cents, currency, credits)
return { success: false, message: 'Failed to create invoice item' } unless invoice_item
finalized_invoice = finalize_topup_invoice(invoice.id)
return { success: false, message: 'Failed to finalize invoice' } unless finalized_invoice
paid_invoice = pay_invoice(invoice.id)
return { success: false, message: 'Failed to pay invoice' } unless paid_invoice
line_items = build_topup_line_items(credits, amount_cents)
invoice_result = charge_topup_invoice(line_items, currency)
return invoice_result unless invoice_result[:success]
credit_grant = create_stripe_credit_grant(amount_cents, currency, credits)
return { success: false, message: 'Failed to create credit grant in Stripe' } unless credit_grant
# Credits will be added by webhook when Stripe sends billing.credit_grant.created event
build_success_response(credits, amount, currency, invoice.id, credit_grant['id'])
build_success_response(credits, amount, currency, invoice_result[:invoice_id], credit_grant['id'])
end
def build_topup_line_items(credits, amount_cents)
[{
amount: amount_cents,
description: "Credit Topup: #{credits} credits",
metadata: {
account_id: account.id.to_s,
credits: credits.to_s,
topup: 'true'
}
}]
end
def charge_topup_invoice(line_items, currency)
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
payment_service.create_and_pay_invoice(
line_items: line_items,
description: 'Credit top-up purchase',
currency: currency,
metadata: {
account_id: account.id.to_s,
topup: 'true'
}
)
end
def build_success_response(credits, amount, currency, invoice_id, credit_grant_id)
@@ -92,60 +98,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
}
end
# Create Invoice following Stripe UBB Integration Guide
def create_topup_invoice(currency)
Stripe::Invoice.create(
{
customer: stripe_customer_id,
currency: currency,
collection_method: 'charge_automatically',
auto_advance: false, # We'll finalize it manually
metadata: {
account_id: account.id.to_s,
topup: 'true'
}
},
stripe_api_options
)
end
# Create Invoice Item with topup amount
def create_topup_invoice_item(invoice_id, amount_cents, currency, credits)
Stripe::InvoiceItem.create(
{
customer: stripe_customer_id,
amount: amount_cents,
currency: currency,
invoice: invoice_id,
description: "Credit Topup: #{credits} credits",
metadata: {
account_id: account.id.to_s,
credits: credits.to_s,
topup: 'true'
}
},
stripe_api_options
)
end
# Finalize Invoice for payment
def finalize_topup_invoice(invoice_id)
Stripe::Invoice.finalize_invoice(
invoice_id,
{ auto_advance: false }, # We'll pay it explicitly
stripe_api_options
)
end
# Pay the invoice explicitly
def pay_invoice(invoice_id)
Stripe::Invoice.pay(
invoice_id,
{},
stripe_api_options
)
end
# Create Credit Grant in Stripe using monetary amount (not custom_pricing_unit)
# Following Stripe UBB Integration Guide section 8
def create_stripe_credit_grant(amount_cents, currency, credits)
@@ -1,4 +1,6 @@
class Enterprise::Billing::V2::WebhookHandlerService
include Enterprise::Billing::Concerns::StripeV2ClientHelper
def perform(event:)
@event = event
return { success: false, message: 'Event is required' } if @event.blank?
@@ -37,20 +39,10 @@ class Enterprise::Billing::V2::WebhookHandlerService
end
def fetch_customer_id_from_subscription(subscription_id)
subscription = StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
stripe_api_options
)
subscription = retrieve_pricing_plan_subscription(subscription_id)
return nil unless subscription&.billing_cadence
cadence = StripeV2Client.request(
:get,
"/v2/billing/cadences/#{subscription.billing_cadence}",
{},
stripe_api_options
)
cadence = retrieve_billing_cadence(subscription.billing_cadence)
cadence.payer&.customer
end
@@ -65,8 +57,4 @@ class Enterprise::Billing::V2::WebhookHandlerService
.new(account: account)
.refresh
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
end