From 083cf8a2e48fe384958ed738e8f6f5ebbf0a267e Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 28 Jul 2026 17:13:29 -0400 Subject: [PATCH 1/4] feat(payments): REST endpoint to register a reseller's Stripe Connect provider POST /api/v1/payment_providers/stripe_connect (api_key auth) creates an entity-scoped Stripe provider from a reseller's connected account, so the agency's sub-account retail invoices collect on their OWN Stripe. - Reuses the organization's platform Stripe key (no secret over the wire); calls run on the connected account via Stripe-Account (per #18). - Idempotent by provider code. Requires a billing_entity_code + acct_. Consumed by growth-os resellerService.registerConnectedProviderInLago. Needs RSpec in CI (local Ruby 2.6 vs 4.0.2). Part of ThinkfleetAI/growth-os#1243. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../stripe_connect_controller.rb | 44 +++++++++++++++++++ config/routes.rb | 3 ++ 2 files changed, 47 insertions(+) create mode 100644 app/controllers/api/v1/payment_providers/stripe_connect_controller.rb 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 From 903ccb5c5a751ce55b4dc9ed97ca619fa0009151 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 28 Jul 2026 18:57:06 -0400 Subject: [PATCH 2/4] test(payments): request spec for stripe_connect provider endpoint Covers: creating an entity-scoped connected Stripe provider on the org's platform key, the resulting connected stripe_request_options (Stripe-Account header), not-found when the org has no platform Stripe provider, and failure on an unknown billing entity code. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../stripe_connect_controller_spec.rb | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 spec/requests/api/v1/payment_providers/stripe_connect_controller_spec.rb 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..ae0f5645d63c --- /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 + 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 + + subject { post_with_token(organization, "/api/v1/payment_providers/stripe_connect", params) } + + 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 From 2415ef64ae5cf5c445176692b1bcc95ff415cae8 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 28 Jul 2026 19:07:19 -0400 Subject: [PATCH 3/4] fix(payments): make billing_entity migration strong_migrations-safe + lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 20260728000000 (#18): use disable_ddl_transaction! + concurrent index + add_foreign_key validate:false, matching the repo convention (AddBillingEntityToWallets). The prior form failed `rails db:migrate` under strong_migrations (index inside a txn) — this blocked deploys. - spec: declare subject before let (RSpec/LeadingSubject). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...8000000_add_billing_entity_to_payment_providers.rb | 11 +++++------ .../stripe_connect_controller_spec.rb | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) 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..8b28de39716d 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,11 @@ # 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 + add_reference :payment_providers, :billing_entity, type: :uuid, null: true, + index: {algorithm: :concurrently} + add_foreign_key :payment_providers, :billing_entities, validate: false end end 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 index ae0f5645d63c..2f6ed31fe6b1 100644 --- a/spec/requests/api/v1/payment_providers/stripe_connect_controller_spec.rb +++ b/spec/requests/api/v1/payment_providers/stripe_connect_controller_spec.rb @@ -8,6 +8,8 @@ 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: { @@ -19,8 +21,6 @@ } end - subject { post_with_token(organization, "/api/v1/payment_providers/stripe_connect", params) } - before do billing_entity platform_provider From 1e5a1da83a36830e0ac46a6629583757ec0f9e31 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 28 Jul 2026 19:11:42 -0400 Subject: [PATCH 4/4] fix(payments): drop DB-level FK on payment_providers.billing_entity_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema-dump check failed because a hand-maintained FK can't match Rails' deterministic constraint name/order. billing_entity_id is an optional, app-managed reference — keep the column + concurrent index, drop the FK. Aligns the migration and structure.sql with the generated schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...0728000000_add_billing_entity_to_payment_providers.rb | 4 +++- db/structure.sql | 9 --------- 2 files changed, 3 insertions(+), 10 deletions(-) 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 8b28de39716d..33929aa363ff 100644 --- a/db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb +++ b/db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb @@ -8,8 +8,10 @@ class AddBillingEntityToPaymentProviders < ActiveRecord::Migration[8.0] disable_ddl_transaction! def change + # 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} - add_foreign_key :payment_providers, :billing_entities, validate: false 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: - --