Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60aac8d731 | ||
|
|
bfdd5d2642 | ||
|
|
df0daebc99 |
@@ -2,11 +2,11 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
include BillingHelper
|
||||
before_action :fetch_account
|
||||
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
|
||||
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)
|
||||
end
|
||||
head :no_content
|
||||
@@ -98,6 +98,19 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
@account.custom_attributes['stripe_customer_id']
|
||||
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
|
||||
reason = 'manual_deletion'
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
process_subscription_updated
|
||||
when 'customer.subscription.deleted'
|
||||
process_subscription_deleted
|
||||
when 'invoice.paid', 'invoice.payment_succeeded'
|
||||
process_invoice_paid
|
||||
else
|
||||
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
||||
end
|
||||
@@ -74,6 +76,12 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
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)
|
||||
current_limits = account.limits || {}
|
||||
|
||||
@@ -109,6 +117,10 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
@subscription ||= @event.data.object
|
||||
end
|
||||
|
||||
def invoice
|
||||
@invoice ||= @event.data.object
|
||||
end
|
||||
|
||||
def previous_attributes
|
||||
@previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json)
|
||||
end
|
||||
@@ -132,6 +144,10 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
|
||||
end
|
||||
|
||||
def invoice_account
|
||||
@invoice_account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", invoice.customer).first
|
||||
end
|
||||
|
||||
def find_plan(plan_id)
|
||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
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!(: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
|
||||
context 'when it is an unauthenticated user' 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)
|
||||
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
|
||||
account.update!(custom_attributes: { is_creating_customer: true })
|
||||
|
||||
@@ -79,6 +98,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
end
|
||||
|
||||
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
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/checkout",
|
||||
headers: admin.create_new_auth_token,
|
||||
@@ -91,6 +114,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
before do
|
||||
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }])
|
||||
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
@@ -158,8 +185,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
before do
|
||||
create(:conversation, account: account)
|
||||
create(:channel_api, account: account)
|
||||
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }])
|
||||
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
|
||||
end
|
||||
|
||||
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') }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
|
||||
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
||||
])
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
|
||||
value: [
|
||||
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
|
||||
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
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
|
||||
before do
|
||||
# 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
|
||||
|
||||
it 'returns not found' do
|
||||
@@ -358,7 +387,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
context 'when it is an admin' do
|
||||
before do
|
||||
# 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
|
||||
|
||||
it 'marks the account for deletion when action is delete' do
|
||||
|
||||
@@ -10,25 +10,23 @@ describe Enterprise::Billing::HandleStripeEventService do
|
||||
|
||||
before do
|
||||
# Create cloud plans configuration
|
||||
create(:installation_config, {
|
||||
name: 'CHATWOOT_CLOUD_PLANS',
|
||||
value: [
|
||||
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
|
||||
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
|
||||
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
|
||||
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
|
||||
]
|
||||
})
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
|
||||
value: [
|
||||
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
|
||||
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
|
||||
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
|
||||
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
|
||||
]
|
||||
)
|
||||
|
||||
create(:installation_config, {
|
||||
name: 'CAPTAIN_CLOUD_PLAN_LIMITS',
|
||||
value: {
|
||||
'hacker' => { 'responses' => 0 },
|
||||
'startups' => { 'responses' => 300 },
|
||||
'business' => { 'responses' => 500 },
|
||||
'enterprise' => { 'responses' => 800 }
|
||||
}
|
||||
})
|
||||
InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').first_or_initialize.update!(
|
||||
value: {
|
||||
'hacker' => { 'responses' => 0 },
|
||||
'startups' => { 'responses' => 300 },
|
||||
'business' => { 'responses' => 500 },
|
||||
'enterprise' => { 'responses' => 800 }
|
||||
}
|
||||
)
|
||||
# Setup common subscription mocks
|
||||
allow(event).to receive(:data).and_return(data)
|
||||
allow(data).to receive(:object).and_return(subscription)
|
||||
@@ -133,6 +131,25 @@ describe Enterprise::Billing::HandleStripeEventService do
|
||||
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
|
||||
context 'with default plan (Hacker)' 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
|
||||
Reference in New Issue
Block a user