From acb2816826feb92e954852eb1934ab5ab9fe8fe2 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 8 Oct 2025 10:11:23 +0200 Subject: [PATCH] stripe v2 init --- app/models/account.rb | 42 ++-- app/models/credit_transaction.rb | 28 +++ config/initializers/stripe.rb | 41 ++++ config/routes.rb | 4 + config/schedule_v2_billing.yml | 14 ++ ...251007111938_create_credit_transactions.rb | 15 ++ db/schema.rb | 62 ++++- .../enterprise/api/v1/accounts_controller.rb | 58 +++++ .../enterprise/webhooks/stripe_controller.rb | 64 +++++- .../enterprise/billing/report_usage_job.rb | 12 + .../enterprise/ai/captain_credit_service.rb | 26 +++ .../enterprise/billing/v2/base_service.rb | 72 ++++++ .../billing/v2/credit_management_service.rb | 128 +++++++++++ .../billing/v2/subscription_service.rb | 52 +++++ .../billing/v2/usage_analytics_service.rb | 36 +++ .../billing/v2/usage_reporter_service.rb | 85 +++++++ .../billing/v2/webhook_handler_service.rb | 119 ++++++++++ .../integrations/openai_processor_service.rb | 24 ++ setup_stripe_v2_billing.rb | 214 ++++++++++++++++++ .../webooks/stripe_controller_spec.rb | 34 ++- .../ai/captain_credit_service_spec.rb | 60 +++++ .../v2/credit_management_service_spec.rb | 74 ++++++ .../billing/v2/subscription_service_spec.rb | 69 ++++++ .../v2/usage_analytics_service_spec.rb | 52 +++++ .../billing/v2/usage_reporter_service_spec.rb | 50 ++++ .../v2/webhook_handler_service_spec.rb | 84 +++++++ 26 files changed, 1498 insertions(+), 21 deletions(-) create mode 100644 app/models/credit_transaction.rb create mode 100644 config/schedule_v2_billing.yml create mode 100644 db/migrate/20251007111938_create_credit_transactions.rb create mode 100644 enterprise/app/jobs/enterprise/billing/report_usage_job.rb create mode 100644 enterprise/app/services/enterprise/ai/captain_credit_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/base_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/credit_management_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/subscription_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb create mode 100755 setup_stripe_v2_billing.rb create mode 100644 spec/enterprise/services/enterprise/ai/captain_credit_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/v2/subscription_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb diff --git a/app/models/account.rb b/app/models/account.rb index 7efb13bb5..9f24c9f25 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/credit_transaction.rb b/app/models/credit_transaction.rb new file mode 100644 index 000000000..048131653 --- /dev/null +++ b/app/models/credit_transaction.rb @@ -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 diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb index 585d9dad4..8e922484e 100644 --- a/config/initializers/stripe.rb +++ b/config/initializers/stripe.rb @@ -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] + } +} diff --git a/config/routes.rb b/config/routes.rb index 757d20620..171f7b7c9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/config/schedule_v2_billing.yml b/config/schedule_v2_billing.yml new file mode 100644 index 000000000..f8784d4a0 --- /dev/null +++ b/config/schedule_v2_billing.yml @@ -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" \ No newline at end of file diff --git a/db/migrate/20251007111938_create_credit_transactions.rb b/db/migrate/20251007111938_create_credit_transactions.rb new file mode 100644 index 000000000..2691403f9 --- /dev/null +++ b/db/migrate/20251007111938_create_credit_transactions.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index f31d05cc3..f42d6d127 100644 --- a/db/schema.rb +++ b/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"). diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 339fbdf3c..9bcfa58ad 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb index 0b66182d1..b99ddb9d0 100644 --- a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb +++ b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb @@ -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 diff --git a/enterprise/app/jobs/enterprise/billing/report_usage_job.rb b/enterprise/app/jobs/enterprise/billing/report_usage_job.rb new file mode 100644 index 000000000..48a12f48b --- /dev/null +++ b/enterprise/app/jobs/enterprise/billing/report_usage_job.rb @@ -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 diff --git a/enterprise/app/services/enterprise/ai/captain_credit_service.rb b/enterprise/app/services/enterprise/ai/captain_credit_service.rb new file mode 100644 index 000000000..97097be53 --- /dev/null +++ b/enterprise/app/services/enterprise/ai/captain_credit_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/base_service.rb b/enterprise/app/services/enterprise/billing/v2/base_service.rb new file mode 100644 index 000000000..353ec49b9 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/base_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb new file mode 100644 index 000000000..cd6ba28d4 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/subscription_service.rb new file mode 100644 index 000000000..862267328 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/subscription_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb b/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb new file mode 100644 index 000000000..05d16d99d --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb new file mode 100644 index 000000000..067d0b1e0 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb new file mode 100644 index 000000000..56fa87e4f --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -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 diff --git a/enterprise/lib/enterprise/integrations/openai_processor_service.rb b/enterprise/lib/enterprise/integrations/openai_processor_service.rb index 5a98ad4c4..259f39e2f 100644 --- a/enterprise/lib/enterprise/integrations/openai_processor_service.rb +++ b/enterprise/lib/enterprise/integrations/openai_processor_service.rb @@ -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? diff --git a/setup_stripe_v2_billing.rb b/setup_stripe_v2_billing.rb new file mode 100755 index 000000000..5bb085984 --- /dev/null +++ b/setup_stripe_v2_billing.rb @@ -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 diff --git a/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb b/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb index bbcddeb29..c8e4b3f00 100644 --- a/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/ai/captain_credit_service_spec.rb b/spec/enterprise/services/enterprise/ai/captain_credit_service_spec.rb new file mode 100644 index 000000000..036b6aaf2 --- /dev/null +++ b/spec/enterprise/services/enterprise/ai/captain_credit_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb new file mode 100644 index 000000000..4b2c28672 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/subscription_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/subscription_service_spec.rb new file mode 100644 index 000000000..589a2df76 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/v2/subscription_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb new file mode 100644 index 000000000..0bf0dc16c --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb new file mode 100644 index 000000000..48d274e43 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb new file mode 100644 index 000000000..88512606e --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb @@ -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