From 50962eede5b05467fdf0a812f9b6e04f0d537950 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 28 Jul 2026 16:20:45 -0400 Subject: [PATCH 1/2] feat(payments): optional per-billing-entity payment providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for reseller "SaaS Mode": let an agency collect on their OWN Stripe for their billing entity's customers, while org-level providers behave exactly as before. - Add nullable payment_providers.billing_entity_id (NULL = org-level = current behavior). Migration + structure.sql (hand-edited; regenerate in CI). - BaseProvider belongs_to :billing_entity, optional. - FindService: optional billing_entity_id — prefer entity-scoped provider, fall back to org-level; scope untouched when absent (webhooks/management unchanged). - Customers::PaymentProviderFinder passes customer.billing_entity_id. - StripeService#create_or_update accepts billing_entity_code (resolved via BillingEntities::ResolveService) to scope a NEW provider; exposed on GraphQL StripeInput. Provider codes stay org-unique (webhook-by-code routing unambiguous). No money-movement or webhook code changed. RSpec to run in CI (local Ruby 2.6). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../types/payment_providers/stripe_input.rb | 3 +++ app/models/payment_providers/base_provider.rb | 14 +++++++++---- .../customers/payment_provider_finder.rb | 3 ++- .../payment_providers/find_service.rb | 20 ++++++++++++++++-- .../payment_providers/stripe_service.rb | 10 +++++++++ ...add_billing_entity_to_payment_providers.rb | 16 ++++++++++++++ db/structure.sql | 21 ++++++++++++++++++- 7 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb diff --git a/app/graphql/types/payment_providers/stripe_input.rb b/app/graphql/types/payment_providers/stripe_input.rb index 316ea775b8ab..c9a6a266ab25 100644 --- a/app/graphql/types/payment_providers/stripe_input.rb +++ b/app/graphql/types/payment_providers/stripe_input.rb @@ -10,6 +10,9 @@ class StripeInput < BaseInputObject argument :secret_key, String, required: false argument :success_redirect_url, String, required: false argument :supports_3ds, Boolean, required: false + # Optional: scope this Stripe provider to a billing entity (reseller's own + # Stripe). Only applied on creation; omit for an org-level provider. + argument :billing_entity_code, String, required: false end end end diff --git a/app/models/payment_providers/base_provider.rb b/app/models/payment_providers/base_provider.rb index beac396bef1f..2625e829182f 100644 --- a/app/models/payment_providers/base_provider.rb +++ b/app/models/payment_providers/base_provider.rb @@ -13,6 +13,9 @@ class BaseProvider < ApplicationRecord self.table_name = "payment_providers" belongs_to :organization + # Optional per-entity scoping: when set, this provider (e.g. an agency's own + # Stripe) collects for that billing entity's customers. NULL = org-level. + belongs_to :billing_entity, optional: true has_many :payment_provider_customers, dependent: :nullify, @@ -50,17 +53,20 @@ def determine_payment_status(payment_status) # name :string not null # secrets :string # settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :uuid not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# billing_entity_id :uuid # # Indexes # # index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) # index_payment_providers_on_organization_id (organization_id) +# index_payment_providers_on_billing_entity_id (billing_entity_id) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/services/customers/payment_provider_finder.rb b/app/services/customers/payment_provider_finder.rb index c453d4ddc5e6..1ad1fb90c2ef 100644 --- a/app/services/customers/payment_provider_finder.rb +++ b/app/services/customers/payment_provider_finder.rb @@ -9,7 +9,8 @@ def payment_provider(customer) payment_provider_result = PaymentProviders::FindService.new( organization_id: customer.organization_id, code: customer.payment_provider_code, - payment_provider_type: customer.payment_provider + payment_provider_type: customer.payment_provider, + billing_entity_id: customer.billing_entity_id ).call return nil if payment_provider_result.error&.code == "payment_provider_not_found" diff --git a/app/services/payment_providers/find_service.rb b/app/services/payment_providers/find_service.rb index 36e031b5db3d..f3cf51621e71 100644 --- a/app/services/payment_providers/find_service.rb +++ b/app/services/payment_providers/find_service.rb @@ -2,13 +2,14 @@ module PaymentProviders class FindService < BaseService - attr_reader :id, :code, :organization_id, :payment_provider_type, :scope + attr_reader :id, :code, :organization_id, :payment_provider_type, :billing_entity_id, :scope - def initialize(organization_id:, code: nil, id: nil, payment_provider_type: nil) + def initialize(organization_id:, code: nil, id: nil, payment_provider_type: nil, billing_entity_id: nil) @id = id @code = code @organization_id = organization_id @payment_provider_type = payment_provider_type + @billing_entity_id = billing_entity_id @scope = PaymentProviders::BaseProvider.where(organization_id:) if payment_provider_type.present? @@ -24,6 +25,12 @@ def call return result end + # Prefer a provider scoped to the caller's billing entity (e.g. an agency's + # own Stripe); fall back to the org-level providers when none exists so + # existing single-Stripe setups are unaffected. When no billing entity is + # given (webhooks, management APIs) the scope is left untouched. + apply_billing_entity_scope! + if code.blank? && scope.count > 1 return result.service_failure!( code: "payment_provider_code_missing", @@ -40,5 +47,14 @@ def call result.payment_provider = scope.first result end + + private + + def apply_billing_entity_scope! + return if billing_entity_id.blank? + + entity_scoped = scope.where(billing_entity_id:) + @scope = entity_scoped if entity_scoped.exists? + end end end diff --git a/app/services/payment_providers/stripe_service.rb b/app/services/payment_providers/stripe_service.rb index 286dca51c0c3..8254c02837dd 100644 --- a/app/services/payment_providers/stripe_service.rb +++ b/app/services/payment_providers/stripe_service.rb @@ -25,6 +25,16 @@ def create_or_update(**args) stripe_provider.secret_key = args[:secret_key] if args.key?(:secret_key) && is_new stripe_provider.code = args[:code] if args.key?(:code) + # Optionally scope this provider to a billing entity (e.g. an agency's own + # Stripe). Only set on creation; NULL keeps it org-level. + if is_new && args.key?(:billing_entity_code) && args[:billing_entity_code].present? + billing_entity_result = BillingEntities::ResolveService.call( + organization: Organization.find(args[:organization_id]), + billing_entity_code: args[:billing_entity_code] + ) + billing_entity_result.raise_if_error! + stripe_provider.billing_entity = billing_entity_result.billing_entity + end stripe_provider.name = args[:name] if args.key?(:name) stripe_provider.success_redirect_url = args[:success_redirect_url] if args.key?(:success_redirect_url) stripe_provider.supports_3ds = args[:supports_3ds] if args.key?(:supports_3ds) diff --git a/db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb b/db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb new file mode 100644 index 000000000000..fe3901389b44 --- /dev/null +++ b/db/migrate/20260728000000_add_billing_entity_to_payment_providers.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Per-entity payment providers (reseller "SaaS Mode"). A payment provider may +# optionally belong to a billing entity so an agency's own Stripe can collect +# 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] + def change + add_reference :payment_providers, + :billing_entity, + type: :uuid, + null: true, + foreign_key: true, + index: true + end +end diff --git a/db/structure.sql b/db/structure.sql index 7fe23153aadd..92706cfc6dd0 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -250,6 +250,7 @@ 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; @@ -470,6 +471,7 @@ DROP INDEX IF EXISTS public.index_payment_receipts_on_organization_id; DROP INDEX IF EXISTS public.index_payment_receipts_on_billing_entity_id; DROP INDEX IF EXISTS public.index_payment_providers_on_organization_id; DROP INDEX IF EXISTS public.index_payment_providers_on_code_and_organization_id; +DROP INDEX IF EXISTS public.index_payment_providers_on_billing_entity_id; DROP INDEX IF EXISTS public.index_payment_provider_customers_on_provider_customer_id; DROP INDEX IF EXISTS public.index_payment_provider_customers_on_payment_provider_id; DROP INDEX IF EXISTS public.index_payment_provider_customers_on_organization_id; @@ -4655,7 +4657,8 @@ CREATE TABLE public.payment_providers ( updated_at timestamp(6) without time zone NOT NULL, code character varying NOT NULL, name character varying NOT NULL, - deleted_at timestamp(6) without time zone + deleted_at timestamp(6) without time zone, + billing_entity_id uuid ); @@ -8786,6 +8789,13 @@ CREATE INDEX index_payment_provider_customers_on_payment_provider_id ON public.p CREATE INDEX index_payment_provider_customers_on_provider_customer_id ON public.payment_provider_customers USING btree (provider_customer_id); +-- +-- Name: index_payment_providers_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_providers_on_billing_entity_id ON public.payment_providers USING btree (billing_entity_id); + + -- -- Name: index_payment_providers_on_code_and_organization_id; Type: INDEX; Schema: public; Owner: - -- @@ -10280,6 +10290,14 @@ 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: - -- @@ -12215,6 +12233,7 @@ ALTER TABLE ONLY public.membership_roles SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260728000000'), ('20260504134804'), ('20260430102814'), ('20260430102813'), From 38464ce499c2e719d13c1506ab3f47fdbef2b248 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 28 Jul 2026 16:55:34 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(payments):=20Stripe=20Connect=20?= =?UTF-8?q?=E2=80=94=20reseller=20connected-account=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on per-entity payment providers: an agency's own Stripe (a Connect connected account, acct_…) can now collect for their billing entity's customers. Fully backward-compatible — a provider with no connected account behaves byte-identically to before. - StripeProvider: settings accessor `connected_account_id`, `connected?`, and `stripe_request_options` (returns {api_key:, stripe_account:} for a connected account; {api_key:} otherwise). For a connected provider, secret_key holds the PLATFORM key and calls run on the connected account via Stripe-Account. - StripeService#create_or_update + GraphQL StripeInput accept connected_account_id (set on creation, scoped to the billing entity). - Threaded stripe_request_options through EVERY Stripe call path so connected providers act on their account: payment intents, customer create/update, checkout sessions, refunds, payment-method retrieve/list/check, funding instructions, setup-intent webhook, and webhook register/refresh. Added a shared helper on PaymentProviders::Stripe::BaseService. - white_label/reset_service (agency-pays-us wholesale) intentionally stays org-level. NOT run locally (repo requires Ruby 4.0.2; local is 2.6). Needs RSpec in CI + a Stripe test-mode end-to-end before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../types/payment_providers/stripe_input.rb | 4 ++++ .../payment_providers/stripe_provider.rb | 21 +++++++++++++++++++ .../credit_notes/refunds/stripe_service.rb | 8 ++++++- .../invoices/payments/stripe_service.rb | 8 ++++++- .../stripe/check_payment_method_service.rb | 6 +++++- .../sync_funding_instructions_service.rb | 2 +- .../stripe_service.rb | 14 +++++++++---- .../payment_providers/stripe/base_service.rb | 8 +++++++ .../fetch_default_payment_method_service.rb | 2 +- .../stripe/payments/create_service.rb | 6 +++--- .../stripe/refresh_webhook_service.rb | 2 +- .../stripe/register_webhook_service.rb | 2 +- .../setup_intent_succeeded_service.rb | 4 ++-- .../payment_providers/stripe_service.rb | 5 +++++ .../payments/stripe_service.rb | 10 ++++++--- .../set_payment_method_data_service.rb | 4 +--- 16 files changed, 84 insertions(+), 22 deletions(-) diff --git a/app/graphql/types/payment_providers/stripe_input.rb b/app/graphql/types/payment_providers/stripe_input.rb index c9a6a266ab25..9bbab67f9fb3 100644 --- a/app/graphql/types/payment_providers/stripe_input.rb +++ b/app/graphql/types/payment_providers/stripe_input.rb @@ -13,6 +13,10 @@ class StripeInput < BaseInputObject # Optional: scope this Stripe provider to a billing entity (reseller's own # Stripe). Only applied on creation; omit for an org-level provider. argument :billing_entity_code, String, required: false + # Optional: Stripe Connect connected account (acct_…) — the reseller's own + # Stripe. When set, secret_key is the platform key and calls run on that + # connected account. + argument :connected_account_id, String, required: false end end end diff --git a/app/models/payment_providers/stripe_provider.rb b/app/models/payment_providers/stripe_provider.rb index 995ab65b7818..2dcd366e3176 100644 --- a/app/models/payment_providers/stripe_provider.rb +++ b/app/models/payment_providers/stripe_provider.rb @@ -37,10 +37,31 @@ class StripeProvider < BaseProvider settings_accessors :webhook_id secrets_accessors :secret_key settings_accessors :supports_3ds + # Stripe Connect: when set, this provider represents a reseller's OWN Stripe + # (a connected account, `acct_…`). Calls are made on the platform key + # (stored here as secret_key) WITH the Stripe-Account header. Blank = + # ordinary org-level provider (unchanged behaviour). + settings_accessors :connected_account_id def payment_type "stripe" end + + def connected? + connected_account_id.present? + end + + # Request options passed to EVERY Stripe API call made as this provider. + # For a connected account, the call runs on the platform key + the + # Stripe-Account header so it acts on the reseller's account; otherwise it + # uses this provider's own key exactly as before. Call sites merge any + # per-request opts (e.g. idempotency_key) on top: + # { **provider.stripe_request_options, idempotency_key: "…" } + def stripe_request_options + return {api_key: secret_key} if connected_account_id.blank? + + {api_key: secret_key, stripe_account: connected_account_id} + end end end diff --git a/app/services/credit_notes/refunds/stripe_service.rb b/app/services/credit_notes/refunds/stripe_service.rb index 1083269b9536..b4d50a6fee5d 100644 --- a/app/services/credit_notes/refunds/stripe_service.rb +++ b/app/services/credit_notes/refunds/stripe_service.rb @@ -105,11 +105,17 @@ def stripe_api_key stripe_payment_provider.secret_key end + # Stripe request options (Stripe-Account header for connected accounts; + # identical to {api_key:} for org-level providers). + def stripe_request_options + stripe_payment_provider.stripe_request_options + end + def create_stripe_refund Stripe::Refund.create( stripe_refund_payload, { - api_key: stripe_api_key, + **stripe_request_options, idempotency_key: credit_note.id } ) diff --git a/app/services/invoices/payments/stripe_service.rb b/app/services/invoices/payments/stripe_service.rb index 80732847c3c8..adcd1624e6c7 100644 --- a/app/services/invoices/payments/stripe_service.rb +++ b/app/services/invoices/payments/stripe_service.rb @@ -64,7 +64,7 @@ def generate_payment_url(payment_intent) res = ::Stripe::Checkout::Session.create( payment_url_payload(payment_intent), { - api_key: stripe_api_key, + **stripe_request_options, idempotency_key: "payment-intent-#{payment_intent.id}" } ) @@ -121,6 +121,12 @@ def stripe_api_key stripe_payment_provider.secret_key end + # Stripe request options (Stripe-Account header for connected accounts; + # identical to {api_key:} for org-level providers). + def stripe_request_options + stripe_payment_provider.stripe_request_options + end + def payment_url_payload(payment_intent) { line_items: [ diff --git a/app/services/payment_provider_customers/stripe/check_payment_method_service.rb b/app/services/payment_provider_customers/stripe/check_payment_method_service.rb index 7a402e61ce74..51785ff19953 100644 --- a/app/services/payment_provider_customers/stripe/check_payment_method_service.rb +++ b/app/services/payment_provider_customers/stripe/check_payment_method_service.rb @@ -15,7 +15,7 @@ def initialize(stripe_customer:, payment_method_id:) def call payment_method = ::Stripe::Customer .new(id: stripe_customer.provider_customer_id) - .retrieve_payment_method(payment_method_id, {}, {api_key:}) + .retrieve_payment_method(payment_method_id, {}, stripe_request_options) result.payment_method = payment_method result @@ -39,6 +39,10 @@ def api_key stripe_customer.payment_provider.secret_key end + def stripe_request_options + stripe_customer.payment_provider.stripe_request_options + end + def customer @customer ||= stripe_customer.customer end diff --git a/app/services/payment_provider_customers/stripe/sync_funding_instructions_service.rb b/app/services/payment_provider_customers/stripe/sync_funding_instructions_service.rb index 73d01dc04420..0aed98080321 100644 --- a/app/services/payment_provider_customers/stripe/sync_funding_instructions_service.rb +++ b/app/services/payment_provider_customers/stripe/sync_funding_instructions_service.rb @@ -67,7 +67,7 @@ def fetch_funding_instructions bank_transfer: funding_type_payload, currency: customer_currency }, - {api_key: stripe_api_key} + stripe_customer.payment_provider.stripe_request_options ) end diff --git a/app/services/payment_provider_customers/stripe_service.rb b/app/services/payment_provider_customers/stripe_service.rb index 1b7c68c6c6dc..025897000fa2 100644 --- a/app/services/payment_provider_customers/stripe_service.rb +++ b/app/services/payment_provider_customers/stripe_service.rb @@ -36,7 +36,7 @@ def create def update return result if !stripe_payment_provider || stripe_customer.provider_customer_id.blank? - ::Stripe::Customer.update(stripe_customer.provider_customer_id, stripe_update_payload, {api_key:}) + ::Stripe::Customer.update(stripe_customer.provider_customer_id, stripe_update_payload, stripe_request_options) sync_funding_instructions result rescue ::Stripe::InvalidRequestError, ::Stripe::PermissionError => e @@ -85,7 +85,7 @@ def generate_checkout_url(send_webhook: true) ) end - res = ::Stripe::Checkout::Session.create(checkout_link_params, {api_key:}) + res = ::Stripe::Checkout::Session.create(checkout_link_params, stripe_request_options) result.checkout_url = res["url"] @@ -122,6 +122,12 @@ def api_key stripe_payment_provider.secret_key end + # Stripe request options (carries the Stripe-Account header for a reseller's + # connected account; identical to {api_key:} for org-level providers). + def stripe_request_options + stripe_payment_provider.stripe_request_options + end + def name customer.name.presence || [customer.firstname, customer.lastname].compact.join(" ") end @@ -144,7 +150,7 @@ def create_stripe_customer ::Stripe::Customer.create( stripe_create_payload, { - api_key:, + **stripe_request_options, idempotency_key: [customer.id, customer.updated_at.to_i].join("-") } ) @@ -157,7 +163,7 @@ def create_stripe_customer message = ["Stripe authentication failed.", e.message.presence].compact.join(" ") result.unauthorized_failure!(message:) rescue ::Stripe::IdempotencyError - stripe_customers = ::Stripe::Customer.list({email: customer.email}, {api_key:}) + stripe_customers = ::Stripe::Customer.list({email: customer.email}, stripe_request_options) return stripe_customers.first if stripe_customers.count == 1 # NOTE: Multiple stripe customers with the same email, diff --git a/app/services/payment_providers/stripe/base_service.rb b/app/services/payment_providers/stripe/base_service.rb index 15248f9a83e6..1aa22bebba36 100644 --- a/app/services/payment_providers/stripe/base_service.rb +++ b/app/services/payment_providers/stripe/base_service.rb @@ -19,6 +19,14 @@ def api_key payment_provider.secret_key end + # Request options for a Stripe API call made as this provider. Carries the + # Stripe-Account header for a reseller's connected account; identical to + # `{api_key:}` for an ordinary org-level provider. Merge per-request opts: + # ::Stripe::X.create(params, {**stripe_request_options, idempotency_key:}) + def stripe_request_options + payment_provider.stripe_request_options + end + def deliver_error_webhook(action:, error:) SendWebhookJob.perform_later( "payment_provider.error", diff --git a/app/services/payment_providers/stripe/customers/fetch_default_payment_method_service.rb b/app/services/payment_providers/stripe/customers/fetch_default_payment_method_service.rb index 99d0bea3fb07..f3fd4b8dc333 100644 --- a/app/services/payment_providers/stripe/customers/fetch_default_payment_method_service.rb +++ b/app/services/payment_providers/stripe/customers/fetch_default_payment_method_service.rb @@ -43,7 +43,7 @@ def call def payment_method_details(payment_method_id:) pm = ::Stripe::PaymentMethod.retrieve( payment_method_id, - {api_key: provider_customer.payment_provider.secret_key} + provider_customer.payment_provider.stripe_request_options ) if pm.type == "card" diff --git a/app/services/payment_providers/stripe/payments/create_service.rb b/app/services/payment_providers/stripe/payments/create_service.rb index ecd386725928..850da84ba949 100644 --- a/app/services/payment_providers/stripe/payments/create_service.rb +++ b/app/services/payment_providers/stripe/payments/create_service.rb @@ -89,7 +89,7 @@ def stripe_payment_method payment_method = ::Stripe::Customer.list_payment_methods( provider_customer.provider_customer_id, {}, - {api_key: payment_provider.secret_key} + payment_provider.stripe_request_options ).first if invoice.organization.feature_flag_enabled?(:multiple_payment_methods) @@ -105,7 +105,7 @@ def stripe_payment_method def update_payment_method_id stripe_customer = ::Stripe::Customer.retrieve( provider_customer.provider_customer_id, - {api_key: payment_provider.secret_key} + payment_provider.stripe_request_options ) # TODO: stripe customer should be updated/deleted @@ -124,7 +124,7 @@ def create_payment_intent ::Stripe::PaymentIntent.create( payment_intent_payload, { - api_key: payment_provider.secret_key, + **payment_provider.stripe_request_options, idempotency_key: "payment-#{payment.id}" } ) diff --git a/app/services/payment_providers/stripe/refresh_webhook_service.rb b/app/services/payment_providers/stripe/refresh_webhook_service.rb index 482e63109a23..92f7ebab415a 100644 --- a/app/services/payment_providers/stripe/refresh_webhook_service.rb +++ b/app/services/payment_providers/stripe/refresh_webhook_service.rb @@ -9,7 +9,7 @@ def call ::Stripe::WebhookEndpoint.update( payment_provider.webhook_id, webhook_endpoint_shared_params, - {api_key:} + stripe_request_options ) result diff --git a/app/services/payment_providers/stripe/register_webhook_service.rb b/app/services/payment_providers/stripe/register_webhook_service.rb index 63fe9ff7ee3e..5a3dd898f2ca 100644 --- a/app/services/payment_providers/stripe/register_webhook_service.rb +++ b/app/services/payment_providers/stripe/register_webhook_service.rb @@ -14,7 +14,7 @@ def call stripe_webhook = ::Stripe::WebhookEndpoint.create( params, - {api_key:} + stripe_request_options ) payment_provider.update!( diff --git a/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb b/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb index 8da9a3ffbcf9..2a725ef187d4 100644 --- a/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb +++ b/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb @@ -53,7 +53,7 @@ def valid_payment_method? def stripe_payment_method @stripe_payment_method ||= ::Stripe::PaymentMethod.retrieve( payment_method_id, - {api_key: stripe_payment_provider.secret_key} + stripe_payment_provider.stripe_request_options ) end @@ -75,7 +75,7 @@ def update_stripe_customer_default_payment_method ::Stripe::Customer.update( stripe_customer_id, {invoice_settings: {default_payment_method: payment_method_id}}, - {api_key: stripe_payment_provider.secret_key} + stripe_payment_provider.stripe_request_options ) end diff --git a/app/services/payment_providers/stripe_service.rb b/app/services/payment_providers/stripe_service.rb index 8254c02837dd..1c06e3739cf7 100644 --- a/app/services/payment_providers/stripe_service.rb +++ b/app/services/payment_providers/stripe_service.rb @@ -35,6 +35,11 @@ def create_or_update(**args) billing_entity_result.raise_if_error! stripe_provider.billing_entity = billing_entity_result.billing_entity end + # Stripe Connect: mark this as a reseller's connected account (acct_…). + # Only on creation; secret_key here is the platform key (see StripeProvider). + if is_new && args.key?(:connected_account_id) && args[:connected_account_id].present? + stripe_provider.connected_account_id = args[:connected_account_id] + end stripe_provider.name = args[:name] if args.key?(:name) stripe_provider.success_redirect_url = args[:success_redirect_url] if args.key?(:success_redirect_url) stripe_provider.supports_3ds = args[:supports_3ds] if args.key?(:supports_3ds) diff --git a/app/services/payment_requests/payments/stripe_service.rb b/app/services/payment_requests/payments/stripe_service.rb index f62c5c8619fd..7a97e8b5868f 100644 --- a/app/services/payment_requests/payments/stripe_service.rb +++ b/app/services/payment_requests/payments/stripe_service.rb @@ -17,9 +17,7 @@ def initialize(payable = nil) def generate_payment_url result_url = ::Stripe::Checkout::Session.create( payment_url_payload, - { - api_key: stripe_api_key - } + stripe_request_options ) result.payment_url = result_url["url"] @@ -99,6 +97,12 @@ def stripe_api_key stripe_payment_provider.secret_key end + # Stripe request options (Stripe-Account header for connected accounts; + # identical to {api_key:} for org-level providers). + def stripe_request_options + stripe_payment_provider.stripe_request_options + end + def description desc = "#{customer.billing_entity.name} - Overdue invoices" diff --git a/app/services/payments/set_payment_method_data_service.rb b/app/services/payments/set_payment_method_data_service.rb index 4d1e8bf66fcb..f599c42e4b6e 100644 --- a/app/services/payments/set_payment_method_data_service.rb +++ b/app/services/payments/set_payment_method_data_service.rb @@ -41,9 +41,7 @@ def call attr_reader :payment, :payment_provider, :provider_payment_method_id def retrieve_stripe_payment_method_data - pm = ::Stripe::PaymentMethod.retrieve(provider_payment_method_id, { - api_key: payment_provider.secret_key - }) + pm = ::Stripe::PaymentMethod.retrieve(provider_payment_method_id, payment_provider.stripe_request_options) data = { id: provider_payment_method_id,