Compare commits

..
10 changed files with 245 additions and 15 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
@@ -46,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
@@ -111,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,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