Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2_fe
This commit is contained in:
@@ -69,7 +69,6 @@ class Account < ApplicationRecord
|
||||
has_many :categories, dependent: :destroy_async, class_name: '::Category'
|
||||
has_many :contacts, dependent: :destroy_async
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :credit_transactions, dependent: :destroy_async
|
||||
has_many :csat_survey_responses, dependent: :destroy_async
|
||||
has_many :custom_attribute_definitions, dependent: :destroy_async
|
||||
has_many :custom_filters, dependent: :destroy_async
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: credit_transactions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# amount :integer not null
|
||||
# credit_type :string not null
|
||||
# description :string
|
||||
# metadata :json
|
||||
# transaction_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_credit_transactions_on_account_id (account_id)
|
||||
# index_credit_transactions_on_account_id_and_created_at (account_id,created_at)
|
||||
#
|
||||
class CreditTransaction < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validates :amount, presence: true, numericality: true
|
||||
validates :transaction_type, presence: true, inclusion: { in: %w[grant expire use topup refund] }
|
||||
validates :credit_type, presence: true, inclusion: { in: %w[monthly topup mixed] }
|
||||
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
class CreateCreditTransactions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :credit_transactions do |t|
|
||||
t.references :account, null: false
|
||||
t.string :transaction_type, null: false
|
||||
t.integer :amount, null: false
|
||||
t.string :credit_type, null: false
|
||||
t.string :description
|
||||
t.jsonb :metadata, default: {}
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :credit_transactions, [:account_id, :created_at]
|
||||
end
|
||||
end
|
||||
@@ -709,19 +709,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do
|
||||
t.index ["user_id"], name: "index_copilot_threads_on_user_id"
|
||||
end
|
||||
|
||||
create_table "credit_transactions", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "transaction_type", null: false
|
||||
t.integer "amount", null: false
|
||||
t.string "credit_type", null: false
|
||||
t.string "description"
|
||||
t.json "metadata"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "created_at"], name: "index_credit_transactions_on_account_id_and_created_at"
|
||||
t.index ["account_id"], name: "index_credit_transactions_on_account_id"
|
||||
end
|
||||
|
||||
create_table "csat_survey_responses", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
|
||||
@@ -13,9 +13,7 @@ module Enterprise::Api::V1::Accounts::Concerns::BillingV2
|
||||
id: @account.id,
|
||||
monthly_credits: balance[:monthly],
|
||||
topup_credits: balance[:topup],
|
||||
total_credits: balance[:total],
|
||||
usage_this_month: balance[:usage_this_month],
|
||||
usage_total: balance[:usage_total]
|
||||
total_credits: balance[:total]
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -122,7 +122,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def handle_error(error)
|
||||
log_error(error)
|
||||
refund_credit
|
||||
process_action('handoff')
|
||||
true
|
||||
end
|
||||
@@ -134,18 +133,4 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def captain_v2_enabled?
|
||||
account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
|
||||
def refund_credit
|
||||
credit_service = Enterprise::Ai::CaptainCreditService.new(conversation: @conversation)
|
||||
credit_service.check_and_use_credits(
|
||||
feature: 'ai_captain_conversation',
|
||||
amount: -1, # Negative to refund
|
||||
metadata: {
|
||||
'assistant_id' => @assistant.id,
|
||||
'conversation_id' => @conversation.id,
|
||||
'inbox_id' => @inbox.id,
|
||||
'refund' => true
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,14 +20,6 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
end
|
||||
|
||||
def generate_response(input)
|
||||
# Check and use credits before making API call
|
||||
credit_result = check_and_use_credits
|
||||
|
||||
unless credit_result[:success]
|
||||
error_message = credit_result[:message] || 'Insufficient credits'
|
||||
return { error: error_message, error_code: 402 }
|
||||
end
|
||||
|
||||
@messages << { role: 'user', content: input } if input.present?
|
||||
response = request_chat_completion
|
||||
|
||||
@@ -121,17 +113,4 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
message_type: message_type
|
||||
)
|
||||
end
|
||||
|
||||
def check_and_use_credits
|
||||
credit_service = Enterprise::Ai::CaptainCreditService.new(account: @account)
|
||||
credit_service.check_and_use_credits(
|
||||
feature: 'ai_copilot',
|
||||
amount: 1,
|
||||
metadata: {
|
||||
'assistant_id' => @assistant.id,
|
||||
'user_id' => @user&.id,
|
||||
'copilot_thread_id' => @copilot_thread&.id
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
class Enterprise::Ai::CaptainCreditService
|
||||
attr_reader :account, :conversation
|
||||
|
||||
def initialize(account: nil, conversation: nil)
|
||||
@account = account || conversation&.account
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def check_and_use_credits(feature: 'ai_captain', amount: 1, metadata: {})
|
||||
# V1 accounts don't use credit system
|
||||
return { success: true } unless v2_enabled?
|
||||
|
||||
# V2 accounts use credit-based billing
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
|
||||
result = service.use_credit(feature: feature, amount: amount, metadata: metadata)
|
||||
Rails.logger.info "Credit result: #{result.inspect}"
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def v2_enabled?
|
||||
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
|
||||
end
|
||||
end
|
||||
@@ -107,11 +107,11 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
end
|
||||
|
||||
def default_stripe_version
|
||||
'2025-08-27.preview'
|
||||
'2025-10-29.preview'
|
||||
end
|
||||
|
||||
def checkout_stripe_version
|
||||
'2025-08-27.preview;checkout_product_catalog_preview=v1'
|
||||
'2025-10-29.preview;checkout_product_catalog_preview=v1'
|
||||
end
|
||||
|
||||
def extract_attribute(object, key)
|
||||
|
||||
@@ -51,16 +51,6 @@ class Enterprise::Billing::V2::BaseService
|
||||
account.with_lock(&)
|
||||
end
|
||||
|
||||
def log_credit_transaction(type:, amount:, credit_type:, description: nil, metadata: nil)
|
||||
account.credit_transactions.create!(
|
||||
transaction_type: type,
|
||||
amount: amount,
|
||||
credit_type: credit_type,
|
||||
description: description,
|
||||
metadata: (metadata || {}).stringify_keys
|
||||
)
|
||||
end
|
||||
|
||||
def with_stripe_error_handling
|
||||
yield
|
||||
rescue Stripe::StripeError => e
|
||||
|
||||
@@ -1,72 +1,13 @@
|
||||
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def use_credit(feature: 'ai_captain', amount: 1, metadata: {})
|
||||
with_locked_account do
|
||||
# Handle refunds (negative amounts)
|
||||
if amount.negative?
|
||||
refund_credits(amount.abs)
|
||||
log_credit_transaction(
|
||||
type: 'use',
|
||||
amount: amount,
|
||||
credit_type: 'topup',
|
||||
description: "Refund for #{feature}",
|
||||
metadata: metadata
|
||||
)
|
||||
{ success: true, credits_refunded: amount.abs, remaining: total_credits }
|
||||
elsif total_credits < amount
|
||||
# Handle usage (positive amounts)
|
||||
{ success: false, message: 'Insufficient credits' }
|
||||
else
|
||||
# Report usage to Stripe
|
||||
stripe_result = report_usage_to_stripe(amount, feature)
|
||||
|
||||
if stripe_result[:success]
|
||||
# Deduct credits locally (monthly first, then topup)
|
||||
deduct_credits(amount)
|
||||
|
||||
# Log transaction
|
||||
log_credit_transaction(
|
||||
type: 'use',
|
||||
amount: amount,
|
||||
credit_type: determine_credit_type(amount),
|
||||
description: "Used for #{feature}",
|
||||
metadata: metadata
|
||||
)
|
||||
|
||||
{ success: true, credits_used: amount, remaining: total_credits }
|
||||
else
|
||||
{ success: false, message: stripe_result[:message] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def sync_monthly_credits(amount, metadata: {})
|
||||
def sync_monthly_credits(amount)
|
||||
with_locked_account do
|
||||
update_credits(monthly: amount)
|
||||
if amount.positive?
|
||||
log_credit_transaction(
|
||||
type: 'grant',
|
||||
amount: amount,
|
||||
credit_type: 'monthly',
|
||||
description: 'Monthly credits from Stripe',
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_topup_credits(amount, metadata: {})
|
||||
def add_topup_credits(amount)
|
||||
with_locked_account do
|
||||
update_credits(topup: topup_credits + amount)
|
||||
log_credit_transaction(
|
||||
type: 'topup',
|
||||
amount: amount,
|
||||
credit_type: 'topup',
|
||||
description: 'Topup credits added',
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,14 +20,10 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
end
|
||||
|
||||
def credit_balance
|
||||
usage_stats = calculate_usage_stats
|
||||
|
||||
{
|
||||
monthly: monthly_credits,
|
||||
topup: topup_credits,
|
||||
total: total_credits,
|
||||
usage_this_month: usage_stats[:this_month],
|
||||
usage_total: usage_stats[:total]
|
||||
total: total_credits
|
||||
}
|
||||
end
|
||||
|
||||
@@ -108,25 +45,6 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
[]
|
||||
end
|
||||
|
||||
def calculate_usage_stats
|
||||
month_start = Time.current.beginning_of_month
|
||||
|
||||
this_month_usage = account.credit_transactions
|
||||
.where(transaction_type: 'use', created_at: month_start..Time.current)
|
||||
.sum(:amount)
|
||||
.abs
|
||||
|
||||
total_usage = account.credit_transactions
|
||||
.where(transaction_type: 'use')
|
||||
.sum(:amount)
|
||||
.abs
|
||||
|
||||
{
|
||||
this_month: this_month_usage,
|
||||
total: total_usage
|
||||
}
|
||||
end
|
||||
|
||||
def total_credits
|
||||
monthly_credits + topup_credits
|
||||
end
|
||||
@@ -173,32 +91,4 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
def stripe_api_options
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
end
|
||||
|
||||
def report_usage_to_stripe(amount, feature)
|
||||
Enterprise::Billing::V2::UsageReporterService.new(account: account).report(amount, feature)
|
||||
end
|
||||
|
||||
def deduct_credits(amount)
|
||||
if monthly_credits >= amount
|
||||
update_credits(monthly: monthly_credits - amount)
|
||||
else
|
||||
remaining = amount - monthly_credits
|
||||
update_credits(monthly: 0, topup: topup_credits - remaining)
|
||||
end
|
||||
end
|
||||
|
||||
def refund_credits(amount)
|
||||
# Refunds are added to topup credits
|
||||
update_credits(topup: topup_credits + amount)
|
||||
end
|
||||
|
||||
def determine_credit_type(amount)
|
||||
if monthly_credits >= amount
|
||||
'monthly'
|
||||
elsif monthly_credits.positive?
|
||||
'mixed'
|
||||
else
|
||||
'topup'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,30 +3,6 @@ module Enterprise::Integrations::OpenaiProcessorService
|
||||
make_friendly make_formal simplify].freeze
|
||||
CACHEABLE_EVENTS = %w[label_suggestion].freeze
|
||||
|
||||
def perform
|
||||
# Check and use credits if account is on V2 billing
|
||||
if v2_enabled?
|
||||
credit_service = Enterprise::Ai::CaptainCreditService.new(
|
||||
account: hook.account,
|
||||
conversation: conversation
|
||||
)
|
||||
|
||||
result = credit_service.check_and_use_credits(
|
||||
feature: "ai_#{event_name}",
|
||||
amount: 1,
|
||||
metadata: {
|
||||
'event_name' => event_name,
|
||||
'conversation_id' => conversation&.id
|
||||
}
|
||||
)
|
||||
|
||||
return { error: 'Insufficient credits', error_code: 402 } unless result[:success]
|
||||
end
|
||||
|
||||
# Call the parent perform method
|
||||
super
|
||||
end
|
||||
|
||||
def label_suggestion_message
|
||||
payload = label_suggestion_body
|
||||
return nil if payload.blank?
|
||||
@@ -103,8 +79,4 @@ module Enterprise::Integrations::OpenaiProcessorService
|
||||
def label_suggestions_enabled?
|
||||
hook.settings['label_suggestion'].present?
|
||||
end
|
||||
|
||||
def v2_enabled?
|
||||
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,17 +8,12 @@ describe Enterprise::Billing::V2::CreditManagementService do
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
'stripe_billing_version' => 2,
|
||||
'monthly_credits' => 100,
|
||||
'topup_credits' => 50,
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_meter_event_name' => 'ai_prompts'
|
||||
'stripe_meter_event_name' => 'ai_prompts',
|
||||
'monthly_credits' => 100,
|
||||
'topup_credits' => 50
|
||||
}
|
||||
)
|
||||
|
||||
# Stub Stripe meter event creation
|
||||
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(
|
||||
OpenStruct.new(identifier: 'test_event_123')
|
||||
)
|
||||
end
|
||||
|
||||
describe '#total_credits' do
|
||||
@@ -27,49 +22,12 @@ describe Enterprise::Billing::V2::CreditManagementService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#use_credit' do
|
||||
context 'when sufficient credits' do
|
||||
it 'uses credits and reports to Stripe' do
|
||||
result = service.use_credit(feature: 'ai_test', amount: 10)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(result[:credits_used]).to eq(10)
|
||||
expect(result[:remaining]).to eq(140)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(90)
|
||||
expect(account.custom_attributes['topup_credits']).to eq(50)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when insufficient credits' do
|
||||
it 'returns error' do
|
||||
result = service.use_credit(feature: 'ai_test', amount: 200)
|
||||
|
||||
expect(result[:success]).to be(false)
|
||||
expect(result[:message]).to eq('Insufficient credits')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using mixed credits' do
|
||||
it 'uses monthly first then topup' do
|
||||
result = service.use_credit(feature: 'ai_test', amount: 120)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(0)
|
||||
expect(account.custom_attributes['topup_credits']).to eq(30)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_monthly_credits' do
|
||||
it 'updates monthly credits from Stripe' do
|
||||
it 'updates monthly credits' do
|
||||
service.sync_monthly_credits(500)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(500)
|
||||
expect(account.credit_transactions.last.description).to eq('Monthly credits from Stripe')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -89,7 +47,6 @@ describe Enterprise::Billing::V2::CreditManagementService do
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['topup_credits']).to eq(150)
|
||||
expect(account.credit_transactions.last.description).to eq('Topup credits added')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user