create and pay an invoice on subscription change
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user