diff --git a/app/controllers/concerns/wallet_actions.rb b/app/controllers/concerns/wallet_actions.rb index a92f76b1db8c..8496f7509a19 100644 --- a/app/controllers/concerns/wallet_actions.rb +++ b/app/controllers/concerns/wallet_actions.rb @@ -73,9 +73,13 @@ def wallet_index(external_customer_id:, currency:, billing_entity_codes: nil) :billing_entity, :metadata, :billable_metrics, - {customer: :billing_entity}, + :billing_object_connections, + {customer: [:billing_entity, :payment_provider_customers, :integration_customers]}, {applied_invoice_custom_sections: :invoice_custom_section}, - {recurring_transaction_rules: {applied_invoice_custom_sections: :invoice_custom_section}} + {recurring_transaction_rules: [ + :billing_object_connections, + {applied_invoice_custom_sections: :invoice_custom_section} + ]} ), ::V1::WalletSerializer, collection_name: "wallets", diff --git a/app/graphql/types/connections/category_enum.rb b/app/graphql/types/connections/category_enum.rb new file mode 100644 index 000000000000..fea5c4aa9a72 --- /dev/null +++ b/app/graphql/types/connections/category_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Connections + class CategoryEnum < Types::BaseEnum + graphql_name "ConnectionCategoryEnum" + + BillingObjectConnection::CATEGORIES.each_key do |category| + value category + end + end + end +end diff --git a/app/graphql/types/connections/object.rb b/app/graphql/types/connections/object.rb new file mode 100644 index 000000000000..3355215fffc3 --- /dev/null +++ b/app/graphql/types/connections/object.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Connections + class Object < Types::BaseObject + graphql_name "ConnectionRouting" + description "The connection a billing object routes to for one category, and where that choice came from" + + field :behavior, Types::Connections::ResolvedBehaviorEnum, null: false + field :category, Types::Connections::CategoryEnum, null: false + field :code, String, null: true, + description: "Code of the connection in effect. Null when the category is skipped or nothing resolves" + end + end +end diff --git a/app/graphql/types/connections/resolved_behavior_enum.rb b/app/graphql/types/connections/resolved_behavior_enum.rb new file mode 100644 index 000000000000..48a21e584cb9 --- /dev/null +++ b/app/graphql/types/connections/resolved_behavior_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Connections + class ResolvedBehaviorEnum < Types::BaseEnum + graphql_name "ConnectionResolvedBehaviorEnum" + description "How a billing object routes a category: its own choice, or inherited from the customer" + + ConnectionResolvable::ROUTING_BEHAVIORS.each do |behavior| + value behavior + end + end + end +end diff --git a/app/graphql/types/wallets/object.rb b/app/graphql/types/wallets/object.rb index caeac872524c..ea1da1d472f4 100644 --- a/app/graphql/types/wallets/object.rb +++ b/app/graphql/types/wallets/object.rb @@ -35,6 +35,7 @@ class Object < Types::BaseObject field :last_ongoing_balance_sync_at, GraphQL::Types::ISO8601DateTime, null: true field :activity_logs, [Types::ActivityLogs::Object], null: true + field :connections, [Types::Connections::Object], null: false, method: :connection_routing field :recurring_transaction_rules, [Types::Wallets::RecurringTransactionRules::Object], null: true field :invoice_requires_successful_payment, Boolean, null: false diff --git a/app/graphql/types/wallets/recurring_transaction_rules/object.rb b/app/graphql/types/wallets/recurring_transaction_rules/object.rb index 79f044f68220..3d9879808157 100644 --- a/app/graphql/types/wallets/recurring_transaction_rules/object.rb +++ b/app/graphql/types/wallets/recurring_transaction_rules/object.rb @@ -8,6 +8,8 @@ class Object < Types::BaseObject field :lago_id, ID, null: false, method: :id + field :connections, [Types::Connections::Object], null: false, method: :connection_routing + field :created_at, GraphQL::Types::ISO8601DateTime, null: false field :expiration_at, GraphQL::Types::ISO8601DateTime, null: true field :granted_credits, String, null: false diff --git a/app/models/concerns/connection_resolvable.rb b/app/models/concerns/connection_resolvable.rb index 0e9cfa076804..3731f631bb4e 100644 --- a/app/models/concerns/connection_resolvable.rb +++ b/app/models/concerns/connection_resolvable.rb @@ -14,6 +14,13 @@ module ConnectionResolvable CATEGORIES = BillingObjectConnection::CATEGORIES + # Read-side only: the absence of an override row is reported as "inherit". The column itself + # only ever holds "specific" or "skip". + INHERIT_BEHAVIOR = "inherit" + ROUTING_BEHAVIORS = (BillingObjectConnection::BEHAVIORS.values + [INHERIT_BEHAVIOR]).freeze + + Routing = Data.define(:category, :behavior, :code) + def effective_payment_connection effective_connection(CATEGORIES[:payment]) end @@ -30,6 +37,32 @@ def effective_crm_connection effective_connection(CATEGORIES[:crm]) end + # The routing of every category, for read surfaces: the stored behaviour ("inherit" when no + # override row exists) alongside the code of the connection actually in effect. Overrides are + # loaded once rather than per category, so `includes(:billing_object_connections)` on a + # collection keeps this to one query. + def connection_routing + overrides = billing_object_connections.index_by(&:category) + + CATEGORIES.each_value.map do |category| + override = overrides[category] + + connection = if override.nil? + customer_default_connection(category) + elsif override.skip? + nil + else + override_connection(override, category) + end + + Routing.new( + category: category, + behavior: override&.behavior || INHERIT_BEHAVIOR, + code: connection&.code + ) + end + end + private def effective_connection(category) diff --git a/app/serializers/v1/wallet_serializer.rb b/app/serializers/v1/wallet_serializer.rb index f012d51a4c91..0bca3a16f6b7 100644 --- a/app/serializers/v1/wallet_serializer.rb +++ b/app/serializers/v1/wallet_serializer.rb @@ -36,6 +36,7 @@ def serialize payload.merge!(limitations) if include?(:limitations) payload.merge!(applied_invoice_custom_sections) if include?(:applied_invoice_custom_sections) payload.merge!(payment_method) + payload.merge!(connections) payload.merge!(metadata) if model.metadata.present? payload @@ -73,6 +74,17 @@ def payment_method } end + # Keyed by category, mirroring the shape accepted on create/update. Every category is present, + # with the behaviour ("inherit" when the wallet makes no choice of its own) and the code of the + # connection actually in effect. + def connections + { + connections: model.connection_routing.index_by { it.category }.transform_values do |routing| + {behavior: routing.behavior, code: routing.code} + end + } + end + def applied_invoice_custom_sections ::CollectionSerializer.new( model.applied_invoice_custom_sections, diff --git a/app/serializers/v1/wallets/recurring_transaction_rule_serializer.rb b/app/serializers/v1/wallets/recurring_transaction_rule_serializer.rb index 4a6b5f44cd46..20f9462e56e2 100644 --- a/app/serializers/v1/wallets/recurring_transaction_rule_serializer.rb +++ b/app/serializers/v1/wallets/recurring_transaction_rule_serializer.rb @@ -27,6 +27,7 @@ def serialize payload.merge!(applied_invoice_custom_sections) payload.merge!(payment_method) + payload.merge!(connections) payload end @@ -49,6 +50,15 @@ def payment_method } } end + + # Keyed by category, mirroring the shape accepted on create/update. + def connections + { + connections: model.connection_routing.index_by { it.category }.transform_values do |routing| + {behavior: routing.behavior, code: routing.code} + end + } + end end end end diff --git a/schema.graphql b/schema.graphql index f7cf6ad7bb76..d6858eed0e47 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1507,6 +1507,35 @@ enum CommitmentTypeEnum { minimum_commitment } +enum ConnectionCategoryEnum { + accounting + crm + payment + tax +} + +""" +How a billing object routes a category: its own choice, or inherited from the customer +""" +enum ConnectionResolvedBehaviorEnum { + inherit + skip + specific +} + +""" +The connection a billing object routes to for one category, and where that choice came from +""" +type ConnectionRouting { + behavior: ConnectionResolvedBehaviorEnum! + category: ConnectionCategoryEnum! + + """ + Code of the connection in effect. Null when the category is skipped or nothing resolves + """ + code: String +} + """ The agreement a customer signed: an optional plan, a validity window and the billing anchor """ @@ -12891,6 +12920,7 @@ enum RecurringTransactionMethodEnum { } type RecurringTransactionRule { + connections: [ConnectionRouting!]! createdAt: ISO8601DateTime! expirationAt: ISO8601DateTime grantedCredits: String! @@ -15819,6 +15849,7 @@ type Wallet { balanceCents: BigInt! billingEntityId: ID code: String + connections: [ConnectionRouting!]! consumedAmountCents: BigInt! consumedCredits: Float! createdAt: ISO8601DateTime! diff --git a/schema.json b/schema.json index 7c7a2f8a585a..e2cf695dcae4 100644 --- a/schema.json +++ b/schema.json @@ -8907,6 +8907,125 @@ ], "possibleTypes": null }, + { + "kind": "ENUM", + "name": "ConnectionCategoryEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "payment", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "accounting", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "crm", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ConnectionResolvedBehaviorEnum", + "description": "How a billing object routes a category: its own choice, or inherited from the customer", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "specific", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skip", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inherit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ConnectionRouting", + "description": "The connection a billing object routes to for one category, and where that choice came from", + "fields": [ + { + "name": "behavior", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ConnectionResolvedBehaviorEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "category", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ConnectionCategoryEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": "Code of the connection in effect. Null when the category is skipped or nothing resolves", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Contract", @@ -70727,6 +70846,30 @@ "name": "RecurringTransactionRule", "description": null, "fields": [ + { + "name": "connections", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ConnectionRouting", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "createdAt", "description": null, @@ -85575,6 +85718,30 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "connections", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ConnectionRouting", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "consumedAmountCents", "description": null, diff --git a/spec/graphql/resolvers/wallet_resolver_spec.rb b/spec/graphql/resolvers/wallet_resolver_spec.rb index d9a24588f0e6..55f6e1a4259c 100644 --- a/spec/graphql/resolvers/wallet_resolver_spec.rb +++ b/spec/graphql/resolvers/wallet_resolver_spec.rb @@ -141,4 +141,56 @@ end end end + + context "with connections" do + let(:connections_query) do + <<~GQL + query($id: ID!) { + wallet(id: $id) { + id + connections { category behavior code } + recurringTransactionRules { + connections { category behavior code } + } + } + } + GQL + end + + let(:pinned) { create(:gocardless_customer, customer:, organization:, code: "gocardless_eu") } + + before do + create(:stripe_customer, customer:, organization:, code: "stripe_default", is_default: true) + create(:billing_object_connection, owner: wallet, organization:, category: "tax", behavior: "skip") + create(:billing_object_connection, owner: wallet, organization:, category: "payment", + behavior: "specific", payment_provider_customer: pinned) + end + + def wallet_connections + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: connections_query, + variables: {id: wallet.id} + ) + result["data"]["wallet"] + end + + it "returns every category with its behaviour and effective code" do + connections = wallet_connections["connections"].index_by { it["category"] } + + expect(connections.keys).to match_array(%w[payment tax accounting crm]) + expect(connections["payment"]).to eq({"category" => "payment", "behavior" => "specific", "code" => "gocardless_eu"}) + expect(connections["tax"]).to eq({"category" => "tax", "behavior" => "skip", "code" => nil}) + expect(connections["crm"]).to eq({"category" => "crm", "behavior" => "inherit", "code" => nil}) + end + + it "returns the rule's own routing, inherited from the customer" do + rule_connections = wallet_connections["recurringTransactionRules"].first["connections"].index_by { it["category"] } + + expect(rule_connections["payment"]).to eq( + {"category" => "payment", "behavior" => "inherit", "code" => "stripe_default"} + ) + end + end end diff --git a/spec/serializers/v1/wallets/recurring_transaction_rule_serializer_spec.rb b/spec/serializers/v1/wallets/recurring_transaction_rule_serializer_spec.rb index 8adeebc47aa0..5c9bbb5568b9 100644 --- a/spec/serializers/v1/wallets/recurring_transaction_rule_serializer_spec.rb +++ b/spec/serializers/v1/wallets/recurring_transaction_rule_serializer_spec.rb @@ -44,4 +44,24 @@ expect(result["recurring_transaction_rule"]["grants_target_top_up"]).to be(true) end end + + context "with connections" do + let(:customer) { recurring_transaction_rule.wallet.customer } + + before do + create(:stripe_customer, customer:, organization: customer.organization, + code: "stripe_default", is_default: true) + create(:billing_object_connection, owner: recurring_transaction_rule, + organization: recurring_transaction_rule.organization, category: "tax", behavior: "skip") + end + + it "reports every category with its behaviour and effective code" do + result = JSON.parse(serializer.to_json) + connections = result["recurring_transaction_rule"]["connections"] + + expect(connections.keys).to match_array(%w[payment tax accounting crm]) + expect(connections["payment"]).to eq({"behavior" => "inherit", "code" => "stripe_default"}) + expect(connections["tax"]).to eq({"behavior" => "skip", "code" => nil}) + end + end end diff --git a/spec/support/shared_examples/connection_resolvable.rb b/spec/support/shared_examples/connection_resolvable.rb index da5c40b1d12f..5d8e765d82c0 100644 --- a/spec/support/shared_examples/connection_resolvable.rb +++ b/spec/support/shared_examples/connection_resolvable.rb @@ -101,4 +101,53 @@ expect(resolvable.effective_crm_connection).to eq(default_connection) end end + + describe "#connection_routing" do + let(:routing) { resolvable.connection_routing.index_by(&:category) } + + it "reports every category" do + expect(routing.keys).to match_array(%w[payment tax accounting crm]) + end + + context "when the category is inherited from the customer" do + let!(:default_connection) do + create(:stripe_customer, customer: resolution_customer, organization:, is_default: true, code: "stripe_default") + end + + it "reports inherit with the customer default's code" do + expect(routing["payment"]).to have_attributes(behavior: "inherit", code: default_connection.code) + end + end + + context "when the object pins a specific connection" do + let(:override_connection) do + create(:gocardless_customer, customer: resolution_customer, organization:, code: "gocardless_eu") + end + + before do + create(:billing_object_connection, owner: resolvable, organization:, category: :payment, + behavior: :specific, payment_provider_customer: override_connection) + end + + it "reports specific with the pinned code" do + expect(routing["payment"]).to have_attributes(behavior: "specific", code: "gocardless_eu") + end + end + + context "when the object skips the category" do + before do + create(:billing_object_connection, owner: resolvable, organization:, category: :payment, behavior: :skip) + end + + it "reports skip with no code" do + expect(routing["payment"]).to have_attributes(behavior: "skip", code: nil) + end + end + + context "when nothing resolves" do + it "reports inherit with a nil code" do + expect(routing["crm"]).to have_attributes(behavior: "inherit", code: nil) + end + end + end end diff --git a/spec/support/shared_examples/wallet_actions.rb b/spec/support/shared_examples/wallet_actions.rb index e153631afbeb..d1d138cb183d 100644 --- a/spec/support/shared_examples/wallet_actions.rb +++ b/spec/support/shared_examples/wallet_actions.rb @@ -1277,6 +1277,52 @@ end end + context "with connections in response" do + let(:stripe_connection) do + create(:stripe_customer, customer:, organization:, code: "stripe_default", is_default: true) + end + + before { stripe_connection } + + it "reports every category with its behaviour and effective code" do + subject + + expect(response).to have_http_status(:success) + + connections = json[:wallet][:connections] + expect(connections.keys).to match_array(%i[payment tax accounting crm]) + expect(connections[:payment]).to eq({behavior: "inherit", code: "stripe_default"}) + expect(connections[:crm]).to eq({behavior: "inherit", code: nil}) + end + + context "when the wallet pins a specific connection" do + let(:pinned) { create(:gocardless_customer, customer:, organization:, code: "gocardless_eu") } + + before do + create(:billing_object_connection, owner: wallet, organization:, category: "payment", + behavior: "specific", payment_provider_customer: pinned) + end + + it "reports specific with the pinned code" do + subject + + expect(json[:wallet][:connections][:payment]).to eq({behavior: "specific", code: "gocardless_eu"}) + end + end + + context "when the wallet skips a category" do + before do + create(:billing_object_connection, owner: wallet, organization:, category: "payment", behavior: "skip") + end + + it "reports skip with no code" do + subject + + expect(json[:wallet][:connections][:payment]).to eq({behavior: "skip", code: nil}) + end + end + end + context "with applied_invoice_custom_sections in response" do before { create(:wallet_applied_invoice_custom_section, wallet:) }