From 6c68f34378a2e55b5b9e4911fd367b2de3e14928 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 16 Jun 2026 21:08:12 -0400 Subject: [PATCH] feat(white-label): POST /api/v1/white_label/provision endpoint + ProvisionService Extracts white-label provisioning into WhiteLabel::ProvisionService (Stripe-linked customer under the flobyte entity + pending agreement, returns signing + portal URLs). Adds an authenticated API endpoint so the app's platform admin can provision (passing the platform owner email as external_id) instead of running a rake task. The rake task now delegates to the same service. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/v1/white_label_controller.rb | 43 ++++++++++++ app/services/white_label/provision_service.rb | 65 +++++++++++++++++++ config/routes.rb | 3 + lib/tasks/flobyte.rake | 51 ++------------- 4 files changed, 118 insertions(+), 44 deletions(-) create mode 100644 app/controllers/api/v1/white_label_controller.rb create mode 100644 app/services/white_label/provision_service.rb diff --git a/app/controllers/api/v1/white_label_controller.rb b/app/controllers/api/v1/white_label_controller.rb new file mode 100644 index 000000000000..82912b1b8af6 --- /dev/null +++ b/app/controllers/api/v1/white_label_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Api + module V1 + # Internal provisioning endpoint for white-label / SDK customers, called by + # the app's platform admin (which resolves the platform owner email and + # passes it as external_id). Authenticated by the org's Lago API key. + class WhiteLabelController < Api::BaseController + def provision + result = ::WhiteLabel::ProvisionService.call( + organization: current_organization, + external_id: params[:external_id], + name: params[:name], + email: params[:email], + plan_code: params[:plan_code] + ) + + if result.success? + render(json: { + white_label_agreement: { + lago_id: result.agreement.id, + status: result.agreement.status, + plan_code: result.agreement.plan_code, + customer_external_id: result.customer.external_id, + signing_url: result.signing_url, + portal_url: result.portal_url + } + }) + else + render_error_response(result) + end + end + + private + + # Authentication (a valid org API key) is sufficient for this internal + # endpoint; there is no per-resource API-key permission for white_label. + def authorize + true + end + end + end +end diff --git a/app/services/white_label/provision_service.rb b/app/services/white_label/provision_service.rb new file mode 100644 index 000000000000..efcfc8c02fa9 --- /dev/null +++ b/app/services/white_label/provision_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module WhiteLabel + # Provisions a white-label / SDK customer: a Stripe-linked customer under the + # `flobyte` billing entity + a pending MSA agreement. Returns the signing and + # portal URLs. Shared by the flobyte:provision_whitelabel rake task and the + # POST /api/v1/white_label/provision endpoint (called by the app's platform + # admin, which resolves the platform owner email and passes it as external_id). + class ProvisionService < BaseService + Result = BaseResult[:agreement, :customer, :signing_url, :portal_url] + + BILLING_ENTITY_CODE = "flobyte" + DEFAULT_PLAN_CODE = "aistack-whitelabel" + MSA_VERSION = "v1" + TERMS_VERSION = "v1" + + def initialize(organization:, external_id:, name:, email:, plan_code: nil) + @organization = organization + @external_id = external_id.to_s.strip + @name = name + @email = email + @plan_code = plan_code.presence || DEFAULT_PLAN_CODE + super + end + + def call + return result.single_validation_failure!(error_code: "missing_external_id") if external_id.blank? + + plan = organization.plans.find_by(code: plan_code) + return result.not_found_failure!(resource: "plan") if plan.nil? + + stripe = organization.stripe_payment_providers.first + return result.single_validation_failure!(error_code: "no_stripe_provider") if stripe.nil? + + customer = organization.customers.find_by(external_id:) + unless customer + create_result = ::Customers::CreateService.call( + organization_id: organization.id, + billing_entity_code: BILLING_ENTITY_CODE, + external_id:, name:, email:, currency: "USD", + payment_provider: "stripe", payment_provider_code: stripe.code, + provider_customer: {sync_with_provider: true, sync: true} + ) + return create_result unless create_result.success? + customer = create_result.customer + end + + agreement = WhiteLabelAgreement.where(customer_id: customer.id).where.not(status: "superseded").first + agreement ||= WhiteLabelAgreement.create!( + organization:, customer:, plan_code:, + msa_version: MSA_VERSION, terms_version: TERMS_VERSION, status: "pending" + ) + + result.agreement = agreement + result.customer = customer + result.signing_url = agreement.signing_url + result.portal_url = ::CustomerPortal::GenerateUrlService.call(customer:).url + result + end + + private + + attr_reader :organization, :external_id, :name, :email, :plan_code + end +end diff --git a/config/routes.rb b/config/routes.rb index d7f635bda05e..035fb5d4727a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,9 @@ namespace :api do namespace :v1 do + # White-label / SDK provisioning (called by the app's platform admin) + post "white_label/provision", to: "white_label#provision" + resources :activity_logs, param: :activity_id, only: %i[index show] resources :api_logs, param: :request_id, only: %i[index show] resources :security_logs, param: :log_id, only: %i[index show] diff --git a/lib/tasks/flobyte.rake b/lib/tasks/flobyte.rake index 0c93e931b0d9..5bfc62a38ecc 100644 --- a/lib/tasks/flobyte.rake +++ b/lib/tasks/flobyte.rake @@ -181,51 +181,14 @@ module FlobyteCatalog def self.provision_whitelabel!(organization_id:, external_id:, name:, email:, plan_code: WHITELABEL_PLAN_CODE) organization = Organization.find(organization_id) + result = ::WhiteLabel::ProvisionService.call( + organization:, external_id:, name:, email:, plan_code: + ) + result.raise_if_error! - plan = organization.plans.find_by(code: plan_code) - abort "Plan '#{plan_code}' not found — run flobyte:seed[#{organization_id}] first" if plan.nil? - - stripe = organization.stripe_payment_providers.first - abort "No Stripe payment provider on organization #{organization_id} — connect Stripe before provisioning" if stripe.nil? - - customer = organization.customers.find_by(external_id:) - if customer - puts "✓ customer '#{external_id}' already exists (#{customer.id})" - else - result = ::Customers::CreateService.call( - organization_id: organization.id, - billing_entity_code: FlobyteCatalog::BILLING_ENTITY_CODE, - external_id:, - name:, - email:, - currency: "USD", - payment_provider: "stripe", - payment_provider_code: stripe.code, - # Eagerly create the Stripe customer (sync) so the acceptance gate can - # generate a checkout URL immediately — without this, Lago only records - # *which* provider to use and never creates the Stripe customer. - provider_customer: {sync_with_provider: true, sync: true} - ) - result.raise_if_error! - customer = result.customer - puts "+ customer '#{external_id}' (#{customer.id}) under '#{FlobyteCatalog::BILLING_ENTITY_CODE}', Stripe-linked" - end - - agreement = WhiteLabelAgreement.where(customer_id: customer.id) - .where.not(status: "superseded").first - if agreement - puts "✓ agreement already exists (status: #{agreement.status})" - else - agreement = WhiteLabelAgreement.create!( - organization:, customer:, plan_code:, - msa_version: MSA_VERSION, terms_version: TERMS_VERSION, status: "pending" - ) - puts "+ pending agreement #{agreement.id}" - end - - link = agreement.signing_url - puts "\nEmail this acceptance link to #{name} <#{email}> (valid #{WhiteLabelAgreement::TOKEN_TTL.inspect}):\n\n #{link}\n" - link + puts "✓ customer '#{external_id}' (#{result.customer.id}), agreement #{result.agreement.id} (#{result.agreement.status})" + puts "\nEmail this acceptance link to #{name} <#{email}> (valid #{WhiteLabelAgreement::TOKEN_TTL.inspect}):\n\n #{result.signing_url}\n" + result.signing_url end def self.apply_entitlements!(organization, plan, spec, dry_run:)