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
7 changes: 7 additions & 0 deletions app/graphql/types/payment_providers/stripe_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ 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
# 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
14 changes: 10 additions & 4 deletions app/models/payment_providers/base_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
#
21 changes: 21 additions & 0 deletions app/models/payment_providers/stripe_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion app/services/credit_notes/refunds/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
)
Expand Down
3 changes: 2 additions & 1 deletion app/services/customers/payment_provider_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion app/services/invoices/payments/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}
)
Expand Down Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 10 additions & 4 deletions app/services/payment_provider_customers/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]

Expand Down Expand Up @@ -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
Expand All @@ -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("-")
}
)
Expand All @@ -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,
Expand Down
20 changes: 18 additions & 2 deletions app/services/payment_providers/find_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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",
Expand All @@ -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
8 changes: 8 additions & 0 deletions app/services/payment_providers/stripe/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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}"
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def call
::Stripe::WebhookEndpoint.update(
payment_provider.webhook_id,
webhook_endpoint_shared_params,
{api_key:}
stripe_request_options
)

result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def call

stripe_webhook = ::Stripe::WebhookEndpoint.create(
params,
{api_key:}
stripe_request_options
)

payment_provider.update!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
15 changes: 15 additions & 0 deletions app/services/payment_providers/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ 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 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)
Expand Down
10 changes: 7 additions & 3 deletions app/services/payment_requests/payments/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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"

Expand Down
4 changes: 1 addition & 3 deletions app/services/payments/set_payment_method_data_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading