From 1e11f271f0c508022dfcc50af8d9f9d13b34c8f7 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 10 Nov 2025 17:40:17 +0530 Subject: [PATCH] feat: add support for topup credits --- .../api/v1/accounts/concerns/billing_v2.rb | 9 +- .../enterprise/billing/v2/topup_service.rb | 145 ++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 enterprise/app/services/enterprise/billing/v2/topup_service.rb diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb index dc52958bd..f56cd187f 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb @@ -23,7 +23,14 @@ module Enterprise::Api::V1::Accounts::Concerns::BillingV2 end def v2_topup - render json: { success: true, message: 'Topup successful.' } + service = Enterprise::Billing::V2::TopupService.new(account: @account) + result = service.create_topup(credits: params[:credits].to_i) + + if result[:success] + render json: { success: true, message: result[:message] } + else + render json: { error: result[:message] }, status: :unprocessable_entity + end end def v2_subscribe diff --git a/enterprise/app/services/enterprise/billing/v2/topup_service.rb b/enterprise/app/services/enterprise/billing/v2/topup_service.rb new file mode 100644 index 000000000..8f54b7562 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/topup_service.rb @@ -0,0 +1,145 @@ +class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseService + def create_topup(credits:) + validation_result = validate_topup_request(credits) + return validation_result unless validation_result[:valid] + + topup_definition = validation_result[:topup_definition] + amount_cents = (topup_definition[:amount] * 100).to_i + currency = topup_definition[:currency] || 'usd' + + with_locked_account do + process_topup_transaction(credits, amount_cents, currency, topup_definition[:amount]) + end + rescue Stripe::StripeError => e + { success: false, message: "Stripe error: #{e.message}" } + end + + private + + def validate_topup_request(credits) + return { valid: false, success: false, message: 'Invalid topup amount' } unless credits.to_i.positive? + + plan_validation = validate_subscription_plan + return plan_validation unless plan_validation[:valid] + + topup_definition = Enterprise::Billing::V2::TopupCatalog.find_option(credits) + return { valid: false, success: false, message: 'Unsupported topup amount' } unless topup_definition + return { valid: false, success: false, message: 'Stripe customer not configured' } if stripe_customer_id.blank? + + payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account) + payment_method_validation = payment_service.validate_payment_method + return payment_method_validation.merge(valid: false) if payment_method_validation + + { valid: true, topup_definition: topup_definition } + end + + def validate_subscription_plan + plan_name = custom_attribute('plan_name') + + # Block topup if no plan or on Hacker plan + if plan_name.blank? || plan_name.downcase == 'hacker' + return { + valid: false, + success: false, + message: 'Top-ups are only available for Startup, Business, and Enterprise plans. Please upgrade your plan to purchase credits.' + } + end + + { valid: true } + end + + def process_topup_transaction(credits, amount_cents, currency, amount) + line_items = build_topup_line_items(credits, amount_cents) + invoice_result = charge_topup_invoice(line_items, currency) + return invoice_result unless invoice_result[:success] + + credit_grant = create_stripe_credit_grant(amount_cents, currency, credits) + return { success: false, message: 'Failed to create credit grant in Stripe' } unless credit_grant + + build_success_response(credits, amount, currency, invoice_result[:invoice_id], credit_grant['id']) + end + + def build_topup_line_items(credits, amount_cents) + [{ + amount: amount_cents, + description: "Credit Topup: #{credits} credits", + metadata: { + account_id: account.id.to_s, + credits: credits.to_s, + topup: 'true' + } + }] + end + + def charge_topup_invoice(line_items, currency) + payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account) + payment_service.create_and_pay_invoice( + line_items: line_items, + description: 'Credit top-up purchase', + currency: currency, + metadata: { + account_id: account.id.to_s, + topup: 'true' + } + ) + end + + def build_success_response(credits, amount, currency, invoice_id, credit_grant_id) + { + success: true, + message: 'Top-up purchased successfully', + credits: credits, + amount: amount, + currency: currency, + invoice_id: invoice_id, + credit_grant_id: credit_grant_id + } + end + + # Create Credit Grant in Stripe using monetary amount (not custom_pricing_unit) + # Following Stripe UBB Integration Guide section 8 + def create_stripe_credit_grant(amount_cents, currency, credits) + Stripe::Billing::CreditGrant.create( + credit_grant_params(amount_cents, currency, credits) + ) + end + + def credit_grant_params(amount_cents, currency, credits) + { + customer: stripe_customer_id, + name: "Topup: #{credits} credits", + amount: credit_grant_amount(amount_cents, currency), + applicability_config: credit_grant_applicability, + category: 'paid', + metadata: credit_grant_metadata(credits) + } + end + + def credit_grant_amount(amount_cents, currency) + { + type: 'monetary', + monetary: { + currency: currency, + value: amount_cents + } + } + end + + def credit_grant_applicability + # Apply credit grant to all metered usage for this customer + # This ensures topup credits offset meter-based billing + { + scope: { + price_type: 'metered' + } + } + end + + def credit_grant_metadata(credits) + { + account_id: account.id.to_s, + source: 'topup', + credits: credits.to_s + } + end +end