Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 0 additions & 9 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: -
--
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading