Compare commits

..
11 changed files with 398 additions and 16 deletions
+17
View File
@@ -58,4 +58,21 @@ class AccountPolicy < ApplicationPolicy
def change_pricing_plan?
@account_user.administrator?
end
# V2 Billing API actions
def pricing_plans?
@account_user.administrator?
end
def topup_options?
@account_user.administrator?
end
def topup?
@account_user.administrator?
end
def subscribe?
@account_user.administrator?
end
end
@@ -5,8 +5,10 @@ if resource.custom_attributes.present?
json.plan_name resource.custom_attributes['plan_name']
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
json.subscription_status resource.custom_attributes['subscription_status']
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
json.subscription_ends_at resource.custom_attributes['subscription_ends_at']
json.subscription_cancelled_at resource.custom_attributes['subscription_cancelled_at'] if resource.custom_attributes['subscription_cancelled_at'].present?
json.stripe_subscription_id resource.custom_attributes['stripe_subscription_id'] if resource.custom_attributes['stripe_subscription_id'].present?
json.stripe_plan_id resource.custom_attributes['stripe_plan_id'] if resource.custom_attributes['stripe_plan_id'].present?
json.stripe_billing_version resource.custom_attributes['stripe_billing_version'] if resource.custom_attributes['stripe_billing_version'].present?
json.stripe_customer_id resource.custom_attributes['stripe_customer_id'] if resource.custom_attributes['stripe_customer_id'].present?
if resource.custom_attributes['pending_stripe_pricing_plan_id'].present?
+1 -1
View File
@@ -437,7 +437,7 @@ Rails.application.routes.draw do
namespace :v2 do
resources :accounts, only: [] do
resource :billing, only: [] do
resource :billing, only: [], controller: 'billing' do
get :credit_grants
get :pricing_plans
get :topup_options
+8
View File
@@ -0,0 +1,8 @@
# Stripe V2 Billing Scheduled Jobs
# Add these to your config/sidekiq_cron.yml or config/schedule.yml
v2_credit_sync:
cron: "0 * * * *" # Every hour
class: "Enterprise::Billing::CreditSyncJob"
queue: low
description: "Sync V2 billing credits with Stripe"
@@ -1,6 +1,6 @@
class Enterprise::Api::V2::BillingController < Api::BaseController
before_action :fetch_account
before_action :check_authorization
before_action :check_billing_authorization
before_action :validate_topup_amount, only: [:topup]
rescue_from StandardError, with: :render_error
@@ -24,7 +24,14 @@ class Enterprise::Api::V2::BillingController < Api::BaseController
end
def topup
raise NotImplementedError, 'Topup functionality not yet implemented'
service = Enterprise::Billing::V2::TopupService.new(account: @account)
result = service.create_topup(credits: params[:credits].to_i)
if result[:success]
render json: { success: true, message: result[:message] }
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def subscribe
@@ -39,7 +46,10 @@ class Enterprise::Api::V2::BillingController < Api::BaseController
def cancel_subscription
service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account)
result = service.cancel_subscription
result = service.cancel_subscription(
reason: params[:reason],
feedback: params[:feedback]
)
if result[:success]
# Include account ID and updated attributes for frontend store update
@@ -104,4 +114,8 @@ class Enterprise::Api::V2::BillingController < Api::BaseController
def render_not_implemented(exception)
render json: { error: exception.message }, status: :not_implemented
end
def check_billing_authorization
authorize(@account, "#{action_name}?".to_sym)
end
end
@@ -0,0 +1,96 @@
class Enterprise::Billing::CreditSyncJob < ApplicationJob
queue_as :low
def perform(account = nil)
if account
sync_single_account(account)
else
sync_all_accounts
end
end
private
def sync_all_accounts
Rails.logger.info '[CreditSyncJob] Starting credit sync for all accounts'
accounts_with_stripe = Account.where(
"custom_attributes->>'stripe_customer_id' IS NOT NULL AND (custom_attributes->>'stripe_billing_version')::integer = 2"
)
synced_count = 0
failed_count = 0
accounts_with_stripe.find_each do |account|
result = sync_account_credits(account)
if result[:success]
synced_count += 1 if result[:credits_reported].to_i.positive?
else
failed_count += 1
Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}"
end
end
Rails.logger.info "[CreditSyncJob] Completed. Synced: #{synced_count}, Failed: #{failed_count}"
{ synced: synced_count, failed: failed_count }
end
def sync_single_account(account)
Rails.logger.info "[CreditSyncJob] Syncing credits for account #{account.id}"
result = sync_account_credits(account)
if result[:success]
Rails.logger.info "[CreditSyncJob] Successfully synced account #{account.id}"
else
Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}"
end
result
end
def sync_account_credits(account)
consumed_credits = account.custom_attributes&.[]('captain_responses_usage').to_i
last_synced_credits = account.custom_attributes&.[]('stripe_last_synced_credits').to_i
credits_to_report = consumed_credits - last_synced_credits
if credits_to_report.positive?
handle_positive_credits(account, credits_to_report, consumed_credits)
elsif credits_to_report.negative?
handle_negative_credits(account, credits_to_report, consumed_credits)
else
{ success: true, message: 'Already in sync', credits_reported: 0 }
end
rescue StandardError => e
handle_sync_error(account, e)
end
def handle_positive_credits(account, credits_to_report, consumed_credits)
reporter = Enterprise::Billing::V2::UsageReporterService.new(account: account)
result = reporter.report(credits_to_report)
return result unless result[:success]
update_last_synced_credits(account, consumed_credits)
Rails.logger.info "[CreditSyncJob] Account #{account.id}: reported #{credits_to_report} credits (total: #{consumed_credits})"
result.merge(credits_reported: credits_to_report)
end
def handle_negative_credits(account, credits_to_report, consumed_credits)
Rails.logger.warn "[CreditSyncJob] Account #{account.id} has negative difference: #{credits_to_report}"
update_last_synced_credits(account, consumed_credits)
{ success: true, message: 'Reset sync point due to negative difference', credits_reported: 0 }
end
def handle_sync_error(account, error)
Rails.logger.error "[CreditSyncJob] Error syncing account #{account.id}: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
{ success: false, message: error.message }
end
def update_last_synced_credits(account, credits)
account.with_lock do
current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {}
current_attributes['stripe_last_synced_credits'] = credits
account.update!(custom_attributes: current_attributes)
end
end
end
@@ -8,14 +8,16 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
# Creates a deactivate billing intent for the pricing plan subscription
# Subscription remains active until the end of the current billing period
#
# @param reason [String] Optional cancellation reason
# @param feedback [String] Optional additional feedback
# @return [Hash] { success:, cancel_at_period_end:, period_end:, message: }
#
def cancel_subscription
def cancel_subscription(reason: nil, feedback: nil)
with_locked_account do
metadata = fetch_subscription_metadata
intent_params = build_deactivate_params(metadata[:subscription_id], metadata[:cadence_id])
execute_billing_intent(intent_params)
update_account_status(metadata[:next_billing_date])
update_account_status(metadata[:next_billing_date], reason: reason, feedback: feedback)
build_success_response
end
rescue Stripe::StripeError => e
@@ -40,14 +42,18 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
}
end
def update_account_status(next_billing_date)
def update_account_status(next_billing_date, reason: nil, feedback: nil)
# Mark subscription as cancelling (will be cancelled at period end)
# Store next_billing_date so the UI can show when the subscription ends
update_custom_attributes({
'subscription_status' => 'cancel_at_period_end',
'subscription_cancelled_at' => Time.current.iso8601,
'subscription_ends_at' => next_billing_date
})
attributes = {
'subscription_status' => 'cancel_at_period_end',
'subscription_cancelled_at' => Time.current.iso8601,
'subscription_ends_at' => next_billing_date
}
attributes['cancellation_reason'] = reason if reason.present?
attributes['cancellation_feedback'] = feedback if feedback.present?
update_custom_attributes(attributes)
end
def build_success_response
@@ -1,9 +1,9 @@
module Enterprise::Billing::V2::TopupCatalog
DEFAULT_TOPUPS = [
{ credits: 1000, amount: 20.0 },
{ credits: 2000, amount: 40.0 },
{ credits: 3000, amount: 60.0 },
{ credits: 4000, amount: 80.0 }
{ credits: 2500, amount: 50.0 },
{ credits: 5000, amount: 100.0 },
{ credits: 10_000, amount: 200.0 }
].freeze
module_function
@@ -0,0 +1,145 @@
class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseService
def create_topup(credits:)
validation_result = validate_topup_request(credits)
return validation_result unless validation_result[:valid]
topup_definition = validation_result[:topup_definition]
amount_cents = (topup_definition[:amount] * 100).to_i
currency = topup_definition[:currency] || 'usd'
with_locked_account do
process_topup_transaction(credits, amount_cents, currency, topup_definition[:amount])
end
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
end
private
def validate_topup_request(credits)
return { valid: false, success: false, message: 'Invalid topup amount' } unless credits.to_i.positive?
plan_validation = validate_subscription_plan
return plan_validation unless plan_validation[:valid]
topup_definition = Enterprise::Billing::V2::TopupCatalog.find_option(credits)
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?
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
def validate_subscription_plan
plan_name = custom_attribute('plan_name')
# Block topup if no plan or on Hacker plan
if plan_name.blank? || plan_name.downcase == 'hacker'
return {
valid: false,
success: false,
message: 'Top-ups are only available for Startup, Business, and Enterprise plans. Please upgrade your plan to purchase credits.'
}
end
{ valid: true }
end
def process_topup_transaction(credits, amount_cents, currency, amount)
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
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)
{
success: true,
message: 'Top-up purchased successfully',
credits: credits,
amount: amount,
currency: currency,
invoice_id: invoice_id,
credit_grant_id: credit_grant_id
}
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)
Stripe::Billing::CreditGrant.create(
credit_grant_params(amount_cents, currency, credits)
)
end
def credit_grant_params(amount_cents, currency, credits)
{
customer: stripe_customer_id,
name: "Topup: #{credits} credits",
amount: credit_grant_amount(amount_cents, currency),
applicability_config: credit_grant_applicability,
category: 'paid',
metadata: credit_grant_metadata(credits)
}
end
def credit_grant_amount(amount_cents, currency)
{
type: 'monetary',
monetary: {
currency: currency,
value: amount_cents
}
}
end
def credit_grant_applicability
# Apply credit grant to all metered usage for this customer
# This ensures topup credits offset meter-based billing
{
scope: {
price_type: 'metered'
}
}
end
def credit_grant_metadata(credits)
{
account_id: account.id.to_s,
source: 'topup',
credits: credits.to_s
}
end
end
@@ -0,0 +1,35 @@
class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::BaseService
def report(credits_used, _feature = nil)
return { success: false, message: 'Missing Stripe configuration' } unless valid_configuration?
event = Stripe::Billing::MeterEvent.create(
meter_event_params(credits_used),
stripe_api_options
)
{ success: true, event_id: event.identifier }
rescue Stripe::StripeError => e
{ success: false, message: e.message }
end
private
def valid_configuration?
stripe_customer_id.present?
end
def meter_event_params(credits_used)
{
event_name: 'chatwoot.usage',
payload: {
value: credits_used.to_s,
stripe_customer_id: stripe_customer_id
},
identifier: "#{account.id}_#{Time.current.to_i}_#{SecureRandom.hex(4)}"
}
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
end
@@ -0,0 +1,59 @@
require 'rails_helper'
RSpec.describe Enterprise::Billing::CreditSyncJob, type: :job do
include ActiveJob::TestHelper
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return('sk_test_123')
allow(InstallationConfig).to receive(:find_by).and_call_original
allow(InstallationConfig).to receive(:find_by).with(name: 'STRIPE_METER_ID')
.and_return(instance_double(InstallationConfig, value: 'mtr_test_123'))
end
describe '#perform' do
context 'with no arguments' do
let!(:account) do
create(:account, custom_attributes: {
'stripe_customer_id' => 'cus_123',
'stripe_billing_version' => 2,
'captain_responses_usage' => 100,
'stripe_last_synced_credits' => 50
})
end
it 'syncs all accounts with Stripe customer ID' do
usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService)
allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter)
allow(usage_reporter).to receive(:report).with(50).and_return({ success: true, event_id: 'evt_123' })
result = described_class.new.perform
expect(result).to eq({ synced: 1, failed: 0 })
expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100)
end
end
context 'with account argument' do
let(:account) do
create(:account, custom_attributes: {
'stripe_customer_id' => 'cus_123',
'captain_responses_usage' => 100,
'stripe_last_synced_credits' => 30
})
end
it 'syncs single account' do
usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService)
allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter)
allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_123' })
result = described_class.new.perform(account)
expect(result[:success]).to be true
expect(result[:credits_reported]).to eq(70)
expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100)
end
end
end
end