mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 11:08:04 +00:00 
			
		
		
		
	stripe v2 init
This commit is contained in:
		| @@ -8,18 +8,29 @@ | |||||||
| #  domain                 :string(100) | #  domain                 :string(100) | ||||||
| #  feature_flags          :bigint           default(0), not null | #  feature_flags          :bigint           default(0), not null | ||||||
| #  internal_attributes    :jsonb            not null | #  internal_attributes    :jsonb            not null | ||||||
|  | #  last_credit_sync_at    :datetime | ||||||
| #  limits                 :jsonb | #  limits                 :jsonb | ||||||
| #  locale                 :integer          default("en") | #  locale                 :integer          default("en") | ||||||
|  | #  monthly_credits        :integer          default(0), not null | ||||||
| #  name                   :string           not null | #  name                   :string           not null | ||||||
| #  settings               :jsonb | #  settings               :jsonb | ||||||
| #  status                 :integer          default("active") | #  status                 :integer          default("active") | ||||||
|  | #  stripe_billing_version :integer          default(1), not null | ||||||
| #  support_email          :string(100) | #  support_email          :string(100) | ||||||
|  | #  topup_credits          :integer          default(0), not null | ||||||
| #  created_at             :datetime         not null | #  created_at             :datetime         not null | ||||||
| #  updated_at             :datetime         not null | #  updated_at             :datetime         not null | ||||||
|  | #  stripe_cadence_id      :string | ||||||
|  | #  stripe_customer_id     :string | ||||||
|  | #  stripe_pricing_plan_id :string | ||||||
| # | # | ||||||
| # Indexes | # 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 | class Account < ApplicationRecord | ||||||
| @@ -69,6 +80,7 @@ class Account < ApplicationRecord | |||||||
|   has_many :categories, dependent: :destroy_async, class_name: '::Category' |   has_many :categories, dependent: :destroy_async, class_name: '::Category' | ||||||
|   has_many :contacts, dependent: :destroy_async |   has_many :contacts, dependent: :destroy_async | ||||||
|   has_many :conversations, 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 :csat_survey_responses, dependent: :destroy_async | ||||||
|   has_many :custom_attribute_definitions, dependent: :destroy_async |   has_many :custom_attribute_definitions, dependent: :destroy_async | ||||||
|   has_many :custom_filters, 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' | require 'stripe' | ||||||
|  |  | ||||||
| Stripe.api_key = ENV.fetch('STRIPE_SECRET_KEY', nil) | 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 |               post :subscription | ||||||
|               get :limits |               get :limits | ||||||
|               post :toggle_deletion |               post :toggle_deletion | ||||||
|  |               # V2 Billing endpoints | ||||||
|  |               get :credits_balance | ||||||
|  |               get :usage_metrics | ||||||
|  |               post :enable_v2_billing | ||||||
|             end |             end | ||||||
|           end |           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. | # 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 |   # These extensions should be enabled to support this database | ||||||
|   enable_extension "pg_stat_statements" |   enable_extension "pg_stat_statements" | ||||||
|   enable_extension "pg_trgm" |   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.integer "status", default: 0 | ||||||
|     t.jsonb "internal_attributes", default: {}, null: false |     t.jsonb "internal_attributes", default: {}, null: false | ||||||
|     t.jsonb "settings", default: {} |     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 ["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 |   end | ||||||
|  |  | ||||||
|   create_table "action_mailbox_inbound_emails", force: :cascade do |t| |   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" |     t.index ["user_id"], name: "index_copilot_threads_on_user_id" | ||||||
|   end |   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| |   create_table "csat_survey_responses", force: :cascade do |t| | ||||||
|     t.bigint "account_id", null: false |     t.bigint "account_id", null: false | ||||||
|     t.bigint "conversation_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 |     t.index ["name", "account_id"], name: "index_email_templates_on_name_and_account_id", unique: true | ||||||
|   end |   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| |   create_table "folders", force: :cascade do |t| | ||||||
|     t.integer "account_id", null: false |     t.integer "account_id", null: false | ||||||
|     t.integer "category_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 |     t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true | ||||||
|   end |   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| |   create_table "leaves", force: :cascade do |t| | ||||||
|     t.bigint "account_id", null: false |     t.bigint "account_id", null: false | ||||||
|     t.bigint "user_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.text "message_signature" | ||||||
|     t.string "otp_secret" |     t.string "otp_secret" | ||||||
|     t.integer "consumed_timestep" |     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.text "otp_backup_codes" | ||||||
|     t.index ["email"], name: "index_users_on_email" |     t.index ["email"], name: "index_users_on_email" | ||||||
|     t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login" |     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_attachments", "active_storage_blobs", column: "blob_id" | ||||||
|   add_foreign_key "active_storage_variant_records", "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" |   add_foreign_key "inboxes", "portals" | ||||||
|   create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1). |   create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1). | ||||||
|       on("accounts"). |       on("accounts"). | ||||||
|   | |||||||
| @@ -55,6 +55,56 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController | |||||||
|     end |     end | ||||||
|   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 |   private | ||||||
|  |  | ||||||
|   def check_cloud_env |   def check_cloud_env | ||||||
| @@ -120,4 +170,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController | |||||||
|       account_user: @current_account_user |       account_user: @current_account_user | ||||||
|     } |     } | ||||||
|   end |   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 | end | ||||||
|   | |||||||
| @@ -7,7 +7,14 @@ class Enterprise::Webhooks::StripeController < ActionController::API | |||||||
|     # Attempt to verify the signature. If successful, we'll handle the event |     # Attempt to verify the signature. If successful, we'll handle the event | ||||||
|     begin |     begin | ||||||
|       event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)) |       event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)) | ||||||
|  |  | ||||||
|  |       # 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) |         ::Enterprise::Billing::HandleStripeEventService.new.perform(event: event) | ||||||
|  |       end | ||||||
|     # If we fail to verify the signature, then something was wrong with the request |     # If we fail to verify the signature, then something was wrong with the request | ||||||
|     rescue JSON::ParserError, Stripe::SignatureVerificationError |     rescue JSON::ParserError, Stripe::SignatureVerificationError | ||||||
|       # Invalid payload |       # Invalid payload | ||||||
| @@ -18,4 +25,59 @@ class Enterprise::Webhooks::StripeController < ActionController::API | |||||||
|     # We've successfully processed the event without blowing up |     # We've successfully processed the event without blowing up | ||||||
|     head :ok |     head :ok | ||||||
|   end |   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 | 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 |                            make_friendly make_formal simplify].freeze | ||||||
|   CACHEABLE_EVENTS = %w[label_suggestion].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 |   def label_suggestion_message | ||||||
|     payload = label_suggestion_body |     payload = label_suggestion_body | ||||||
|     return nil if payload.blank? |     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' | require 'rails_helper' | ||||||
|  | # rubocop:disable RSpec/VerifiedDoubles | ||||||
|  |  | ||||||
| RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do | RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do | ||||||
|   describe 'POST /enterprise/webhooks/stripe' do |   describe 'POST /enterprise/webhooks/stripe' do | ||||||
|     let(:params) { { content: 'hello' } } |     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 |       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(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe) | ||||||
|       allow(handle_stripe).to receive(:perform) |       allow(handle_stripe).to receive(:perform) | ||||||
|  |  | ||||||
|       post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params |       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 |     end | ||||||
|  |  | ||||||
|     it 'returns a bad request if the headers are missing' do |     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 |   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
	 Tanmay Deep Sharma
					Tanmay Deep Sharma