Compare commits

...
6 changed files with 278 additions and 30 deletions
@@ -2,11 +2,11 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper include BillingHelper
before_action :fetch_account before_action :fetch_account
before_action :check_authorization before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion] before_action :check_cloud_env, only: [:checkout, :limits, :toggle_deletion]
def subscription def subscription
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@account.update(custom_attributes: { is_creating_customer: true }) @account.update(custom_attributes: subscription_custom_attributes)
Enterprise::CreateStripeCustomerJob.perform_later(@account) Enterprise::CreateStripeCustomerJob.perform_later(@account)
end end
head :no_content head :no_content
@@ -98,6 +98,19 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
@account.custom_attributes['stripe_customer_id'] @account.custom_attributes['stripe_customer_id']
end end
def subscription_custom_attributes
attributes = @account.custom_attributes.merge('is_creating_customer' => true)
attributes['billing_attribution'] = billing_attribution if billing_attribution.present?
attributes
end
def billing_attribution
{
'datafast_visitor_id' => cookies[:datafast_visitor_id],
'datafast_session_id' => cookies[:datafast_session_id]
}.compact
end
def mark_for_deletion def mark_for_deletion
reason = 'manual_deletion' reason = 'manual_deletion'
@@ -14,6 +14,8 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated process_subscription_updated
when 'customer.subscription.deleted' when 'customer.subscription.deleted'
process_subscription_deleted process_subscription_deleted
when 'invoice.paid', 'invoice.payment_succeeded'
process_invoice_paid
else else
Rails.logger.debug { "Unhandled event type: #{event.type}" } Rails.logger.debug { "Unhandled event type: #{event.type}" }
end end
@@ -74,6 +76,12 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end end
def process_invoice_paid
return if invoice_account.blank?
Enterprise::Billing::TrackPaymentAttributionService.new(account: invoice_account, invoice: invoice).perform
end
def handle_subscription_credits(plan, previous_usage) def handle_subscription_credits(plan, previous_usage)
current_limits = account.limits || {} current_limits = account.limits || {}
@@ -109,6 +117,10 @@ class Enterprise::Billing::HandleStripeEventService
@subscription ||= @event.data.object @subscription ||= @event.data.object
end end
def invoice
@invoice ||= @event.data.object
end
def previous_attributes def previous_attributes
@previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json) @previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json)
end end
@@ -132,6 +144,10 @@ class Enterprise::Billing::HandleStripeEventService
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first @account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end end
def invoice_account
@invoice_account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", invoice.customer).first
end
def find_plan(plan_id) def find_plan(plan_id)
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || [] cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
cloud_plans.find { |config| config['product_id'].include?(plan_id) } cloud_plans.find { |config| config['product_id'].include?(plan_id) }
@@ -0,0 +1,100 @@
class Enterprise::Billing::TrackPaymentAttributionService
pattr_initialize [:account!, :invoice!]
API_ENDPOINT = 'https://datafa.st/api/v1/payments'.freeze
API_KEY_CONFIG = 'DATAFAST_API_KEY'.freeze
ZERO_DECIMAL_CURRENCIES = %w[BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF UGX VND VUV XAF XOF XPF].freeze
def perform
return unless trackable?
response = HTTParty.post(
API_ENDPOINT,
headers: {
'Authorization' => "Bearer #{api_key}",
'Content-Type' => 'application/json'
},
body: payload.to_json,
timeout: 5
)
log_failure("#{response.code} #{response.body}") unless response.success?
rescue StandardError => e
log_failure("#{e.class} - #{e.message}")
end
private
def trackable?
ChatwootApp.chatwoot_cloud? && [api_key, datafast_visitor_id, amount_paid, currency, transaction_id].all?(&:present?)
end
def payload
{
amount: amount,
currency: currency.upcase,
transaction_id: transaction_id,
datafast_visitor_id: datafast_visitor_id,
email: customer_email,
name: customer_name,
customer_id: customer_id,
renewal: renewal?
}.compact
end
def amount
return amount_paid if ZERO_DECIMAL_CURRENCIES.include?(currency.upcase)
amount_paid.to_f / 100
end
def amount_paid
invoice_value('amount_paid')
end
def currency
invoice_value('currency')
end
def transaction_id
invoice_value('id')
end
def customer_id
invoice_value('customer') || account.custom_attributes['stripe_customer_id']
end
def customer_email
invoice_value('customer_email') || account.administrators.first&.email
end
def customer_name
invoice_value('customer_name') || account.name
end
def renewal?
invoice_value('billing_reason') == 'subscription_cycle'
end
def datafast_visitor_id
attribution['datafast_visitor_id']
end
def attribution
account.custom_attributes['billing_attribution'] || {}
end
def api_key
GlobalConfigService.load(API_KEY_CONFIG, nil)
end
def log_failure(message)
Rails.logger.warn("Payment attribution failed for invoice #{transaction_id}: #{message}")
end
def invoice_value(key)
invoice[key]
rescue NoMethodError
nil
end
end
@@ -5,6 +5,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
let!(:admin) { create(:user, account: account, role: :administrator) } let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) } let!(:agent) { create(:user, account: account, role: :agent) }
before do
allow(GlobalConfig).to receive(:get_value).and_call_original
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
@@ -35,6 +39,21 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
expect(account.reload.custom_attributes).to eq({ 'is_creating_customer': true }.with_indifferent_access) expect(account.reload.custom_attributes).to eq({ 'is_creating_customer': true }.with_indifferent_access)
end end
it 'stores billing attribution from request cookies' do
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
headers: admin.create_new_auth_token.merge(
'Cookie' => 'datafast_visitor_id=visitor-123; datafast_session_id=session-123'
),
as: :json
expect(account.reload.custom_attributes['billing_attribution']).to eq(
{
'datafast_visitor_id' => 'visitor-123',
'datafast_session_id' => 'session-123'
}
)
end
it 'does not enqueue a job if a job is already enqueued' do it 'does not enqueue a job if a job is already enqueued' do
account.update!(custom_attributes: { is_creating_customer: true }) account.update!(custom_attributes: { is_creating_customer: true })
@@ -79,6 +98,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end end
context 'when it is an admin and the stripe customer id is not present' do context 'when it is an admin and the stripe customer id is not present' do
before do
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
end
it 'returns error' do it 'returns error' do
post "/enterprise/api/v1/accounts/#{account.id}/checkout", post "/enterprise/api/v1/accounts/#{account.id}/checkout",
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
@@ -91,6 +114,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end end
context 'when it is an admin and the stripe customer is present' do context 'when it is an admin and the stripe customer is present' do
before do
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
end
it 'calls create session' do it 'calls create session' do
account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' }) account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' })
@@ -122,8 +149,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
before do before do
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud') allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }]) InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
end end
context 'when it is an agent' do context 'when it is an agent' do
@@ -158,8 +185,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
before do before do
create(:conversation, account: account) create(:conversation, account: account)
create(:channel_api, account: account) create(:channel_api, account: account)
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud') allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }]) InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
end end
it 'returns the limits if the plan is default' do it 'returns the limits if the plan is default' do
@@ -252,10 +279,12 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') } let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
before do before do
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [ InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] }, value: [
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] } { 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
]) { 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
]
)
end end
it 'returns unauthorized for unauthenticated user' do it 'returns unauthorized for unauthenticated user' do
@@ -341,7 +370,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when deployment environment is not cloud' do context 'when deployment environment is not cloud' do
before do before do
# Set deployment environment to something other than cloud # Set deployment environment to something other than cloud
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'self_hosted') allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('self_hosted')
end end
it 'returns not found' do it 'returns not found' do
@@ -358,7 +387,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when it is an admin' do context 'when it is an admin' do
before do before do
# Create the installation config for cloud environment # Create the installation config for cloud environment
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
end end
it 'marks the account for deletion when action is delete' do it 'marks the account for deletion when action is delete' do
@@ -10,25 +10,23 @@ describe Enterprise::Billing::HandleStripeEventService do
before do before do
# Create cloud plans configuration # Create cloud plans configuration
create(:installation_config, { InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
name: 'CHATWOOT_CLOUD_PLANS', value: [
value: [ { 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] }, { 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }, { 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] }, { 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] } ]
] )
})
create(:installation_config, { InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').first_or_initialize.update!(
name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: {
value: { 'hacker' => { 'responses' => 0 },
'hacker' => { 'responses' => 0 }, 'startups' => { 'responses' => 300 },
'startups' => { 'responses' => 300 }, 'business' => { 'responses' => 500 },
'business' => { 'responses' => 500 }, 'enterprise' => { 'responses' => 800 }
'enterprise' => { 'responses' => 800 } }
} )
})
# Setup common subscription mocks # Setup common subscription mocks
allow(event).to receive(:data).and_return(data) allow(event).to receive(:data).and_return(data)
allow(data).to receive(:object).and_return(subscription) allow(data).to receive(:object).and_return(subscription)
@@ -133,6 +131,25 @@ describe Enterprise::Billing::HandleStripeEventService do
end end
end end
describe 'invoice payment handling' do
let(:invoice) { instance_double(Stripe::Invoice, customer: 'cus_123') }
it 'tracks payment attribution on paid invoices' do
allow(event).to receive(:type).and_return('invoice.paid')
allow(data).to receive(:object).and_return(invoice)
attribution_service = instance_double(Enterprise::Billing::TrackPaymentAttributionService, perform: true)
allow(Enterprise::Billing::TrackPaymentAttributionService)
.to receive(:new)
.with(account: account, invoice: invoice)
.and_return(attribution_service)
stripe_event_service.new.perform(event: event)
expect(attribution_service).to have_received(:perform)
end
end
describe 'plan-specific feature management' do describe 'plan-specific feature management' do
context 'with default plan (Hacker)' do context 'with default plan (Hacker)' do
it 'disables all premium features' do it 'disables all premium features' do
@@ -0,0 +1,73 @@
require 'rails_helper'
RSpec.describe Enterprise::Billing::TrackPaymentAttributionService do
subject(:service) { described_class.new(account: account, invoice: invoice) }
let(:account) do
create(
:account,
custom_attributes: {
'stripe_customer_id' => 'cus_123',
'billing_attribution' => {
'datafast_visitor_id' => 'visitor-123'
}
}
)
end
let!(:admin) { create(:user, account: account, role: :administrator, email: 'admin@example.com') }
let(:invoice) do
{
'id' => 'in_123',
'amount_paid' => 2900,
'currency' => 'usd',
'customer' => 'cus_123',
'customer_name' => 'Acme Finance',
'billing_reason' => 'subscription_create'
}
end
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(HTTParty).to receive(:post).and_return(instance_double(HTTParty::Response, success?: true))
allow(GlobalConfigService).to receive(:load).with('DATAFAST_API_KEY', nil).and_return('test-key')
end
it 'sends payment attribution to the payment API' do
service.perform
expect(HTTParty).to have_received(:post).with(
'https://datafa.st/api/v1/payments',
headers: {
'Authorization' => 'Bearer test-key',
'Content-Type' => 'application/json'
},
body: {
amount: 29.0,
currency: 'USD',
transaction_id: 'in_123',
datafast_visitor_id: 'visitor-123',
email: admin.email,
name: 'Acme Finance',
customer_id: 'cus_123',
renewal: false
}.to_json,
timeout: 5
)
end
it 'skips the API call when attribution is unavailable' do
account.update!(custom_attributes: { 'stripe_customer_id' => 'cus_123' })
service.perform
expect(HTTParty).not_to have_received(:post)
end
it 'skips the API call when the installation config is unavailable' do
allow(GlobalConfigService).to receive(:load).with('DATAFAST_API_KEY', nil).and_return(nil)
service.perform
expect(HTTParty).not_to have_received(:post)
end
end