From 03a223d6f9906e69221c053a311838cbf61700f8 Mon Sep 17 00:00:00 2001 From: Mario Diniz Date: Wed, 9 Sep 2026 09:57:01 -0300 Subject: [PATCH 1/2] [ING-466] feat(wallets): accept connections param ## Context Billing objects need to route themselves to one of a customer's connections. The table, the resolution cascade and the connection codes already shipped; nothing wrote the override rows yet. This adds the first write path, behind the multi_connection flag. ## Description The wallet endpoints and each nested recurring transaction rule accept a connections object keyed by category (payment, tax, accounting, crm). An entry either names a connection code, which pins it, or a behavior: skip never falls back, inherit drops the choice. An omitted category changes nothing. Inherit is never persisted, since resolution reads a missing row as inheritance. An unknown code is a validation error, and connections sent while the flag is off are refused rather than ignored. Validation and persistence are polymorphic in the owner, so the subscription and invoice surfaces can reuse them. --- app/controllers/concerns/wallet_actions.rb | 24 ++ .../attach_to_resource_service.rb | 105 ++++++++ .../validate_service.rb | 70 ++++++ app/services/wallets/create_service.rb | 15 ++ .../create_service.rb | 14 ++ .../update_service.rb | 29 +++ app/services/wallets/update_service.rb | 23 ++ app/services/wallets/validate_service.rb | 10 + .../attach_to_resource_service_spec.rb | 227 ++++++++++++++++++ .../validate_service_spec.rb | 125 ++++++++++ .../update_service_spec.rb | 135 +++++++++++ .../support/shared_examples/wallet_actions.rb | 175 ++++++++++++++ 12 files changed, 952 insertions(+) create mode 100644 app/services/billing_object_connections/attach_to_resource_service.rb create mode 100644 app/services/billing_object_connections/validate_service.rb create mode 100644 spec/services/billing_object_connections/attach_to_resource_service_spec.rb create mode 100644 spec/services/billing_object_connections/validate_service_spec.rb diff --git a/app/controllers/concerns/wallet_actions.rb b/app/controllers/concerns/wallet_actions.rb index a92f76b1db8c..b502e55ba228 100644 --- a/app/controllers/concerns/wallet_actions.rb +++ b/app/controllers/concerns/wallet_actions.rb @@ -140,6 +140,12 @@ def input_params payment_method: [ :payment_method_type, :payment_method_id + ], + connections: [ + payment: [:behavior, :code], + tax: [:behavior, :code], + accounting: [:behavior, :code], + crm: [:behavior, :code] ] ], applies_to: [ @@ -153,6 +159,12 @@ def input_params payment_method: [ :payment_method_type, :payment_method_id + ], + connections: [ + payment: [:behavior, :code], + tax: [:behavior, :code], + accounting: [:behavior, :code], + crm: [:behavior, :code] ] ) end @@ -196,6 +208,12 @@ def update_params payment_method: [ :payment_method_type, :payment_method_id + ], + connections: [ + payment: [:behavior, :code], + tax: [:behavior, :code], + accounting: [:behavior, :code], + crm: [:behavior, :code] ] ], applies_to: [ @@ -209,6 +227,12 @@ def update_params payment_method: [ :payment_method_type, :payment_method_id + ], + connections: [ + payment: [:behavior, :code], + tax: [:behavior, :code], + accounting: [:behavior, :code], + crm: [:behavior, :code] ] ) end diff --git a/app/services/billing_object_connections/attach_to_resource_service.rb b/app/services/billing_object_connections/attach_to_resource_service.rb new file mode 100644 index 000000000000..b7b5604e0dca --- /dev/null +++ b/app/services/billing_object_connections/attach_to_resource_service.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module BillingObjectConnections + class AttachToResourceService < BaseService + Result = BaseResult[:billing_object_connections] + + INHERIT_BEHAVIOR = "inherit" + + def initialize(resource:, params:) + @resource = resource + @params = params + super + end + + def call + return result unless params.key?(:connections) + return result if connections.blank? + + ActiveRecord::Base.transaction do + connections.each do |category, choice| + next if choice.blank? + + apply_choice(category.to_s, choice) + end + end + + result.billing_object_connections = resource.billing_object_connections.reload + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + # raise_if_error! unwinds the transaction on an unresolvable code; the failure is returned + # rather than propagated so `.call` keeps the BaseService contract. + e.result + end + + private + + attr_reader :resource, :params + + def connections + params[:connections] + end + + def customer + resource.customer + end + + def apply_choice(category, choice) + behavior = choice[:behavior].to_s + + if behavior == INHERIT_BEHAVIOR + destroy_override(category) + elsif behavior == BillingObjectConnection::BEHAVIORS[:skip] + upsert_override(category, behavior: :skip, connection: nil) + else + connection = resolve_connection(category, choice[:code]) + + if connection.nil? + result.single_validation_failure!(field: :connections, error_code: "connection_not_found") + result.raise_if_error! + end + + upsert_override(category, behavior: :specific, connection:) + end + end + + def destroy_override(category) + resource.billing_object_connections.find_by(category:)&.destroy! + end + + def upsert_override(category, behavior:, connection:) + override = resource.billing_object_connections.find_or_initialize_by(category:) + + override.organization_id = resource.organization_id + override.behavior = behavior + override.payment_provider_customer = nil + override.integration_customer = nil + + if payment?(category) + override.payment_provider_customer = connection + else + override.integration_customer = connection + end + + override.save! + end + + # Both foreign keys are optional on the model and the category/column pairing lives only in + # ConnectionResolvable, so the mapping is mirrored here. + def resolve_connection(category, code) + return nil if code.blank? || customer.nil? + + if payment?(category) + customer.payment_connection(code) + else + customer.integration_customers.find_by(category:, code:) + end + end + + def payment?(category) + category == BillingObjectConnection::CATEGORIES[:payment] + end + end +end diff --git a/app/services/billing_object_connections/validate_service.rb b/app/services/billing_object_connections/validate_service.rb new file mode 100644 index 000000000000..1048bb50ee5b --- /dev/null +++ b/app/services/billing_object_connections/validate_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module BillingObjectConnections + class ValidateService < BaseValidator + CATEGORIES = BillingObjectConnection::CATEGORIES.values.freeze + # "specific" is never sent: it is implied by supplying a code. "inherit" is params-only + # and means "destroy the override row", since row absence is what ConnectionResolvable + # reads as inheritance. + BEHAVIORS = %w[inherit skip].freeze + + # Pure validator: it accumulates error codes and leaves surfacing them to the caller, because + # the wallet create path merges them into an accumulating validator while the update and + # recurring-rule paths fail the result directly. + def valid? + validate_connections if connections.present? + + !errors? + end + + def error_codes + errors[:connections] || [] + end + + private + + def connections + args[:connections] + end + + def validate_connections + unless connections.is_a?(Hash) + add_error(field: :connections, error_code: "invalid_connections") + return + end + + connections.each do |category, choice| + validate_category(category) + validate_choice(choice) + end + end + + def validate_category(category) + return true if CATEGORIES.include?(category.to_s) + + add_error(field: :connections, error_code: "invalid_connection_category") + end + + def validate_choice(choice) + unless choice.is_a?(Hash) + add_error(field: :connections, error_code: "invalid_connection_choice") + return + end + + code = choice[:code] + behavior = choice[:behavior] + + if code.present? && behavior.present? + return add_error(field: :connections, error_code: "invalid_connection_choice") + end + + if code.blank? && behavior.blank? + return add_error(field: :connections, error_code: "invalid_connection_choice") + end + + return true if behavior.blank? || BEHAVIORS.include?(behavior.to_s) + + add_error(field: :connections, error_code: "invalid_connection_behavior") + end + end +end diff --git a/app/services/wallets/create_service.rb b/app/services/wallets/create_service.rb index 39e2054b0253..1bbefd45dc10 100644 --- a/app/services/wallets/create_service.rb +++ b/app/services/wallets/create_service.rb @@ -20,6 +20,7 @@ def call result.payment_method = payment_method return result unless valid? + return result.forbidden_failure! if connections_requested? && organization_flag_disabled?(:multi_connection) code = params[:code] @@ -88,6 +89,10 @@ def call InvoiceCustomSections::AttachToResourceService.call(resource: wallet, params:) end + if connections_requested? + BillingObjectConnections::AttachToResourceService.call!(resource: wallet, params:) + end + billable_metrics.each do |bm| WalletTarget.create!(wallet:, billable_metric: bm, organization_id:) end @@ -163,6 +168,16 @@ def organization_flag_enabled?(flag) customer.organization.feature_flag_enabled?(flag) end + def organization_flag_disabled?(flag) + !organization_flag_enabled?(flag) + end + + def connections_requested? + return true if params[:connections].present? + + Array(params[:recurring_transaction_rules]).any? { |rule| rule[:connections].present? } + end + def valid? Wallets::ValidateService.new(result, **params).valid? end diff --git a/app/services/wallets/recurring_transaction_rules/create_service.rb b/app/services/wallets/recurring_transaction_rules/create_service.rb index 2ea5353e6e29..2d45b31b5e03 100644 --- a/app/services/wallets/recurring_transaction_rules/create_service.rb +++ b/app/services/wallets/recurring_transaction_rules/create_service.rb @@ -15,6 +15,7 @@ def initialize(wallet:, wallet_params:) def call return unless License.premium? return result unless valid_payment_method? + return result unless valid_connections? if method == "fixed" && rule_params[:paid_credits].nil? && rule_params[:granted_credits].nil? paid_credits = wallet_params[:paid_credits] @@ -67,6 +68,10 @@ def call InvoiceCustomSections::AttachToResourceService.call(resource: rule, params: rule_params) end + if rule_params[:connections].present? + BillingObjectConnections::AttachToResourceService.call!(resource: rule, params: rule_params) + end + result.recurring_transaction_rule = rule result rescue ActiveRecord::RecordInvalid => e @@ -109,6 +114,15 @@ def valid_payment_method? PaymentMethods::ValidateService.new(result, **rule_params).valid? end + def valid_connections? + validator = BillingObjectConnections::ValidateService.new(result, **rule_params) + return true if validator.valid? + + result.single_validation_failure!(field: :connections, error_code: validator.error_codes.first) + + false + end + def payment_method return @payment_method if defined? @payment_method return nil if rule_params[:payment_method].blank? || rule_params[:payment_method][:payment_method_id].blank? diff --git a/app/services/wallets/recurring_transaction_rules/update_service.rb b/app/services/wallets/recurring_transaction_rules/update_service.rb index 67305fc2911c..75a084f37861 100644 --- a/app/services/wallets/recurring_transaction_rules/update_service.rb +++ b/app/services/wallets/recurring_transaction_rules/update_service.rb @@ -14,6 +14,7 @@ def initialize(wallet:, params:) def call return result unless valid_payment_methods? + return result unless valid_connections? created_recurring_rules_ids = [] @@ -33,6 +34,8 @@ def call rule_attributes.delete(:payment_method) end + connections = rule_attributes.delete(:connections) + recurring_rule = wallet.recurring_transaction_rules.active.find_by(id: lago_id) normalize_grants_target_top_up!(rule_attributes, recurring_rule) @@ -52,6 +55,8 @@ def call end recurring_rule.update!(rule_attributes) + + attach_connections(recurring_rule, connections) else unless rule_attributes.key?(:invoice_requires_successful_payment) rule_attributes[:invoice_requires_successful_payment] = wallet.invoice_requires_successful_payment @@ -68,6 +73,8 @@ def call ) end + attach_connections(created_recurring_rule, connections) + created_recurring_rules_ids.push(created_recurring_rule.id) end end @@ -130,6 +137,28 @@ def valid_payment_methods? true end + def attach_connections(recurring_rule, connections) + return if connections.blank? + + BillingObjectConnections::AttachToResourceService.call!( + resource: recurring_rule, + params: {connections:} + ) + end + + def valid_connections? + hash_recurring_rules.each do |payload_rule| + validator = BillingObjectConnections::ValidateService.new(result, **payload_rule) + next if validator.valid? + + result.single_validation_failure!(field: :connections, error_code: validator.error_codes.first) + + return false + end + + true + end + def payment_method(rule_params) return nil if rule_params[:payment_method].blank? || rule_params[:payment_method][:payment_method_id].blank? diff --git a/app/services/wallets/update_service.rb b/app/services/wallets/update_service.rb index 5d888a3747cf..c6cbfa8a4136 100644 --- a/app/services/wallets/update_service.rb +++ b/app/services/wallets/update_service.rb @@ -24,6 +24,8 @@ def call return result unless valid_recurring_transaction_rules? return result unless valid_limitations? return result unless valid_payment_method? + return result unless valid_connections? + return result.forbidden_failure! if connections_requested? && organization_flag_disabled?(:multi_connection) if billing_entity_param_sent? if billing_entity_value_provided? && billing_entity.nil? @@ -78,6 +80,8 @@ def call end InvoiceCustomSections::AttachToResourceService.call!(resource: wallet, params:) + + BillingObjectConnections::AttachToResourceService.call!(resource: wallet, params:) if connections_requested? SendWebhookJob.perform_after_commit("wallet.updated", wallet) end @@ -134,6 +138,15 @@ def valid_payment_method? PaymentMethods::ValidateService.new(result, **params).valid? end + def valid_connections? + validator = BillingObjectConnections::ValidateService.new(result, **params) + return true if validator.valid? + + result.validation_failure!(errors: {connections: validator.error_codes}) + + false + end + def process_billable_metrics # In case of adding new type of limitation in wallet_targets, query from below should use compact to avoid nil values in the array existing_wallet_billable_metric_ids = wallet.wallet_targets.pluck(:billable_metric_id) @@ -203,6 +216,16 @@ def organization_flag_enabled?(flag) wallet.customer.organization.feature_flag_enabled?(flag) end + def organization_flag_disabled?(flag) + !organization_flag_enabled?(flag) + end + + def connections_requested? + return true if params[:connections].present? + + Array(params[:recurring_transaction_rules]).any? { |rule| rule[:connections].present? } + end + def billing_entity_param_sent? params.key?(:billing_entity_id) || params.key?(:billing_entity_code) end diff --git a/app/services/wallets/validate_service.rb b/app/services/wallets/validate_service.rb index da9db35fcd1b..66b3ca964689 100644 --- a/app/services/wallets/validate_service.rb +++ b/app/services/wallets/validate_service.rb @@ -15,6 +15,7 @@ def valid? valid_limitations? if args[:applies_to] valid_wallet_limit? valid_payment_method? if args[:payment_method] + valid_connections? if args[:connections].present? if errors? result.validation_failure!(errors:) @@ -117,5 +118,14 @@ def valid_payment_method? add_error(field: :payment_method, error_code: "invalid_payment_method") end + + def valid_connections? + validator = BillingObjectConnections::ValidateService.new(result, **args) + return true if validator.valid? + + validator.error_codes.each { |error_code| add_error(field: :connections, error_code:) } + + false + end end end diff --git a/spec/services/billing_object_connections/attach_to_resource_service_spec.rb b/spec/services/billing_object_connections/attach_to_resource_service_spec.rb new file mode 100644 index 000000000000..929bd35ba17e --- /dev/null +++ b/spec/services/billing_object_connections/attach_to_resource_service_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingObjectConnections::AttachToResourceService do + subject(:result) { described_class.call(resource:, params:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:resource) { create(:wallet, customer:, organization:) } + + let(:stripe_connection) { create(:stripe_customer, customer:, code: "stripe_us") } + let(:netsuite_connection) { create(:netsuite_customer, customer:, code: "netsuite_main") } + + describe "#call" do + context "when the connections key is absent" do + let(:params) { {name: "whatever"} } + + it "does not touch any row" do + expect { result }.not_to change(BillingObjectConnection, :count) + expect(result).to be_success + end + end + + context "when connections is empty" do + let(:params) { {connections: {}} } + + it "does not touch any row" do + expect { result }.not_to change(BillingObjectConnection, :count) + expect(result).to be_success + end + end + + context "with a specific payment connection" do + let(:params) { {connections: {payment: {code: "stripe_us"}}} } + + before { stripe_connection } + + it "pins the payment provider customer" do + expect { result }.to change(BillingObjectConnection, :count).by(1) + + override = resource.billing_object_connections.sole + expect(override).to have_attributes( + category: "payment", + behavior: "specific", + organization_id: organization.id, + payment_provider_customer_id: stripe_connection.id, + integration_customer_id: nil + ) + end + end + + context "with a specific accounting connection" do + let(:params) { {connections: {accounting: {code: "netsuite_main"}}} } + + before { netsuite_connection } + + it "pins the integration customer" do + result + + override = resource.billing_object_connections.sole + expect(override).to have_attributes( + category: "accounting", + behavior: "specific", + integration_customer_id: netsuite_connection.id, + payment_provider_customer_id: nil + ) + end + end + + context "with skip" do + let(:params) { {connections: {tax: {behavior: "skip"}}} } + + it "stores a skip row with no connection attached" do + result + + override = resource.billing_object_connections.sole + expect(override).to have_attributes( + category: "tax", + behavior: "skip", + payment_provider_customer_id: nil, + integration_customer_id: nil + ) + end + end + + context "with inherit" do + let(:params) { {connections: {tax: {behavior: "inherit"}}} } + + context "when an override exists" do + before { create(:billing_object_connection, owner: resource, organization:, category: "tax", behavior: "skip") } + + it "destroys the override so resolution falls back to the customer" do + expect { result }.to change(BillingObjectConnection, :count).by(-1) + expect(resource.billing_object_connections.reload).to be_empty + end + end + + context "when no override exists" do + it "is a no-op" do + expect { result }.not_to change(BillingObjectConnection, :count) + expect(result).to be_success + end + end + end + + context "when a category is omitted" do + let(:params) { {connections: {payment: {behavior: "skip"}}} } + + before { create(:billing_object_connection, owner: resource, organization:, category: "tax", behavior: "skip") } + + it "leaves the existing row for that category untouched" do + expect { result }.to change(BillingObjectConnection, :count).by(1) + expect(resource.billing_object_connections.reload.pluck(:category)).to match_array(%w[payment tax]) + end + end + + context "when the same category is written twice" do + let(:params) { {connections: {payment: {code: "stripe_us"}}} } + + before do + stripe_connection + create(:billing_object_connection, owner: resource, organization:, category: "payment", behavior: "skip") + end + + it "updates the existing row rather than duplicating it" do + expect { result }.not_to change(BillingObjectConnection, :count) + + expect(resource.billing_object_connections.sole).to have_attributes( + behavior: "specific", + payment_provider_customer_id: stripe_connection.id + ) + end + end + + context "when the code does not resolve" do + let(:params) { {connections: {payment: {code: "does_not_exist"}}} } + + it "fails with connection_not_found" do + expect(result).not_to be_success + expect(result.error.messages[:connections]).to include("connection_not_found") + end + + it "does not persist anything" do + expect { result }.not_to change(BillingObjectConnection, :count) + end + end + + context "when the code belongs to another customer" do + let(:other_customer) { create(:customer, organization:) } + let(:params) { {connections: {payment: {code: "stripe_other"}}} } + + before { create(:stripe_customer, customer: other_customer, code: "stripe_other") } + + it "fails with connection_not_found" do + expect(result).not_to be_success + expect(result.error.messages[:connections]).to include("connection_not_found") + end + end + + context "when the code exists but in another category" do + let(:params) { {connections: {crm: {code: "netsuite_main"}}} } + + before { netsuite_connection } + + it "fails with connection_not_found" do + expect(result).not_to be_success + expect(result.error.messages[:connections]).to include("connection_not_found") + end + end + + context "when one category resolves and a later one does not" do + let(:params) do + {connections: {payment: {code: "stripe_us"}, accounting: {code: "nope"}}} + end + + before { stripe_connection } + + it "rolls back the rows written before the failure" do + expect { result }.not_to change(BillingObjectConnection, :count) + expect(result).not_to be_success + end + end + + context "when the resource is a recurring transaction rule" do + let(:wallet) { create(:wallet, customer:, organization:) } + let(:resource) { create(:recurring_transaction_rule, wallet:, organization:) } + let(:params) { {connections: {payment: {code: "stripe_us"}}} } + + before { stripe_connection } + + it "owns the connection by the rule" do + result + + override = resource.billing_object_connections.sole + expect(override).to have_attributes( + owner_id: resource.id, + owner_type: "RecurringTransactionRule", + payment_provider_customer_id: stripe_connection.id + ) + end + end + + context "with every category at once" do + let(:params) do + { + connections: { + payment: {code: "stripe_us"}, + tax: {behavior: "skip"}, + accounting: {code: "netsuite_main"}, + crm: {behavior: "skip"} + } + } + end + + before do + stripe_connection + netsuite_connection + end + + it "writes one row per category" do + expect { result }.to change(BillingObjectConnection, :count).by(4) + expect(result.billing_object_connections.pluck(:category)).to match_array(%w[payment tax accounting crm]) + end + end + end +end diff --git a/spec/services/billing_object_connections/validate_service_spec.rb b/spec/services/billing_object_connections/validate_service_spec.rb new file mode 100644 index 000000000000..f2f866b53381 --- /dev/null +++ b/spec/services/billing_object_connections/validate_service_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingObjectConnections::ValidateService do + subject(:validator) { described_class.new(result, connections:) } + + let(:result) { BaseResult.new } + + describe "#valid?" do + context "when connections is absent" do + let(:connections) { nil } + + it "is valid" do + expect(validator).to be_valid + end + end + + context "when connections is empty" do + let(:connections) { {} } + + it "is valid" do + expect(validator).to be_valid + end + end + + context "with a code" do + let(:connections) { {payment: {code: "stripe_us"}} } + + it "is valid" do + expect(validator).to be_valid + end + end + + context "with each supported behavior" do + %w[inherit skip].each do |behavior| + context "when behavior is #{behavior}" do + let(:connections) { {tax: {behavior:}} } + + it "is valid" do + expect(validator).to be_valid + end + end + end + end + + context "with every category" do + let(:connections) do + { + payment: {behavior: "skip"}, + tax: {behavior: "skip"}, + accounting: {code: "netsuite_main"}, + crm: {behavior: "inherit"} + } + end + + it "is valid" do + expect(validator).to be_valid + end + end + + context "when the category is unknown" do + let(:connections) { {shipping: {behavior: "skip"}} } + + it "returns an invalid_connection_category error" do + expect(validator).not_to be_valid + expect(validator.error_codes).to include("invalid_connection_category") + end + end + + context "when the behavior is unknown" do + let(:connections) { {payment: {behavior: "whatever"}} } + + it "returns an invalid_connection_behavior error" do + expect(validator).not_to be_valid + expect(validator.error_codes).to include("invalid_connection_behavior") + end + end + + context "when behavior is specific" do + let(:connections) { {payment: {behavior: "specific"}} } + + it "is rejected, since specific is implied by supplying a code" do + expect(validator).not_to be_valid + expect(validator.error_codes).to include("invalid_connection_behavior") + end + end + + context "when both a code and a behavior are given" do + let(:connections) { {payment: {code: "stripe_us", behavior: "skip"}} } + + it "returns an invalid_connection_choice error" do + expect(validator).not_to be_valid + expect(validator.error_codes).to include("invalid_connection_choice") + end + end + + context "when neither a code nor a behavior is given" do + let(:connections) { {payment: {}} } + + it "returns an invalid_connection_choice error" do + expect(validator).not_to be_valid + expect(validator.error_codes).to include("invalid_connection_choice") + end + end + + context "when the choice is not an object" do + let(:connections) { {payment: "stripe_us"} } + + it "returns an invalid_connection_choice error" do + expect(validator).not_to be_valid + expect(validator.error_codes).to include("invalid_connection_choice") + end + end + + context "when connections is not an object" do + let(:connections) { ["payment"] } + + it "returns an invalid_connections error" do + expect(validator).not_to be_valid + expect(validator.error_codes).to eq(["invalid_connections"]) + end + end + end +end diff --git a/spec/services/wallets/recurring_transaction_rules/update_service_spec.rb b/spec/services/wallets/recurring_transaction_rules/update_service_spec.rb index 5c325a03652d..0aa52b39f09b 100644 --- a/spec/services/wallets/recurring_transaction_rules/update_service_spec.rb +++ b/spec/services/wallets/recurring_transaction_rules/update_service_spec.rb @@ -483,5 +483,140 @@ end end end + + context "with connections" do + let(:customer) { create(:customer) } + let(:wallet) { create(:wallet, customer:, organization: customer.organization) } + let(:stripe_connection) { create(:stripe_customer, customer:, code: "stripe_us") } + + before { stripe_connection } + + context "when the rule is matched by lago_id" do + let(:params) do + [ + { + lago_id: recurring_transaction_rule.id, + trigger: "interval", + interval: "weekly", + connections: {payment: {code: "stripe_us"}} + } + ] + end + + it "updates the rule in place and pins the connection to it" do + rule = result.wallet.reload.recurring_transaction_rules.active.sole + + expect(rule.id).to eq(recurring_transaction_rule.id) + expect(rule.billing_object_connections.sole).to have_attributes( + category: "payment", + behavior: "specific", + payment_provider_customer_id: stripe_connection.id + ) + end + + it "does not leak the connections key into the rule attributes" do + expect(result).to be_success + end + + context "when the rule already has an override for that category" do + before do + create( + :billing_object_connection, + owner: recurring_transaction_rule, + organization: wallet.organization, + category: "payment", + behavior: "skip" + ) + end + + it "keeps the surviving rule's row and updates it rather than duplicating" do + expect { result }.not_to change(BillingObjectConnection, :count) + + rule = result.wallet.reload.recurring_transaction_rules.active.sole + expect(rule.billing_object_connections.sole).to have_attributes( + behavior: "specific", + payment_provider_customer_id: stripe_connection.id + ) + end + end + end + + context "when the rule is replaced because no lago_id was sent" do + let(:params) do + [ + { + trigger: "interval", + interval: "weekly", + connections: {payment: {code: "stripe_us"}} + } + ] + end + + before do + create( + :billing_object_connection, + owner: recurring_transaction_rule, + organization: wallet.organization, + category: "payment", + behavior: "skip" + ) + end + + it "writes fresh rows for the new rule and leaves the terminated rule's rows alone" do + expect { result }.to change(BillingObjectConnection, :count).by(1) + + wallet = result.wallet.reload + new_rule = wallet.recurring_transaction_rules.active.sole + expect(new_rule.id).not_to eq(recurring_transaction_rule.id) + + expect(new_rule.billing_object_connections.sole).to have_attributes( + behavior: "specific", + payment_provider_customer_id: stripe_connection.id + ) + + # The superseded rule is soft-terminated, never destroyed, so dependent: :destroy does + # not fire and its override survives on the dead rule. + expect(recurring_transaction_rule.reload).to be_terminated + expect(recurring_transaction_rule.billing_object_connections.sole.behavior).to eq("skip") + end + end + + context "when a code does not resolve" do + let(:params) do + [ + { + lago_id: recurring_transaction_rule.id, + trigger: "interval", + interval: "weekly", + connections: {payment: {code: "nope"}} + } + ] + end + + it "fails with connection_not_found" do + expect(result).not_to be_success + expect(result.error.messages[:connections]).to include("connection_not_found") + end + end + + context "when the behavior is invalid" do + let(:params) do + [ + { + lago_id: recurring_transaction_rule.id, + trigger: "interval", + interval: "weekly", + connections: {payment: {behavior: "nonsense"}} + } + ] + end + + it "fails before writing anything" do + expect { result }.not_to change(BillingObjectConnection, :count) + expect(result).not_to be_success + expect(result.error.messages[:connections]).to include("invalid_connection_behavior") + end + end + end end end diff --git a/spec/support/shared_examples/wallet_actions.rb b/spec/support/shared_examples/wallet_actions.rb index e153631afbeb..2c1b1893b497 100644 --- a/spec/support/shared_examples/wallet_actions.rb +++ b/spec/support/shared_examples/wallet_actions.rb @@ -719,6 +719,139 @@ end end end + + context "with connections" do + let(:stripe_connection) { create(:stripe_customer, customer:, code: "stripe_us") } + let(:netsuite_connection) { create(:netsuite_customer, customer:, code: "netsuite_main") } + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + connections: { + payment: {code: "stripe_us"}, + tax: {behavior: "skip"}, + accounting: {code: "netsuite_main"}, + crm: {behavior: "skip"} + } + } + end + + before do + organization.enable_feature_flag!(:multi_connection) + stripe_connection + netsuite_connection + end + + it "persists one connection per category" do + expect { subject }.to change(BillingObjectConnection, :count).by(4) + + expect(response).to have_http_status(:success) + + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(wallet.billing_object_connections.pluck(:category)).to match_array(%w[payment tax accounting crm]) + expect(wallet.effective_payment_connection).to eq(stripe_connection) + expect(wallet.effective_accounting_connection).to eq(netsuite_connection) + expect(wallet.effective_tax_connection).to be_nil + end + + it "keeps the top-level payment_method working alongside connections" do + create_params[:payment_method] = {payment_method_type: "provider", payment_method_id: payment_method.id} + + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:payment_method][:payment_method_id]).to eq(payment_method.id) + expect(Wallet.find(json[:wallet][:lago_id]).billing_object_connections.count).to eq(4) + end + + context "when a code does not resolve" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + connections: {payment: {code: "unknown_connection"}} + } + end + + it "returns a validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:connections]).to include("connection_not_found") + end + end + + context "when the behavior is invalid" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + connections: {payment: {behavior: "nonsense"}} + } + end + + it "returns a validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:connections]).to include("invalid_connection_behavior") + end + end + + context "when the multi_connection flag is disabled" do + before { organization.disable_feature_flag!(:multi_connection) } + + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + end + end + + context "with connections on a recurring transaction rule", :premium do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + recurring_transaction_rules: [ + { + trigger: "interval", + interval: "monthly", + connections: {payment: {code: "stripe_us"}} + } + ] + } + end + + it "owns the connection by the rule, not the wallet" do + subject + + expect(response).to have_http_status(:success) + + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(wallet.billing_object_connections).to be_empty + + rule = wallet.recurring_transaction_rules.sole + expect(rule.billing_object_connections.sole).to have_attributes( + category: "payment", + behavior: "specific", + payment_provider_customer_id: stripe_connection.id + ) + end + end + end end RSpec.shared_examples "a wallet create endpoint with billing_entity_id" do @@ -1252,6 +1385,48 @@ end end end + + context "with connections" do + let(:stripe_connection) { create(:stripe_customer, customer:, code: "stripe_us") } + let(:update_params) { {name: "wallet1", connections: {payment: {code: "stripe_us"}}} } + + before do + organization.enable_feature_flag!(:multi_connection) + stripe_connection + end + + it "pins the connection on the wallet" do + expect { subject }.to change(BillingObjectConnection, :count).by(1) + + expect(response).to have_http_status(:success) + expect(wallet.reload.effective_payment_connection).to eq(stripe_connection) + end + + context "when inherit is sent for a category that has an override" do + let(:update_params) { {name: "wallet1", connections: {payment: {behavior: "inherit"}}} } + + before do + create(:billing_object_connection, owner: wallet, organization:, category: "payment", behavior: "skip") + end + + it "clears the override so resolution falls back to the customer" do + expect { subject }.to change(BillingObjectConnection, :count).by(-1) + + expect(response).to have_http_status(:success) + expect(wallet.reload.billing_object_connections).to be_empty + end + end + + context "when the multi_connection flag is disabled" do + before { organization.disable_feature_flag!(:multi_connection) } + + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + end + end + end end RSpec.shared_examples "a wallet show endpoint" do From ecc308cbd7eede2ef596eee1cc2827db004ef5fc Mon Sep 17 00:00:00 2001 From: Mario Diniz Date: Thu, 10 Sep 2026 10:22:37 -0300 Subject: [PATCH 2/2] [ING-462] feat(subscriptions): accept connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Billing objects need to route themselves to one of a customer's connections. ING-466 added the write path for wallets along with the shared validation and persistence services; this is the subscription half of the same work (dive-in T18b). ## Description `POST/PATCH /subscriptions` accept a `connections` object keyed by category, with the same semantics as the wallet endpoints: a `code` pins that connection, `skip` short-circuits the category, `inherit` drops the override, and an omitted category changes nothing. No new services — the polymorphic ones from ING-466 are reused as-is, so only the params, the validator and the two write paths change. Behind the `multi_connection` flag. Connections sent while the flag is off are refused rather than ignored, and an unresolvable code is a 422. Unlike `invoice_custom_section`, connections are attached on a downgrade too. They are routing config in the same family as `payment_method`, which the upgrade and downgrade paths already carry onto the new subscription, so skipping them would silently discard the choice. --- .../api/v1/subscriptions_controller.rb | 12 ++ app/services/subscriptions/create_service.rb | 14 ++ app/services/subscriptions/update_service.rb | 14 ++ .../subscriptions/validate_service.rb | 12 ++ .../api/v1/subscriptions_controller_spec.rb | 124 ++++++++++++++++++ .../subscriptions/create_service_spec.rb | 85 ++++++++++++ 6 files changed, 261 insertions(+) diff --git a/app/controllers/api/v1/subscriptions_controller.rb b/app/controllers/api/v1/subscriptions_controller.rb index 6a4d06239d1d..f1b6619d9db0 100644 --- a/app/controllers/api/v1/subscriptions_controller.rb +++ b/app/controllers/api/v1/subscriptions_controller.rb @@ -180,6 +180,12 @@ def create_params :payment_method_type, :payment_method_id ], + connections: [ + payment: [:behavior, :code], + tax: [:behavior, :code], + accounting: [:behavior, :code], + crm: [:behavior, :code] + ], usage_thresholds: usage_thresholds_params, plan_overrides: ) @@ -206,6 +212,12 @@ def update_params :payment_method_type, :payment_method_id ], + connections: [ + payment: [:behavior, :code], + tax: [:behavior, :code], + accounting: [:behavior, :code], + crm: [:behavior, :code] + ], usage_thresholds: usage_thresholds_params, plan_overrides: ) diff --git a/app/services/subscriptions/create_service.rb b/app/services/subscriptions/create_service.rb index 25e58ea1353e..3752e3272544 100644 --- a/app/services/subscriptions/create_service.rb +++ b/app/services/subscriptions/create_service.rb @@ -28,12 +28,14 @@ def call subscription_at:, ending_at: params[:ending_at], payment_method: params[:payment_method], + connections: params[:connections], activation_rules: params[:activation_rules], subscription_type:, consolidate_invoice: params[:consolidate_invoice], consolidate_invoice_provided: params.key?(:consolidate_invoice) ) return result.forbidden_failure! if !License.premium? && params.key?(:plan_overrides) + return result.forbidden_failure! if connections_requested? && organization_flag_disabled?(:multi_connection) if params.key?(:plan_overrides) && plan.organization.product_catalog_enabled? return result.single_validation_failure!(field: :plan_overrides, error_code: "legacy_billing_disabled") @@ -83,6 +85,10 @@ def call end InvoiceCustomSections::AttachToResourceService.call(resource: subscription, params:) unless downgrade? + if connections_requested? + BillingObjectConnections::AttachToResourceService.call!(resource: subscription, params:) + end + result.subscription = subscription end end @@ -114,6 +120,14 @@ def valid?(args) Subscriptions::ValidateService.new(result, **args).valid? end + def connections_requested? + params[:connections].present? + end + + def organization_flag_disabled?(flag) + !customer.organization.feature_flag_enabled?(flag) + end + def handle_subscription return upgrade_subscription if upgrade? return downgrade_subscription if downgrade? diff --git a/app/services/subscriptions/update_service.rb b/app/services/subscriptions/update_service.rb index 2a38fdb605f3..3fb180116021 100644 --- a/app/services/subscriptions/update_service.rb +++ b/app/services/subscriptions/update_service.rb @@ -37,6 +37,7 @@ def call on_termination_credit_note: params[:on_termination_credit_note], on_termination_invoice: params[:on_termination_invoice], payment_method: params[:payment_method], + connections: params[:connections], activation_rules: params[:activation_rules], subscription_type: "update", subscription:, @@ -55,6 +56,7 @@ def call end return result.forbidden_failure! if !License.premium? && params.key?(:plan_overrides) + return result.forbidden_failure! if connections_requested? && organization_flag_disabled?(:multi_connection) if params.key?(:plan_overrides) && subscription.plan.organization.product_catalog_enabled? return result.single_validation_failure!(field: :plan_overrides, error_code: "legacy_billing_disabled") @@ -123,6 +125,10 @@ def call end InvoiceCustomSections::AttachToResourceService.call(resource: subscription, params:) + + if connections_requested? + BillingObjectConnections::AttachToResourceService.call!(resource: subscription, params:) + end end result.subscription = subscription @@ -287,6 +293,14 @@ def valid?(args) Subscriptions::ValidateService.new(result, **args).valid? end + def connections_requested? + params[:connections].present? + end + + def organization_flag_disabled?(flag) + !subscription.organization.feature_flag_enabled?(flag) + end + def payment_method return @payment_method if defined? @payment_method return nil if params[:payment_method].blank? || params[:payment_method][:payment_method_id].blank? diff --git a/app/services/subscriptions/validate_service.rb b/app/services/subscriptions/validate_service.rb index 7e707251bba0..e6032d11037e 100644 --- a/app/services/subscriptions/validate_service.rb +++ b/app/services/subscriptions/validate_service.rb @@ -11,6 +11,7 @@ def valid? valid_on_termination_credit_note? valid_on_termination_invoice? valid_payment_method? + valid_connections? valid_activation_rules? valid_consolidate_invoice? @@ -101,6 +102,17 @@ def valid_payment_method? false end + def valid_connections? + return true if args[:connections].blank? + + validator = BillingObjectConnections::ValidateService.new(result, connections: args[:connections]) + return true if validator.valid? + + validator.error_codes.each { |error_code| add_error(field: :connections, error_code:) } + + false + end + def valid_activation_rules? return true unless args[:activation_rules] diff --git a/spec/requests/api/v1/subscriptions_controller_spec.rb b/spec/requests/api/v1/subscriptions_controller_spec.rb index 0b4576ef4d2c..c8e8d3236c7f 100644 --- a/spec/requests/api/v1/subscriptions_controller_spec.rb +++ b/spec/requests/api/v1/subscriptions_controller_spec.rb @@ -866,6 +866,88 @@ end end end + + context "with connections" do + let(:stripe_connection) { create(:stripe_customer, customer:, code: "stripe_us") } + let(:netsuite_connection) { create(:netsuite_customer, customer:, code: "netsuite_main") } + let(:params) do + { + external_customer_id: customer.external_id, + plan_code: plan.code, + external_id: SecureRandom.uuid, + connections: { + payment: {code: "stripe_us"}, + tax: {behavior: "skip"}, + accounting: {code: "netsuite_main"}, + crm: {behavior: "skip"} + } + } + end + + before do + organization.enable_feature_flag!(:multi_connection) + stripe_connection + netsuite_connection + end + + it "persists one connection per category" do + expect { subject }.to change(BillingObjectConnection, :count).by(4) + + expect(response).to have_http_status(:success) + + subscription = Subscription.find(json[:subscription][:lago_id]) + expect(subscription.billing_object_connections.pluck(:category)).to match_array(%w[payment tax accounting crm]) + expect(subscription.effective_payment_connection).to eq(stripe_connection) + expect(subscription.effective_accounting_connection).to eq(netsuite_connection) + expect(subscription.effective_tax_connection).to be_nil + end + + context "when a code does not resolve" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code: plan.code, + external_id: SecureRandom.uuid, + connections: {payment: {code: "unknown_connection"}} + } + end + + it "returns a validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:connections]).to include("connection_not_found") + end + end + + context "when the behavior is invalid" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code: plan.code, + external_id: SecureRandom.uuid, + connections: {payment: {behavior: "nonsense"}} + } + end + + it "returns a validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:connections]).to include("invalid_connection_behavior") + end + end + + context "when the multi_connection flag is disabled" do + before { organization.disable_feature_flag!(:multi_connection) } + + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + end + end + end end describe "DELETE /api/v1/subscriptions/:external_id" do @@ -1913,6 +1995,48 @@ def test_termination(expected_on_termination_credit_note: nil, expected_on_termi end end end + + context "with connections" do + let(:stripe_connection) { create(:stripe_customer, customer:, code: "stripe_us") } + let(:update_params) { {connections: {payment: {code: "stripe_us"}}} } + + before do + organization.enable_feature_flag!(:multi_connection) + stripe_connection + end + + it "pins the connection on the subscription" do + expect { subject }.to change(BillingObjectConnection, :count).by(1) + + expect(response).to have_http_status(:success) + expect(subscription.reload.effective_payment_connection).to eq(stripe_connection) + end + + context "when inherit is sent for a category that has an override" do + let(:update_params) { {connections: {payment: {behavior: "inherit"}}} } + + before do + create(:billing_object_connection, owner: subscription, organization:, category: "payment", behavior: "skip") + end + + it "clears the override so resolution falls back to the customer" do + expect { subject }.to change(BillingObjectConnection, :count).by(-1) + + expect(response).to have_http_status(:success) + expect(subscription.reload.billing_object_connections).to be_empty + end + end + + context "when the multi_connection flag is disabled" do + before { organization.disable_feature_flag!(:multi_connection) } + + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + end + end + end end describe "GET /api/v1/subscriptions/:external_id" do diff --git a/spec/services/subscriptions/create_service_spec.rb b/spec/services/subscriptions/create_service_spec.rb index 6ad492fa6bf0..2589bf30b3fb 100644 --- a/spec/services/subscriptions/create_service_spec.rb +++ b/spec/services/subscriptions/create_service_spec.rb @@ -2219,5 +2219,90 @@ end end end + + context "with connections" do + let(:stripe_connection) { create(:stripe_customer, customer:, code: "stripe_us") } + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + connections: {payment: {code: "stripe_us"}} + } + end + + before do + organization.enable_feature_flag!(:multi_connection) + stripe_connection + end + + it "pins the connection on the created subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.billing_object_connections.sole).to have_attributes( + category: "payment", + behavior: "specific", + payment_provider_customer_id: stripe_connection.id + ) + end + + context "when the code does not resolve" do + let(:params) { super().merge(connections: {payment: {code: "nope"}}) } + + it "fails with connection_not_found and creates no subscription" do + expect { create_service.call }.not_to change(Subscription, :count) + expect(create_service.call.error.messages[:connections]).to include("connection_not_found") + end + end + + context "when the multi_connection flag is disabled" do + before { organization.disable_feature_flag!(:multi_connection) } + + it "returns a forbidden failure rather than dropping the routing choice" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "when upgrading an existing subscription" do + let(:old_plan) { create(:plan, amount_cents: 50, organization:, amount_currency: "EUR") } + let!(:current_subscription) do + create(:subscription, customer:, organization:, plan: old_plan, external_id:) + end + + it "pins the connection on the new subscription, not the terminated one" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.id).not_to eq(current_subscription.id) + expect(result.subscription.billing_object_connections.sole.payment_provider_customer_id) + .to eq(stripe_connection.id) + expect(current_subscription.reload.billing_object_connections).to be_empty + end + end + + context "when downgrading an existing subscription" do + let(:plan) { create(:plan, amount_cents: 50, organization:, amount_currency: "EUR") } + let(:old_plan) { create(:plan, amount_cents: 500, organization:, amount_currency: "EUR") } + + before { create(:subscription, customer:, organization:, plan: old_plan, external_id:) } + + # Deliberately not skipped the way invoice_custom_section is on a downgrade: connections are + # routing config in the same family as payment_method, which the downgrade path already + # carries onto the pending subscription. + it "pins the connection on the pending subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.billing_object_connections.sole.payment_provider_customer_id) + .to eq(stripe_connection.id) + end + end + end end end