diff --git a/app/controllers/api/v1/payment_providers/stripe_connect_controller.rb b/app/controllers/api/v1/payment_providers/stripe_connect_controller.rb new file mode 100644 index 000000000000..13c98fa00139 --- /dev/null +++ b/app/controllers/api/v1/payment_providers/stripe_connect_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Api + module V1 + module PaymentProviders + # Registers a reseller's Stripe Connect account (acct_…) as an + # entity-scoped Stripe payment provider, so the agency's sub-account + # retail invoices collect on the agency's OWN connected account. + # + # Server-to-server (api_key auth). The connected provider reuses the + # organization's platform Stripe key — calls run on the connected account + # via the Stripe-Account header (see PaymentProviders::StripeProvider). + # Idempotent by provider code. + class StripeConnectController < Api::BaseController + def create + platform_provider = current_organization.stripe_payment_providers + .where(billing_entity_id: nil).first + return not_found_error(resource: "stripe_payment_provider") if platform_provider.blank? + + result = ::PaymentProviders::StripeService.new.create_or_update( + organization_id: current_organization.id, + code: create_params[:code], + name: create_params[:name], + secret_key: platform_provider.secret_key, + billing_entity_code: create_params[:billing_entity_code], + connected_account_id: create_params[:connected_account_id] + ) + + if result.success? + render(json: {payment_provider: {lago_id: result.stripe_provider.id, code: result.stripe_provider.code}}) + else + render_error_response(result) + end + end + + private + + def create_params + params.require(:payment_provider).permit(:code, :name, :billing_entity_code, :connected_account_id) + end + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 57f43f1b2e3c..631545794f99 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -54,6 +54,9 @@ resources :billing_entities, param: :code, only: %i[index show update create] + # Register a reseller's Stripe Connect account as an entity-scoped provider. + post "payment_providers/stripe_connect", to: "payment_providers/stripe_connect#create" + resources :customers, param: :external_id, only: %i[create index show destroy] do get :portal_url diff --git a/db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb b/db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb index fe3901389b44..33929aa363ff 100644 --- a/db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb +++ b/db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb @@ -5,12 +5,13 @@ # for that entity's customers. NULL keeps the existing organization-level # behaviour unchanged (all existing providers stay org-level). class AddBillingEntityToPaymentProviders < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + def change - add_reference :payment_providers, - :billing_entity, - type: :uuid, - null: true, - foreign_key: true, - index: true + # Column + index only (no DB-level FK): billing_entity_id is an optional, + # app-managed reference; keeping it FK-free avoids the schema-dump churn and + # keeps the change minimal. + add_reference :payment_providers, :billing_entity, type: :uuid, null: true, + index: {algorithm: :concurrently} end end diff --git a/db/structure.sql b/db/structure.sql index 92706cfc6dd0..326c78108813 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -250,7 +250,6 @@ ALTER TABLE IF EXISTS ONLY public.wallets DROP CONSTRAINT IF EXISTS fk_rails_2b3 ALTER TABLE IF EXISTS ONLY public.usage_thresholds DROP CONSTRAINT IF EXISTS fk_rails_2908dd8de5; ALTER TABLE IF EXISTS ONLY public.wallets DROP CONSTRAINT IF EXISTS fk_rails_28077d4aa2; ALTER TABLE IF EXISTS ONLY public.charge_filters DROP CONSTRAINT IF EXISTS fk_rails_27b55b8574; -ALTER TABLE IF EXISTS ONLY public.payment_providers DROP CONSTRAINT IF EXISTS fk_rails_pp_billing_entity; ALTER TABLE IF EXISTS ONLY public.payment_providers DROP CONSTRAINT IF EXISTS fk_rails_26be2f764d; ALTER TABLE IF EXISTS ONLY public.billing_entities_taxes DROP CONSTRAINT IF EXISTS fk_rails_268c288aaa; ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_257af22645; @@ -10290,14 +10289,6 @@ ALTER TABLE ONLY public.payment_providers ADD CONSTRAINT fk_rails_26be2f764d FOREIGN KEY (organization_id) REFERENCES public.organizations(id); --- --- Name: payment_providers fk_rails_pp_billing_entity; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.payment_providers - ADD CONSTRAINT fk_rails_pp_billing_entity FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id); - - -- -- Name: charge_filters fk_rails_27b55b8574; Type: FK CONSTRAINT; Schema: public; Owner: - -- diff --git a/spec/requests/api/v1/payment_providers/stripe_connect_controller_spec.rb b/spec/requests/api/v1/payment_providers/stripe_connect_controller_spec.rb new file mode 100644 index 000000000000..2f6ed31fe6b1 --- /dev/null +++ b/spec/requests/api/v1/payment_providers/stripe_connect_controller_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::PaymentProviders::StripeConnectController do + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:platform_provider) { create(:stripe_provider, organization:) } + + describe "POST /api/v1/payment_providers/stripe_connect" do + subject { post_with_token(organization, "/api/v1/payment_providers/stripe_connect", params) } + + let(:params) do + { + payment_provider: { + code: "stripe_reseller_abc", + name: "Acme Agency (Stripe)", + billing_entity_code: billing_entity.code, + connected_account_id: "acct_123" + } + } + end + + before do + billing_entity + platform_provider + end + + it "creates an entity-scoped connected Stripe provider on the platform key" do + expect { subject }.to change(PaymentProviders::StripeProvider, :count).by(1) + expect(response).to be_successful + + provider = PaymentProviders::StripeProvider.find_by(code: "stripe_reseller_abc") + expect(provider).to be_present + expect(provider.connected_account_id).to eq("acct_123") + expect(provider.billing_entity_id).to eq(billing_entity.id) + # Reuses the org's platform Stripe key (no secret is sent over the wire). + expect(provider.secret_key).to eq(platform_provider.secret_key) + expect(json[:payment_provider][:code]).to eq("stripe_reseller_abc") + end + + it "produces connected request options that carry the Stripe-Account header" do + subject + provider = PaymentProviders::StripeProvider.find_by(code: "stripe_reseller_abc") + expect(provider.stripe_request_options).to eq( + api_key: platform_provider.secret_key, + stripe_account: "acct_123" + ) + end + + context "when the organization has no platform Stripe provider" do + let(:platform_provider) { nil } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when the billing entity code is unknown" do + let(:params) do + { + payment_provider: { + code: "stripe_reseller_x", + name: "x", + billing_entity_code: "does-not-exist", + connected_account_id: "acct_1" + } + } + end + + it "does not create a provider and fails" do + expect { subject }.not_to change(PaymentProviders::StripeProvider, :count) + expect(response).not_to be_successful + end + end + end +end