stripe v2 init

This commit is contained in:
Tanmay Deep Sharma
2025-10-08 10:11:23 +02:00
parent 829142c808
commit acb2816826
26 changed files with 1498 additions and 21 deletions

View File

@@ -2,24 +2,35 @@
#
# Table name: accounts
#
# id :integer not null, primary key
# auto_resolve_duration :integer
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# internal_attributes :jsonb not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
# settings :jsonb
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
# updated_at :datetime not null
# id :integer not null, primary key
# auto_resolve_duration :integer
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# internal_attributes :jsonb not null
# last_credit_sync_at :datetime
# limits :jsonb
# locale :integer default("en")
# monthly_credits :integer default(0), not null
# name :string not null
# settings :jsonb
# status :integer default("active")
# stripe_billing_version :integer default(1), not null
# support_email :string(100)
# topup_credits :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# stripe_cadence_id :string
# stripe_customer_id :string
# stripe_pricing_plan_id :string
#
# Indexes
#
# index_accounts_on_status (status)
# index_accounts_on_status (status)
# index_accounts_on_stripe_billing_version (stripe_billing_version)
# index_accounts_on_stripe_cadence_id (stripe_cadence_id)
# index_accounts_on_stripe_customer_id (stripe_customer_id)
# index_accounts_on_stripe_pricing_plan_id (stripe_pricing_plan_id)
#
class Account < ApplicationRecord
@@ -69,6 +80,7 @@ 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

View File

@@ -0,0 +1,28 @@
# == 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: { greater_than: 0 }
validates :transaction_type, presence: true, inclusion: { in: %w[grant expire use topup] }
validates :credit_type, presence: true, inclusion: { in: %w[monthly topup mixed] }
scope :recent, -> { order(created_at: :desc) }
end

View File

@@ -1,3 +1,44 @@
require 'stripe'
Stripe.api_key = ENV.fetch('STRIPE_SECRET_KEY', nil)
# Set API version if specified
Stripe.api_version = ENV['STRIPE_API_VERSION'] if ENV['STRIPE_API_VERSION'].present?
# V2 Billing Configuration
Rails.application.config.stripe_v2 = {
enabled: ENV['STRIPE_V2_ENABLED'] == 'true',
usage_reporting_enabled: ENV['STRIPE_V2_USAGE_REPORTING_ENABLED'] != 'false',
meter_id: ENV.fetch('STRIPE_V2_METER_ID', nil),
meter_event_name: ENV.fetch('STRIPE_V2_METER_EVENT_NAME', nil),
# Plan-specific configurations
plans: {
free: {
pricing_plan_id: ENV.fetch('STRIPE_V2_FREE_PLAN_ID', nil),
monthly_credits: ENV.fetch('STRIPE_V2_FREE_CREDITS', '100').to_i,
price_per_agent_per_month: ENV.fetch('STRIPE_V2_FREE_PRICE', '0').to_f
},
startup: {
pricing_plan_id: ENV.fetch('STRIPE_V2_STARTUP_PLAN_ID', nil),
monthly_credits: ENV.fetch('STRIPE_V2_STARTUP_CREDITS', '500').to_i,
price_per_agent_per_month: ENV.fetch('STRIPE_V2_STARTUP_PRICE', '19').to_f
},
business: {
pricing_plan_id: ENV.fetch('STRIPE_V2_BUSINESS_PLAN_ID', nil),
monthly_credits: ENV.fetch('STRIPE_V2_BUSINESS_CREDITS', '2000').to_i,
price_per_agent_per_month: ENV.fetch('STRIPE_V2_BUSINESS_PRICE', '39').to_f
},
enterprise: {
pricing_plan_id: ENV.fetch('STRIPE_V2_ENTERPRISE_PLAN_ID', nil),
monthly_credits: ENV.fetch('STRIPE_V2_ENTERPRISE_CREDITS', '5000').to_i,
price_per_agent_per_month: ENV.fetch('STRIPE_V2_ENTERPRISE_PRICE', '99').to_f
}
},
# Topup configuration (same for all plans)
topup: {
price_per_credit: ENV.fetch('STRIPE_V2_TOPUP_PRICE_PER_CREDIT', '0.01').to_f,
available_packs: [100, 500, 1000, 2500, 5000]
}
}

View File

@@ -430,6 +430,10 @@ Rails.application.routes.draw do
post :subscription
get :limits
post :toggle_deletion
# V2 Billing endpoints
get :credits_balance
get :usage_metrics
post :enable_v2_billing
end
end
end

View File

@@ -0,0 +1,14 @@
# Stripe V2 Billing Scheduled Jobs
# Add these to your config/sidekiq_cron.yml or config/schedule.yml
v2_credit_sync:
cron: "0 */6 * * *" # Every 6 hours
class: "Enterprise::Billing::CreditSyncJob"
queue: low
description: "Sync V2 billing credits with Stripe"
v2_retry_failed_usage:
cron: "*/30 * * * *" # Every 30 minutes
class: "Enterprise::Billing::RetryFailedUsageReportsJob"
queue: low
description: "Retry failed V2 usage reports"

View File

@@ -0,0 +1,15 @@
class CreateCreditTransactions < ActiveRecord::Migration[7.0]
def change
create_table :credit_transactions do |t|
t.references :account, null: false, foreign_key: true
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

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
ActiveRecord::Schema[7.1].define(version: 2025_10_07_111938) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -73,7 +73,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.integer "status", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.integer "stripe_billing_version", default: 1, null: false
t.string "stripe_cadence_id"
t.string "stripe_pricing_plan_id"
t.integer "monthly_credits", default: 0, null: false
t.integer "topup_credits", default: 0, null: false
t.datetime "last_credit_sync_at"
t.string "stripe_customer_id"
t.index ["status"], name: "index_accounts_on_status"
t.index ["stripe_billing_version"], name: "index_accounts_on_stripe_billing_version"
t.index ["stripe_cadence_id"], name: "index_accounts_on_stripe_cadence_id"
t.index ["stripe_customer_id"], name: "index_accounts_on_stripe_customer_id"
t.index ["stripe_pricing_plan_id"], name: "index_accounts_on_stripe_pricing_plan_id"
end
create_table "action_mailbox_inbound_emails", force: :cascade do |t|
@@ -694,6 +705,19 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) 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
@@ -784,6 +808,21 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.index ["name", "account_id"], name: "index_email_templates_on_name_and_account_id", unique: true
end
create_table "failed_usage_reports", force: :cascade do |t|
t.bigint "account_id", null: false
t.integer "credits", null: false
t.string "feature", null: false
t.text "error"
t.integer "retry_count", default: 0
t.datetime "retried_at"
t.boolean "resolved", default: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "resolved_at"
t.index ["account_id"], name: "index_failed_usage_reports_on_account_id"
t.index ["resolved", "created_at"], name: "index_failed_usage_reports_on_resolved_and_created_at"
end
create_table "folders", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "category_id", null: false
@@ -884,6 +923,24 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
end
create_table "leave_records", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "user_id", null: false
t.date "start_date", null: false
t.date "end_date", null: false
t.integer "leave_type", default: 0, null: false
t.integer "status", default: 0, null: false
t.text "reason"
t.bigint "approved_by_id"
t.datetime "approved_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "status"], name: "index_leave_records_on_account_id_and_status"
t.index ["account_id"], name: "index_leave_records_on_account_id"
t.index ["approved_by_id"], name: "index_leave_records_on_approved_by_id"
t.index ["user_id"], name: "index_leave_records_on_user_id"
end
create_table "leaves", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "user_id", null: false
@@ -1197,7 +1254,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.text "message_signature"
t.string "otp_secret"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false
t.boolean "otp_required_for_login", default: false, null: false
t.text "otp_backup_codes"
t.index ["email"], name: "index_users_on_email"
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
@@ -1236,6 +1293,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "failed_usage_reports", "accounts"
add_foreign_key "inboxes", "portals"
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
on("accounts").

View File

@@ -55,6 +55,56 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
end
end
# V2 Billing Endpoints
def credits_balance
return render_v2_not_enabled unless v2_enabled?
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
balance = service.credit_balance
render json: {
id: @account.id,
monthly_credits: balance[:monthly],
topup_credits: balance[:topup],
total_credits: balance[:total]
}
end
def usage_metrics
return render_v2_not_enabled unless v2_enabled?
service = Enterprise::Billing::V2::UsageAnalyticsService.new(account: @account)
result = service.fetch_usage_summary
if result[:success]
render json: result
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def enable_v2_billing
unless Rails.application.config.stripe_v2[:enabled]
return render json: { error: 'V2 billing not enabled system-wide' },
status: :unprocessable_entity
end
return render json: { error: 'Account already on V2 billing' }, status: :ok if @account.custom_attributes['stripe_billing_version'].to_i == 2
plan_type = params[:plan_type] || 'startup'
service = Enterprise::Billing::V2::SubscriptionService.new(account: @account)
result = service.migrate_to_v2(plan_type: plan_type)
if result[:success]
render json: {
success: true,
message: result[:message],
billing_version: @account.custom_attributes['stripe_billing_version']
}
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
private
def check_cloud_env
@@ -120,4 +170,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
account_user: @current_account_user
}
end
def v2_enabled?
Rails.application.config.stripe_v2[:enabled] && @account.custom_attributes['stripe_billing_version'].to_i == 2
end
def render_v2_not_enabled
render json: { error: 'V2 billing not enabled for this account' }, status: :unprocessable_entity
end
end

View File

@@ -7,7 +7,14 @@ class Enterprise::Webhooks::StripeController < ActionController::API
# Attempt to verify the signature. If successful, we'll handle the event
begin
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
# Check if this is a V2 billing event
if v2_billing_event?(event)
handle_v2_event(event)
else
# Handle V1 events with existing service
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
end
# If we fail to verify the signature, then something was wrong with the request
rescue JSON::ParserError, Stripe::SignatureVerificationError
# Invalid payload
@@ -18,4 +25,59 @@ class Enterprise::Webhooks::StripeController < ActionController::API
# We've successfully processed the event without blowing up
head :ok
end
private
def v2_billing_event?(event)
%w[
billing.credit_grant.created
billing.credit_grant.expired
invoice.payment_failed
invoice.payment_succeeded
billing.alert.triggered
v2.billing.pricing_plan_subscription.servicing_activated
].include?(event.type)
end
def handle_v2_event(event)
customer_id = extract_customer_id(event)
return if customer_id.blank?
account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id)
return unless account&.custom_attributes&.[]('stripe_billing_version').to_i == 2
service = ::Enterprise::Billing::V2::WebhookHandlerService.new(account: account)
service.process(event)
end
def extract_customer_id(event)
data = event.data.object
candidate = customer_id_from_reader(data) ||
customer_id_from_hash(data) ||
customer_id_from_alert(data)
candidate.presence
end
def customer_id_from_reader(data)
return unless data.respond_to?(:customer)
return if data.customer.blank?
data.customer
end
def customer_id_from_hash(data)
return unless data.respond_to?(:[])
value = data['customer']
(value.presence)
end
def customer_id_from_alert(data)
return unless data.respond_to?(:[])
customer = data.dig('credit_balance_threshold', 'customer')
return unless customer.is_a?(Hash)
customer['id']
end
end

View File

@@ -0,0 +1,12 @@
class Enterprise::Billing::ReportUsageJob < ApplicationJob
queue_as :low
def perform(account_id:, credits_used:, feature:)
account = Account.find_by(id: account_id)
return if account.nil?
return unless account.custom_attributes&.[]('stripe_billing_version').to_i == 2
service = V2::UsageReporterService.new(account: account)
service.report(credits_used, feature)
end
end

View File

@@ -0,0 +1,26 @@
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 } if account.custom_attributes['stripe_billing_version'].to_i != 2
# V2 accounts use credit-based billing
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
service.use_credit(feature: feature, amount: amount, metadata: metadata)
end
# rubocop:disable Naming/PredicateName
def has_credits?
return true if account.custom_attributes['stripe_billing_version'].to_i != 2
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
service.total_credits.positive?
end
# rubocop:enable Naming/PredicateName
end

View File

@@ -0,0 +1,72 @@
class Enterprise::Billing::V2::BaseService
attr_reader :account
def initialize(account:)
@account = account
end
private
def stripe_client
@stripe_client ||= Stripe::StripeClient.new(api_key: ENV.fetch('STRIPE_SECRET_KEY', nil))
end
def v2_enabled?
custom_attribute('stripe_billing_version').to_i == 2
end
def v2_config
Rails.application.config.stripe_v2 || {}
end
def monthly_credits
custom_attribute('monthly_credits').to_i
end
def topup_credits
custom_attribute('topup_credits').to_i
end
def update_credits(monthly: nil, topup: nil)
updates = {}
updates['monthly_credits'] = monthly unless monthly.nil?
updates['topup_credits'] = topup unless topup.nil?
update_custom_attributes(updates)
end
def update_custom_attributes(updates)
return if updates.blank?
current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {}
updates.each do |key, value|
current_attributes[key.to_s] = value
end
account.update!(custom_attributes: current_attributes)
end
def custom_attribute(key)
account.custom_attributes&.[](key.to_s)
end
def with_locked_account(&)
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
Rails.logger.error "Stripe V2 Error: #{e.message}"
{ success: false, message: e.message }
end
end

View File

@@ -0,0 +1,128 @@
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
def grant_monthly_credits(amount = 2000, metadata: {})
result = with_locked_account do
expired_amount = expire_current_monthly_credits(metadata: metadata)
update_credits(monthly: amount)
if amount.positive?
log_credit_transaction(
type: 'grant',
amount: amount,
credit_type: 'monthly',
description: 'Monthly credit grant',
metadata: base_metadata(metadata).merge('expired_amount' => expired_amount)
)
end
{ success: true, granted: amount, expired: expired_amount, remaining: total_credits }
end
Rails.logger.info "Granted #{amount} monthly credits to account #{account.id}"
result
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Failed to grant monthly credits to account #{account.id}: #{e.message}"
{ success: false, message: e.message }
end
# rubocop:disable Metrics/MethodLength
def use_credit(feature: 'ai_captain', amount: 1, metadata: {})
return { success: true, credits_used: 0, remaining: total_credits } if amount <= 0
with_locked_account do
return { success: false, message: 'Insufficient credits' } if total_credits < amount
current_monthly = monthly_credits
current_topup = topup_credits
credit_type = 'monthly'
if current_monthly >= amount
update_credits(monthly: current_monthly - amount)
else
monthly_used = current_monthly
topup_used = amount - monthly_used
update_credits(monthly: 0, topup: current_topup - topup_used)
credit_type = monthly_used.positive? ? 'mixed' : 'topup'
end
log_credit_transaction(
type: 'use',
amount: amount,
credit_type: credit_type,
description: "Used for #{feature}",
metadata: base_metadata(metadata).merge('feature' => feature)
)
Enterprise::Billing::V2::UsageReporterService.new(account: account).report_async(amount, feature)
{ success: true, credits_used: amount, remaining: total_credits }
end
end
# rubocop:enable Metrics/MethodLength
def add_topup_credits(amount, metadata: {})
result = with_locked_account do
new_balance = topup_credits + amount
update_credits(topup: new_balance)
log_credit_transaction(
type: 'topup',
amount: amount,
credit_type: 'topup',
description: 'Topup credits added',
metadata: base_metadata(metadata)
)
{ success: true, topup_balance: new_balance, total: total_credits }
end
Rails.logger.info "Added #{amount} topup credits to account #{account.id}"
result
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Failed to add topup credits to account #{account.id}: #{e.message}"
{ success: false, message: e.message }
end
def total_credits
monthly_credits + topup_credits
end
def credit_balance
{
monthly: monthly_credits,
topup: topup_credits,
total: total_credits,
last_synced: Time.current
}
end
def expire_monthly_credits(metadata: {})
with_locked_account do
expired_amount = expire_current_monthly_credits(metadata: metadata)
{ success: true, expired: expired_amount, remaining: total_credits }
end
end
private
def expire_current_monthly_credits(metadata: {})
current_monthly = monthly_credits
return 0 if current_monthly.zero?
update_credits(monthly: 0)
log_credit_transaction(
type: 'expire',
amount: current_monthly,
credit_type: 'monthly',
description: 'Monthly credits expired',
metadata: base_metadata(metadata)
)
current_monthly
end
def base_metadata(metadata)
metadata.is_a?(Hash) ? metadata.stringify_keys : {}
end
end

View File

@@ -0,0 +1,52 @@
class Enterprise::Billing::V2::SubscriptionService < Enterprise::Billing::V2::BaseService
# rubocop:disable Metrics/MethodLength
def migrate_to_v2(plan_type: 'startup')
return { success: false, message: 'Already on V2' } if v2_enabled?
credits = plan_credits(plan_type)
with_locked_account do
update_custom_attributes(
'stripe_billing_version' => 2,
'monthly_credits' => credits,
'topup_credits' => 0,
'plan_name' => plan_type.capitalize,
'subscription_status' => 'active'
)
log_credit_transaction(
type: 'grant',
amount: credits,
credit_type: 'monthly',
description: "Initial V2 migration grant - #{plan_type} plan",
metadata: { 'source' => 'migration', 'plan_type' => plan_type }
)
Rails.logger.info "Migrated account #{account.id} to V2 billing with #{plan_type} plan"
end
{ success: true, message: 'Successfully migrated to V2 billing' }
rescue StandardError => e
Rails.logger.error "Failed to migrate account #{account.id} to V2: #{e.message}"
{ success: false, message: e.message }
end
# rubocop:enable Metrics/MethodLength
def update_plan(plan_type)
return { success: false, message: 'Not on V2 billing' } unless v2_enabled?
update_custom_attributes('plan_name' => plan_type.capitalize)
{ success: true, plan: plan_type }
end
private
def plan_credits(plan_type)
config = Rails.application.config.stripe_v2
return 100 unless config && config[:plans]
plan_config = config[:plans][plan_type.to_s.downcase.to_sym]
plan_config ? plan_config[:monthly_credits] : 100
end
end

View File

@@ -0,0 +1,36 @@
class Enterprise::Billing::V2::UsageAnalyticsService < Enterprise::Billing::V2::BaseService
def fetch_usage_summary
return { success: false, message: 'Not on V2 billing' } unless v2_enabled?
return { success: false, message: 'No Stripe customer' } if stripe_customer_id.blank?
with_stripe_error_handling do
# Get current month's usage from Stripe
start_time = Time.current.beginning_of_month.to_i
end_time = Time.current.to_i
# For now, return mock data as Stripe Billing Meters require setup
# In production, this would call Stripe's usage API
Rails.logger.info "Fetching usage for customer #{stripe_customer_id} from #{start_time} to #{end_time}"
# Calculate usage from local credit transactions for current month
total_used = account.credit_transactions
.where(transaction_type: 'use',
created_at: Time.current.all_month)
.sum(:amount)
{
success: true,
total_usage: total_used,
period_start: Time.zone.at(start_time),
period_end: Time.zone.at(end_time),
source: 'local' # Indicate this is from local data
}
end
end
private
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
end

View File

@@ -0,0 +1,85 @@
class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::BaseService
def report_async(credits_used, feature)
return unless should_report_usage?
Enterprise::Billing::ReportUsageJob.perform_later(
account_id: account.id,
credits_used: credits_used,
feature: feature
)
end
def report(credits_used, feature, _metadata: {})
return { success: false, message: 'Usage reporting disabled' } unless should_report_usage?
with_stripe_error_handling do
payload = build_meter_payload(credits_used, feature)
response = stripe_client.execute_request(
:post,
'/v1/billing/meter_events',
headers: { 'Idempotency-Key' => payload[:identifier] },
params: payload
)
response_data = extract_response_data(response)
event_id = response_data['id'] || payload[:identifier]
log_usage_payload(credits_used, feature, payload)
{ success: true, event_id: event_id, reported_credits: credits_used }
end
rescue StandardError => e
Rails.logger.error "Failed to report usage for account #{account.id}: #{e.message}"
{ success: false, message: e.message }
end
private
# No HTTP client; use Stripe gem's generic execute_request to support preview endpoints
def build_meter_payload(credits_used, feature)
{
identifier: usage_identifier(feature),
event_name: meter_event_name,
timestamp: Time.current.to_i,
payload: {
value: credits_used,
stripe_customer_id: stripe_customer_id,
feature: feature
}
}
end
def log_usage_payload(credits_used, feature, payload)
Rails.logger.info "Usage Reported: #{credits_used} credits for account #{account.id} (#{feature})"
Rails.logger.info "Stripe meter ID: #{v2_config[:meter_id]}"
Rails.logger.debug { "Stripe meter event payload: #{payload}" }
end
def should_report_usage?
v2_enabled? &&
stripe_customer_id.present? &&
meter_event_name.present? &&
v2_config[:usage_reporting_enabled] != false
end
def meter_event_name
v2_config[:meter_event_name].presence || "captain_prompts_#{Rails.env}"
end
def usage_identifier(feature)
"acct_#{account.id}_#{feature}_#{SecureRandom.hex(8)}"
end
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
def extract_response_data(response)
return response if response.is_a?(Hash)
return response.data if response.respond_to?(:data)
{}
end
end

View File

@@ -0,0 +1,119 @@
class Enterprise::Billing::V2::WebhookHandlerService < Enterprise::Billing::V2::BaseService
def process(event)
Rails.logger.info "Processing V2 billing event: #{event.type}"
case event.type
when 'billing.credit_grant.created'
handle_credit_grant_created(event)
when 'billing.credit_grant.expired'
handle_credit_grant_expired(event)
when 'invoice.payment_succeeded'
handle_payment_succeeded(event)
when 'invoice.payment_failed'
handle_payment_failed(event)
else
Rails.logger.info "Event type not handled: #{event.type}"
{ success: true }
end
rescue StandardError => e
Rails.logger.error "Error processing V2 webhook: #{e.message}"
{ success: false, error: e.message }
end
private
def handle_credit_grant_created(event)
return { success: true } if processed_event?(event.id)
grant = event.data.object
amount = extract_credit_amount(grant)
return { success: true } if amount.zero?
metadata = event_metadata(event, grant_id: grant.respond_to?(:id) ? grant.id : nil)
credit_service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
if monthly_grant?(grant)
credit_service.grant_monthly_credits(amount, metadata: metadata)
Rails.logger.info "Granted #{amount} monthly credits to account #{account.id}"
else
credit_service.add_topup_credits(amount, metadata: metadata.merge('source' => 'credit_grant'))
Rails.logger.info "Added #{amount} topup credits to account #{account.id} via credit grant"
end
{ success: true }
end
def handle_credit_grant_expired(_event)
Enterprise::Billing::V2::CreditManagementService.new(account: account).expire_monthly_credits
Rails.logger.info "Expired monthly credits for account #{account.id}"
{ success: true }
end
def handle_payment_succeeded(event)
return { success: true } if processed_event?(event.id)
invoice = event.data.object
# Check if this is a topup payment
return { success: true } unless invoice_metadata(invoice, 'type') == 'topup'
credits = invoice_metadata(invoice, 'credits').to_i
return { success: true } if credits.zero?
metadata = event_metadata(event, invoice_id: invoice.id)
Enterprise::Billing::V2::CreditManagementService.new(account: account)
.add_topup_credits(credits, metadata: metadata.merge('source' => 'invoice'))
Rails.logger.info "Added #{credits} topup credits for account #{account.id}"
{ success: true }
end
def handle_payment_failed(event)
invoice = event.data.object
Rails.logger.error "Payment failed for account #{account.id}: #{invoice.id}"
# Update subscription status
account.custom_attributes['subscription_status'] = 'past_due'
account.save!
{ success: true }
end
def processed_event?(event_id)
account.credit_transactions.exists?(["metadata ->> 'stripe_event_id' = ?", event_id])
end
def extract_credit_amount(grant)
return grant.amount.to_i if grant.respond_to?(:amount) && grant.amount.is_a?(Numeric)
raw = grant.respond_to?(:amount) ? grant.amount : grant['amount']
return 0 if raw.blank?
return hash_amount_value(raw) if raw.is_a?(Hash) || raw.respond_to?(:[])
raw.to_i
end
def hash_amount_value(raw)
cpu = raw['custom_pricing_unit'] || raw[:custom_pricing_unit]
return cpu['value'].to_i if cpu.present?
return raw['value'].to_i if raw['value']
0
end
def monthly_grant?(grant)
grant.respond_to?(:expires_at) && grant.expires_at.present?
end
def event_metadata(event, extra = {})
{ 'stripe_event_id' => event.id }.merge(extra.compact.transform_keys(&:to_s))
end
def invoice_metadata(invoice, key)
return unless invoice.respond_to?(:metadata)
metadata = invoice.metadata
return metadata[key] if metadata.respond_to?(:[])
nil
end
end

View File

@@ -3,6 +3,30 @@ 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 hook.account.custom_attributes['stripe_billing_version'].to_i == 2
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?

214
setup_stripe_v2_billing.rb Executable file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env ruby
require 'stripe'
require 'httparty'
require 'optparse'
require 'json'
# Provision core V2 Billing objects using Stripe Ruby client.
# Uses generic execute_request to call preview endpoints.
class StripeV2Provisioner
def initialize(api_key:, api_version: ENV.fetch('STRIPE_API_VERSION', '2024-09-30.acacia'))
Stripe.api_key = api_key
Stripe.api_version = api_version
@client = Stripe::StripeClient.new(api_key: api_key)
@api_key = api_key
@api_version = api_version
end
def run!
# Always ensure the v1 meter exists first so usage reporting works
meter = find_or_create_meter(env_suffix('captain_prompts'), 'Captain Prompts')
results = { meter: meter }
# Attempt V2 provisioning; if preview not enabled, continue gracefully
begin
cpu = create_custom_pricing_unit
plan = create_pricing_plan
item = create_licensed_item
fee = create_license_fee(item['id'])
svc = create_service_action(cpu['id'])
mi = create_metered_item(meter['id'])
rc = create_rate_card
add_rate(rc['id'], mi['id'], cpu['id'])
attach_components(plan['id'], fee, svc, rc)
publish_plan(plan['id'])
results.merge!(cpu: cpu, plan: plan, licensed_item: item, license_fee: fee, service_action: svc, metered_item: mi, rate_card: rc)
rescue StandardError => e
warn "[WARN] V2 provisioning skipped due to: #{e.message}"
end
print_summary(results)
end
def create_custom_pricing_unit
post('/v2/billing/custom_pricing_units', display_name: 'Captain Credits', lookup_key: env_suffix('captain_credits'))
end
def find_or_create_meter(event_name, display_name)
list = get('/v1/billing/meters', limit: 100)
arr = list.is_a?(Hash) ? (list['data'] || []) : Array(list)
existing = arr.find { |m| (m['event_name'] || m[:event_name]) == event_name }
return existing if existing
post('/v1/billing/meters',
display_name: display_name,
event_name: event_name,
default_aggregation: { formula: 'sum' },
customer_mapping: { type: 'by_id', event_payload_key: 'stripe_customer_id' },
value_settings: { event_payload_key: 'value' })
end
def create_pricing_plan
post('/v2/billing/pricing_plans', display_name: env_suffix('Chatwoot Business Plan'), currency: 'usd', tax_behavior: 'exclusive')
end
def create_licensed_item
post('/v2/billing/licensed_items', display_name: 'Business Agent Seat', lookup_key: env_suffix('business_agent'), unit_label: 'per agent')
end
def create_license_fee(licensed_item_id)
post('/v2/billing/license_fees', display_name: 'Business Monthly Fee', currency: 'usd', service_interval: 'month', service_interval_count: 1,
tax_behavior: 'exclusive', unit_amount: '3900', licensed_item: licensed_item_id)
end
def create_service_action(cpu_id)
post(
'/v2/billing/service_actions',
lookup_key: env_suffix('business_monthly_credits'),
service_interval: 'month',
service_interval_count: 1,
type: 'credit_grant',
credit_grant: {
name: 'Business Monthly Credits',
amount: {
type: 'custom_pricing_unit',
custom_pricing_unit: { id: cpu_id, value: '2000' }
},
expiry_config: { type: 'end_of_service_period' },
applicability_config: { scope: { price_type: 'metered' } }
}
)
end
def create_metered_item(meter_id)
post('/v2/billing/metered_items', display_name: 'Captain Prompt', lookup_key: env_suffix('captain_prompt'), meter: meter_id)
end
def create_rate_card
post(
'/v2/billing/rate_cards',
display_name: env_suffix('Chatwoot Usage Rates'),
currency: 'usd',
service_interval: 'month',
service_interval_count: 1,
tax_behavior: 'exclusive'
)
end
def add_rate(rate_card_id, metered_item_id, cpu_id)
post("/v2/billing/rate_cards/#{rate_card_id}/rates", metered_item: metered_item_id, custom_pricing_unit_amount: { id: cpu_id, value: '1' })
end
def attach_components(plan_id, license_fee, service_action, rate_card)
post(
"/v2/billing/pricing_plans/#{plan_id}/components",
type: 'license_fee',
license_fee: { id: license_fee['id'], version: license_fee['latest_version'] }
)
post(
"/v2/billing/pricing_plans/#{plan_id}/components",
type: 'credit_grant',
service_action: { id: service_action['id'] }
)
post(
"/v2/billing/pricing_plans/#{plan_id}/components",
type: 'rate_card',
rate_card: { id: rate_card['id'], version: rate_card['latest_version'] }
)
end
def publish_plan(plan_id)
post("/v2/billing/pricing_plans/#{plan_id}", live_version: 'latest')
end
def print_summary(results)
puts "\n--- Stripe V2 IDs ---"
summary = {}
summary[:meter_id] = results[:meter]['id'] if results[:meter]
summary[:cpu_id] = results[:cpu]['id'] if results[:cpu]
summary[:plan_id] = results[:plan]['id'] if results[:plan]
summary[:licensed_item_id] = results[:licensed_item]['id'] if results[:licensed_item]
summary[:license_fee_id] = results[:license_fee]['id'] if results[:license_fee]
summary[:service_action_id] = results[:service_action]['id'] if results[:service_action]
summary[:metered_item_id] = results[:metered_item]['id'] if results[:metered_item]
summary[:rate_card_id] = results[:rate_card]['id'] if results[:rate_card]
puts JSON.pretty_generate(summary)
puts "\nEnvironment variables to set:"
puts 'STRIPE_V2_ENABLED=true'
if results[:meter]
puts "STRIPE_V2_METER_ID=#{results[:meter]['id']}"
puts "STRIPE_V2_METER_EVENT_NAME=#{env_suffix('captain_prompts')}"
end
puts "STRIPE_V2_BUSINESS_PLAN_ID=#{results[:plan]['id']}" if results[:plan]
puts "STRIPE_API_VERSION=#{Stripe.api_version}"
end
private
def env_suffix(base)
env = ENV.fetch('RAILS_ENV', 'development')
"#{base}_#{env}"
end
def post(path, **params)
if path.start_with?('/v2')
http_post_v2(path, params)
else
# Use Stripe gem for v1 endpoints
@client.execute_request(:post, path, params: params)
end
end
def get(path, **params)
url = "https://api.stripe.com#{path}"
headers = { 'Authorization' => "Bearer #{@api_key}" }
response = HTTParty.get(url, headers: headers, query: params)
raise(StandardError, response.parsed_response) unless response.success?
response.parsed_response
end
def http_post_v2(path, params)
url = "https://api.stripe.com#{path}"
headers = {
'Authorization' => "Bearer #{@api_key}",
'Stripe-Version' => @api_version,
'Stripe-Api-Version' => @api_version,
'Content-Type' => 'application/json'
}
response = HTTParty.post(url, headers: headers, body: JSON.generate(params))
raise(StandardError, response.parsed_response) unless response.success?
response.parsed_response
end
end
if __FILE__ == $PROGRAM_NAME
options = {}
OptionParser.new do |opts|
opts.banner = 'Usage: ruby setup_stripe_v2_billing.rb --key sk_test_xxx [--version 2025-08-27.preview]'
opts.on('-k', '--key KEY', 'Stripe secret key') { |v| options[:key] = v }
opts.on('-v', '--version VERSION', 'Stripe API version') { |v| options[:version] = v }
end.parse!
key = options[:key] || ENV.fetch('STRIPE_SECRET_KEY', nil)
abort 'Missing STRIPE_SECRET_KEY' if key.to_s.strip.empty?
StripeV2Provisioner.new(api_key: key, api_version: options[:version]).run!
end

View File

@@ -1,16 +1,43 @@
require 'rails_helper'
# rubocop:disable RSpec/VerifiedDoubles
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
describe 'POST /enterprise/webhooks/stripe' do
let(:params) { { content: 'hello' } }
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
it 'delegates to the v1 handler for legacy events' do
handle_stripe = double
allow(Stripe::Webhook).to receive(:construct_event).and_return(params)
event = double('Stripe::Event', type: 'invoice.created')
allow(Stripe::Webhook).to receive(:construct_event).and_return(event)
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe)
allow(handle_stripe).to receive(:perform)
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
expect(handle_stripe).to have_received(:perform).with(event: params)
expect(handle_stripe).to have_received(:perform).with(event: event)
end
it 'delegates v2 billing events to the v2 webhook handler' do
account = create(:account)
account.update!(custom_attributes: (account.custom_attributes || {}).merge(
'stripe_billing_version' => 2,
'stripe_customer_id' => 'cus_123'
))
event_object = double('StripeObject', customer: 'cus_123')
event = double('Stripe::Event', type: 'billing.credit_grant.created', data: double(object: event_object))
handler_double = instance_double(Enterprise::Billing::V2::WebhookHandlerService, process: { success: true })
allow(Stripe::Webhook).to receive(:construct_event).and_return(event)
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new)
allow(Enterprise::Billing::V2::WebhookHandlerService).to receive(:new).with(account: account).and_return(handler_double)
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
expect(Enterprise::Billing::V2::WebhookHandlerService).to have_received(:new).with(account: account)
expect(handler_double).to have_received(:process).with(event)
expect(response).to have_http_status(:ok)
end
it 'returns a bad request if the headers are missing' do
@@ -24,3 +51,4 @@ RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
end
end
end
# rubocop:enable RSpec/VerifiedDoubles

View File

@@ -0,0 +1,60 @@
require 'rails_helper'
describe Enterprise::Ai::CaptainCreditService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
before do
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).and_return(credit_service)
end
describe '#check_and_use_credits' do
context 'when account is not on v2 billing' do
it 'returns success without invoking credit service' do
result = service.check_and_use_credits
expect(result[:success]).to be(true)
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
end
end
context 'when account uses v2 billing' do
before do
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
allow(credit_service).to receive(:use_credit).and_return(success: true)
end
it 'delegates to credit management service with metadata' do
metadata = { 'feature' => 'ai_reply', 'conversation_id' => 123 }
result = service.check_and_use_credits(feature: 'ai_reply', metadata: metadata)
expect(result[:success]).to be(true)
expect(credit_service).to have_received(:use_credit)
.with(feature: 'ai_reply', amount: 1, metadata: metadata)
end
end
end
describe '#has_credits?' do
context 'when account is not on v2 billing' do
it 'returns true without checking credit service' do
expect(service.has_credits?).to be(true)
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
end
end
context 'when account is on v2 billing' do
before do
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
allow(credit_service).to receive(:total_credits).and_return(5)
end
it 'returns credit availability from credit service' do
expect(service.has_credits?).to be(true)
expect(credit_service).to have_received(:total_credits)
end
end
end
end

View File

@@ -0,0 +1,74 @@
require 'rails_helper'
describe Enterprise::Billing::V2::CreditManagementService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
before do
allow(Enterprise::Billing::ReportUsageJob).to receive(:perform_later)
account.update!(
custom_attributes: (account.custom_attributes || {}).merge(
'stripe_billing_version' => 2,
'monthly_credits' => 100,
'topup_credits' => 50
)
)
end
describe '#credit_balance' do
it 'returns current credit balance' do
balance = service.credit_balance
expect(balance[:monthly]).to eq(100)
expect(balance[:topup]).to eq(50)
expect(balance[:total]).to eq(150)
end
end
describe '#use_credit' do
context 'when sufficient monthly credits' do
it 'uses monthly credits first' do
result = service.use_credit(feature: 'ai_test', amount: 10)
expect(result[:success]).to be(true)
expect(result[:credits_used]).to eq(10)
account.reload
expect(account.custom_attributes['monthly_credits']).to eq(90)
expect(account.custom_attributes['topup_credits']).to eq(50)
expect(account.credit_transactions.order(created_at: :desc).first.metadata['feature']).to eq('ai_test')
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
end
describe '#grant_monthly_credits' do
it 'grants new monthly credits and logs transaction metadata' do
expect { service.grant_monthly_credits(500, metadata: { source: 'spec' }) }
.to change { account.credit_transactions.count }.by(2) # expire + grant
account.reload
expect(account.custom_attributes['monthly_credits']).to eq(500)
expect(account.credit_transactions.order(created_at: :desc).first.metadata['source']).to eq('spec')
end
end
describe '#add_topup_credits' do
it 'adds topup credits' do
expect { service.add_topup_credits(100, metadata: { source: 'spec' }) }
.to change { account.credit_transactions.count }.by(1)
account.reload
expect(account.custom_attributes['topup_credits']).to eq(150)
expect(account.credit_transactions.order(created_at: :desc).first.metadata['source']).to eq('spec')
end
end
end

View File

@@ -0,0 +1,69 @@
require 'rails_helper'
describe Enterprise::Billing::V2::SubscriptionService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
let(:config) { Rails.application.config.stripe_v2 }
around do |example|
original_config = config.deep_dup
example.run
config.replace(original_config)
end
before do
config[:plans] ||= {}
config[:plans][:startup] = {
monthly_credits: 800,
pricing_plan_id: 'plan_startup'
}
end
describe '#migrate_to_v2' do
it 'updates account attributes and logs initial credit grant' do # rubocop:disable RSpec/MultipleExpectations
result = nil
expect do
result = service.migrate_to_v2(plan_type: 'startup')
end.to change { account.reload.credit_transactions.count }.by(1)
account.reload
expect(result).to include(success: true)
expect(account.custom_attributes['stripe_billing_version']).to eq(2)
expect(account.custom_attributes['monthly_credits']).to eq(800)
expect(account.custom_attributes['plan_name']).to eq('Startup')
transaction = account.credit_transactions.order(created_at: :desc).first
expect(transaction.amount).to eq(800)
expect(transaction.metadata['source']).to eq('migration')
expect(transaction.metadata['plan_type']).to eq('startup')
end
it 'returns error when already on v2' do
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
result = service.migrate_to_v2
expect(result[:success]).to be(false)
expect(result[:message]).to eq('Already on V2')
end
end
describe '#update_plan' do
it 'updates plan name when on v2 billing' do
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
result = service.update_plan('business')
expect(result).to include(success: true, plan: 'business')
expect(account.reload.custom_attributes['plan_name']).to eq('Business')
end
it 'returns error when account not migrated' do
result = service.update_plan('business')
expect(result[:success]).to be(false)
expect(result[:message]).to eq('Not on V2 billing')
end
end
end

View File

@@ -0,0 +1,52 @@
require 'rails_helper'
describe Enterprise::Billing::V2::UsageAnalyticsService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
describe '#fetch_usage_summary' do
context 'when account not on v2 billing' do
it 'returns error response' do
result = service.fetch_usage_summary
expect(result[:success]).to be(false)
expect(result[:message]).to eq('Not on V2 billing')
end
end
context 'when account has usage' do
before do
account.update!(custom_attributes: (account.custom_attributes || {}).merge(
'stripe_billing_version' => 2,
'stripe_customer_id' => 'cus_123'
))
account.credit_transactions.create!(
transaction_type: 'use',
credit_type: 'monthly',
amount: 5,
description: 'spec monthly',
metadata: {},
created_at: Time.current
)
account.credit_transactions.create!(
transaction_type: 'use',
credit_type: 'topup',
amount: 3,
description: 'spec topup',
metadata: {},
created_at: Time.current
)
end
it 'aggregates usage from credit transactions' do
result = service.fetch_usage_summary
expect(result[:success]).to be(true)
expect(result[:total_usage]).to eq(8)
expect(result[:source]).to eq('local')
end
end
end
end

View File

@@ -0,0 +1,50 @@
require 'rails_helper'
describe Enterprise::Billing::V2::UsageReporterService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
let(:config) { Rails.application.config.stripe_v2 }
let!(:original_config) { config.deep_dup }
before do
account.update!(
custom_attributes: (account.custom_attributes || {}).merge(
'stripe_billing_version' => 2,
'stripe_customer_id' => 'cus_test_123'
)
)
config[:meter_id] = 'mtr_test_123'
config[:meter_event_name] = 'chat_prompts'
config[:usage_reporting_enabled] = true
end
after { config.replace(original_config) }
it 'posts usage events to Stripe meters' do # rubocop:disable RSpec/MultipleExpectations
response = { 'id' => 'me_test_123' }
stripe_client = instance_double(Stripe::StripeClient)
allow(service).to receive(:stripe_client).and_return(stripe_client)
allow(stripe_client).to receive(:execute_request).and_return(response)
result = service.report(5, 'ai_test')
expect(result).to include(success: true, reported_credits: 5)
expect(stripe_client).to have_received(:execute_request) do |method, path, **kw|
expect(method).to eq(:post)
expect(path).to eq('/v1/billing/meter_events')
expect(kw[:headers]).to include('Idempotency-Key')
expect(kw[:params][:event_name]).to eq('chat_prompts')
expect(kw[:params][:payload][:value]).to eq(5)
expect(kw[:params][:payload][:stripe_customer_id]).to eq('cus_test_123')
end
end
it 'returns failure when usage reporting disabled' do
Rails.application.config.stripe_v2[:usage_reporting_enabled] = false
result = service.report(5, 'ai_test')
expect(result).to include(success: false)
end
end

View File

@@ -0,0 +1,84 @@
require 'rails_helper'
# rubocop:disable RSpec/VerifiedDoubles
describe Enterprise::Billing::V2::WebhookHandlerService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
before do
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).with(account: account).and_return(credit_service)
end
def build_event(type:, id:, object:)
data = double('Stripe::Event::Data', object: object)
double('Stripe::Event', type: type, id: id, data: data)
end
describe '#process' do
context 'when handling monthly credit grant' do
it 'grants monthly credits with metadata' do
allow(credit_service).to receive(:grant_monthly_credits).and_return(success: true)
grant = Struct.new(:id, :amount, :expires_at).new('cg_123', 2000, Time.current)
event = build_event(type: 'billing.credit_grant.created', id: 'evt_123', object: grant)
result = service.process(event)
expect(result[:success]).to be(true)
expect(credit_service).to have_received(:grant_monthly_credits).with(2000,
metadata: hash_including('stripe_event_id' => 'evt_123',
'grant_id' => 'cg_123'))
end
end
context 'when handling topup credit grant' do
it 'adds topup credits' do
allow(credit_service).to receive(:add_topup_credits).and_return(success: true)
grant = Struct.new(:id, :amount, :expires_at).new('cg_456', 500, nil)
event = build_event(type: 'billing.credit_grant.created', id: 'evt_456', object: grant)
result = service.process(event)
expect(result[:success]).to be(true)
expect(credit_service).to have_received(:add_topup_credits)
.with(500, metadata: hash_including('stripe_event_id' => 'evt_456', 'grant_id' => 'cg_456', 'source' => 'credit_grant'))
end
end
context 'when invoice payment succeeded for a topup' do
it 'adds topup credits from invoice metadata' do
allow(credit_service).to receive(:add_topup_credits).and_return(success: true)
invoice_metadata = ActiveSupport::HashWithIndifferentAccess.new(type: 'topup', credits: '300')
invoice = Struct.new(:id, :metadata).new('in_123', invoice_metadata)
event = build_event(type: 'invoice.payment_succeeded', id: 'evt_789', object: invoice)
result = service.process(event)
expect(result[:success]).to be(true)
expect(credit_service).to have_received(:add_topup_credits)
.with(300, metadata: hash_including('stripe_event_id' => 'evt_789', 'invoice_id' => 'in_123', 'source' => 'invoice'))
end
end
context 'when event already processed' do
it 'skips duplicate handling' do
account.credit_transactions.create!(
transaction_type: 'grant',
amount: 100,
credit_type: 'monthly',
metadata: { 'stripe_event_id' => 'evt_dup' }
)
allow(credit_service).to receive(:grant_monthly_credits)
grant = Struct.new(:id, :amount, :expires_at).new('cg_dup', 200, Time.current)
event = build_event(type: 'billing.credit_grant.created', id: 'evt_dup', object: grant)
result = service.process(event)
expect(result[:success]).to be(true)
expect(credit_service).not_to have_received(:grant_monthly_credits)
end
end
end
end
# rubocop:enable RSpec/VerifiedDoubles