mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
stripe v2 init
This commit is contained in:
@@ -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
|
||||
|
||||
28
app/models/credit_transaction.rb
Normal file
28
app/models/credit_transaction.rb
Normal 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
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
14
config/schedule_v2_billing.yml
Normal file
14
config/schedule_v2_billing.yml
Normal 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"
|
||||
15
db/migrate/20251007111938_create_credit_transactions.rb
Normal file
15
db/migrate/20251007111938_create_credit_transactions.rb
Normal 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
|
||||
62
db/schema.rb
62
db/schema.rb
@@ -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").
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
12
enterprise/app/jobs/enterprise/billing/report_usage_job.rb
Normal file
12
enterprise/app/jobs/enterprise/billing/report_usage_job.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
214
setup_stripe_v2_billing.rb
Executable 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user