diff --git a/app/graphql/types/integration_collection_mappings/create_input.rb b/app/graphql/types/integration_collection_mappings/create_input.rb index f84d61b9802..cdec75dc60f 100644 --- a/app/graphql/types/integration_collection_mappings/create_input.rb +++ b/app/graphql/types/integration_collection_mappings/create_input.rb @@ -5,6 +5,7 @@ module IntegrationCollectionMappings class CreateInput < Types::BaseInputObject graphql_name "CreateIntegrationCollectionMappingInput" + argument :billing_entity_id, ID, required: false argument :external_account_code, String, required: false argument :external_id, String, required: true argument :external_name, String, required: false diff --git a/app/graphql/types/integration_collection_mappings/object.rb b/app/graphql/types/integration_collection_mappings/object.rb index 6be1b33d46b..4bd995ff2a1 100644 --- a/app/graphql/types/integration_collection_mappings/object.rb +++ b/app/graphql/types/integration_collection_mappings/object.rb @@ -5,6 +5,7 @@ module IntegrationCollectionMappings class Object < Types::BaseObject graphql_name "CollectionMapping" + field :billing_entity_id, ID, null: true field :external_account_code, String, null: true field :external_id, String, null: false field :external_name, String, null: true diff --git a/app/graphql/types/integration_mappings/create_input.rb b/app/graphql/types/integration_mappings/create_input.rb index f6859a94f73..f9dc84e8827 100644 --- a/app/graphql/types/integration_mappings/create_input.rb +++ b/app/graphql/types/integration_mappings/create_input.rb @@ -5,6 +5,7 @@ module IntegrationMappings class CreateInput < Types::BaseInputObject graphql_name "CreateIntegrationMappingInput" + argument :billing_entity_id, ID, required: false argument :external_account_code, String, required: false argument :external_id, String, required: true argument :external_name, String, required: false diff --git a/app/graphql/types/integration_mappings/object.rb b/app/graphql/types/integration_mappings/object.rb index ed47648a1e9..0c5b55e5a53 100644 --- a/app/graphql/types/integration_mappings/object.rb +++ b/app/graphql/types/integration_mappings/object.rb @@ -5,6 +5,7 @@ module IntegrationMappings class Object < Types::BaseObject graphql_name "Mapping" + field :billing_entity_id, ID, null: true field :external_account_code, String, null: true field :external_id, String, null: false field :external_name, String, null: true diff --git a/app/models/billing_entity.rb b/app/models/billing_entity.rb index 3cc85672a2a..ea5a14fe872 100644 --- a/app/models/billing_entity.rb +++ b/app/models/billing_entity.rb @@ -17,16 +17,23 @@ class BillingEntity < ApplicationRecord EINVOICING_COUNTRIES = %w[FR].map(&:upcase) belongs_to :organization + belongs_to :applied_dunning_campaign, class_name: "DunningCampaign", optional: true has_many :applied_taxes, class_name: "BillingEntity::AppliedTax", dependent: :destroy has_many :customers has_many :fees has_many :invoices has_many :payment_receipts - has_many :applied_invoice_custom_sections, class_name: "BillingEntity::AppliedInvoiceCustomSection", dependent: :destroy + has_many :integration_collection_mappings, + class_name: "IntegrationCollectionMappings::BaseCollectionMapping", + dependent: :destroy + has_many :integration_mappings, + class_name: "IntegrationMappings::BaseMapping", + dependent: :destroy + has_many :selected_invoice_custom_sections, through: :applied_invoice_custom_sections, source: :invoice_custom_section @@ -50,8 +57,6 @@ class BillingEntity < ApplicationRecord class_name: "Clickhouse::ActivityLog", as: :resource - belongs_to :applied_dunning_campaign, class_name: "DunningCampaign", optional: true - has_one_attached :logo DOCUMENT_NUMBERINGS = { diff --git a/app/models/integration_collection_mappings/anrok_collection_mapping.rb b/app/models/integration_collection_mappings/anrok_collection_mapping.rb index 5e1c94a8f93..d898c7a80fb 100644 --- a/app/models/integration_collection_mappings/anrok_collection_mapping.rb +++ b/app/models/integration_collection_mappings/anrok_collection_mapping.rb @@ -9,23 +9,27 @@ class AnrokCollectionMapping < BaseCollectionMapping # # Table name: integration_collection_mappings # -# id :uuid not null, primary key -# mapping_type :integer not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_int_collection_mappings_on_mapping_type_and_int_id (mapping_type,integration_id) UNIQUE -# index_integration_collection_mappings_on_integration_id (integration_id) -# index_integration_collection_mappings_on_organization_id (organization_id) +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/integration_collection_mappings/avalara_collection_mapping.rb b/app/models/integration_collection_mappings/avalara_collection_mapping.rb index 957d2acedb4..769e94f5451 100644 --- a/app/models/integration_collection_mappings/avalara_collection_mapping.rb +++ b/app/models/integration_collection_mappings/avalara_collection_mapping.rb @@ -9,23 +9,27 @@ class AvalaraCollectionMapping < BaseCollectionMapping # # Table name: integration_collection_mappings # -# id :uuid not null, primary key -# mapping_type :integer not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_int_collection_mappings_on_mapping_type_and_int_id (mapping_type,integration_id) UNIQUE -# index_integration_collection_mappings_on_integration_id (integration_id) -# index_integration_collection_mappings_on_organization_id (organization_id) +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/integration_collection_mappings/base_collection_mapping.rb b/app/models/integration_collection_mappings/base_collection_mapping.rb index ae8cd525cff..53bdedf5705 100644 --- a/app/models/integration_collection_mappings/base_collection_mapping.rb +++ b/app/models/integration_collection_mappings/base_collection_mapping.rb @@ -9,16 +9,37 @@ class BaseCollectionMapping < ApplicationRecord belongs_to :integration, class_name: "Integrations::BaseIntegration" belongs_to :organization + belongs_to :billing_entity, optional: true MAPPING_TYPES = %i[ - fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit credit_note account + fallback_item + coupon + subscription_fee + minimum_commitment + tax + prepaid_credit + credit_note account ].freeze - enum :mapping_type, MAPPING_TYPES + enum :mapping_type, MAPPING_TYPES, validate: true - validates :mapping_type, uniqueness: {scope: :integration_id} + validates :mapping_type, presence: true + validates :mapping_type, + uniqueness: {scope: [:integration_id, :organization_id, :billing_entity_id]} + + validate :validate_billing_entity_organization settings_accessors :external_id, :external_account_code, :external_name + + private + + def validate_billing_entity_organization + return unless billing_entity + + if billing_entity.organization_id != organization_id + errors.add(:billing_entity, :invalid) + end + end end end @@ -26,23 +47,27 @@ class BaseCollectionMapping < ApplicationRecord # # Table name: integration_collection_mappings # -# id :uuid not null, primary key -# mapping_type :integer not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_int_collection_mappings_on_mapping_type_and_int_id (mapping_type,integration_id) UNIQUE -# index_integration_collection_mappings_on_integration_id (integration_id) -# index_integration_collection_mappings_on_organization_id (organization_id) +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/integration_collection_mappings/netsuite_collection_mapping.rb b/app/models/integration_collection_mappings/netsuite_collection_mapping.rb index ffaca377056..448fee9235f 100644 --- a/app/models/integration_collection_mappings/netsuite_collection_mapping.rb +++ b/app/models/integration_collection_mappings/netsuite_collection_mapping.rb @@ -10,23 +10,27 @@ class NetsuiteCollectionMapping < BaseCollectionMapping # # Table name: integration_collection_mappings # -# id :uuid not null, primary key -# mapping_type :integer not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_int_collection_mappings_on_mapping_type_and_int_id (mapping_type,integration_id) UNIQUE -# index_integration_collection_mappings_on_integration_id (integration_id) -# index_integration_collection_mappings_on_organization_id (organization_id) +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/integration_collection_mappings/xero_collection_mapping.rb b/app/models/integration_collection_mappings/xero_collection_mapping.rb index 6ec7750a7aa..945eb4ea22a 100644 --- a/app/models/integration_collection_mappings/xero_collection_mapping.rb +++ b/app/models/integration_collection_mappings/xero_collection_mapping.rb @@ -9,23 +9,27 @@ class XeroCollectionMapping < BaseCollectionMapping # # Table name: integration_collection_mappings # -# id :uuid not null, primary key -# mapping_type :integer not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_int_collection_mappings_on_mapping_type_and_int_id (mapping_type,integration_id) UNIQUE -# index_integration_collection_mappings_on_integration_id (integration_id) -# index_integration_collection_mappings_on_organization_id (organization_id) +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/integration_mappings/anrok_mapping.rb b/app/models/integration_mappings/anrok_mapping.rb index 3ddcaa74db4..b3f7009cfff 100644 --- a/app/models/integration_mappings/anrok_mapping.rb +++ b/app/models/integration_mappings/anrok_mapping.rb @@ -9,24 +9,28 @@ class AnrokMapping < BaseMapping # # Table name: integration_mappings # -# id :uuid not null, primary key -# mappable_type :string not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# mappable_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_integration_mappings_on_integration_id (integration_id) -# index_integration_mappings_on_mappable (mappable_type,mappable_id) -# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/integration_mappings/avalara_mapping.rb b/app/models/integration_mappings/avalara_mapping.rb index f58d6f096d2..fd37acace48 100644 --- a/app/models/integration_mappings/avalara_mapping.rb +++ b/app/models/integration_mappings/avalara_mapping.rb @@ -9,24 +9,28 @@ class AvalaraMapping < BaseMapping # # Table name: integration_mappings # -# id :uuid not null, primary key -# mappable_type :string not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# mappable_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_integration_mappings_on_integration_id (integration_id) -# index_integration_mappings_on_mappable (mappable_type,mappable_id) -# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/integration_mappings/base_mapping.rb b/app/models/integration_mappings/base_mapping.rb index 31539589d50..76469311397 100644 --- a/app/models/integration_mappings/base_mapping.rb +++ b/app/models/integration_mappings/base_mapping.rb @@ -10,10 +10,24 @@ class BaseMapping < ApplicationRecord belongs_to :integration, class_name: "Integrations::BaseIntegration" belongs_to :mappable, polymorphic: true belongs_to :organization + belongs_to :billing_entity, optional: true MAPPABLE_TYPES = %i[AddOn BillableMetric].freeze + validates :mappable_type, inclusion: {in: MAPPABLE_TYPES.map(&:to_s)} + validates :mappable_type, + uniqueness: {scope: [:mappable_id, :integration_id, :organization_id, :billing_entity_id]} + validate :validate_billing_entity_organization + settings_accessors :external_id, :external_account_code, :external_name + + private + + def validate_billing_entity_organization + return unless billing_entity + + errors.add(:billing_entity, "must belong to the same organization") if billing_entity.organization_id != organization_id + end end end @@ -21,24 +35,28 @@ class BaseMapping < ApplicationRecord # # Table name: integration_mappings # -# id :uuid not null, primary key -# mappable_type :string not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# mappable_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_integration_mappings_on_integration_id (integration_id) -# index_integration_mappings_on_mappable (mappable_type,mappable_id) -# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/integration_mappings/netsuite_mapping.rb b/app/models/integration_mappings/netsuite_mapping.rb index 3435a90791a..64c67ba3032 100644 --- a/app/models/integration_mappings/netsuite_mapping.rb +++ b/app/models/integration_mappings/netsuite_mapping.rb @@ -9,24 +9,28 @@ class NetsuiteMapping < BaseMapping # # Table name: integration_mappings # -# id :uuid not null, primary key -# mappable_type :string not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# mappable_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_integration_mappings_on_integration_id (integration_id) -# index_integration_mappings_on_mappable (mappable_type,mappable_id) -# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/integration_mappings/xero_mapping.rb b/app/models/integration_mappings/xero_mapping.rb index 0b28512ea85..12fba9e9014 100644 --- a/app/models/integration_mappings/xero_mapping.rb +++ b/app/models/integration_mappings/xero_mapping.rb @@ -9,24 +9,28 @@ class XeroMapping < BaseMapping # # Table name: integration_mappings # -# id :uuid not null, primary key -# mappable_type :string not null -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# integration_id :uuid not null -# mappable_id :uuid not null -# organization_id :uuid not null +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null # # Indexes # -# index_integration_mappings_on_integration_id (integration_id) -# index_integration_mappings_on_mappable (mappable_type,mappable_id) -# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) # # Foreign Keys # +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade # fk_rails_... (integration_id => integrations.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/services/fees/create_pay_in_advance_service.rb b/app/services/fees/create_pay_in_advance_service.rb index d4f00d54ea1..c043ee041d4 100644 --- a/app/services/fees/create_pay_in_advance_service.rb +++ b/app/services/fees/create_pay_in_advance_service.rb @@ -232,7 +232,7 @@ def apply_provider_taxes(fees_result) taxes_result end - FakeInvoice = Data.define(:id, :issuing_date, :currency, :customer) + FakeInvoice = Data.define(:id, :issuing_date, :currency, :customer, :billing_entity) def invoice result.invoice_id = SecureRandom.uuid @@ -241,7 +241,8 @@ def invoice id: result.invoice_id, issuing_date: Time.current.in_time_zone(customer.applicable_timezone).to_date, currency: subscription.plan.amount_currency, - customer: + customer:, + billing_entity: customer.billing_entity ) end diff --git a/app/services/integration_collection_mappings/create_service.rb b/app/services/integration_collection_mappings/create_service.rb index 174932e0ca5..9dd33ec27db 100644 --- a/app/services/integration_collection_mappings/create_service.rb +++ b/app/services/integration_collection_mappings/create_service.rb @@ -15,10 +15,16 @@ def call return result.not_found_failure!(resource: "integration") unless integration + if params[:billing_entity_id] + billing_entity = integration.organization.billing_entities.find_by(id: params[:billing_entity_id]) + return result.not_found_failure!(resource: "billing_entity") unless billing_entity + end + integration_collection_mapping = IntegrationCollectionMappings::Factory.new_instance(integration:).new( organization_id: params[:organization_id], integration_id: params[:integration_id], - mapping_type: params[:mapping_type] + mapping_type: params[:mapping_type], + billing_entity_id: params[:billing_entity_id] ) integration_collection_mapping.organization = integration.organization diff --git a/app/services/integration_mappings/create_service.rb b/app/services/integration_mappings/create_service.rb index 376d7bb4816..d0aea1c2d27 100644 --- a/app/services/integration_mappings/create_service.rb +++ b/app/services/integration_mappings/create_service.rb @@ -7,11 +7,17 @@ def call(**args) return result.not_found_failure!(resource: "integration") unless integration + if (billing_entity_id = args[:billing_entity_id]) + billing_entity = integration.organization.billing_entities.find_by(id: billing_entity_id) + return result.not_found_failure!(resource: "billing_entity") unless billing_entity + end + integration_mapping = IntegrationMappings::Factory.new_instance(integration:).new( organization_id: integration.organization_id, integration_id: args[:integration_id], mappable_id: args[:mappable_id], - mappable_type: args[:mappable_type] + mappable_type: args[:mappable_type], + billing_entity_id: billing_entity_id ) integration_mapping.external_id = args[:external_id] if args.key?(:external_id) diff --git a/app/services/integrations/aggregator/base_payload.rb b/app/services/integrations/aggregator/base_payload.rb index 58d76f33530..158254bcb01 100644 --- a/app/services/integrations/aggregator/base_payload.rb +++ b/app/services/integrations/aggregator/base_payload.rb @@ -13,52 +13,45 @@ def initialize(result, code:) end end - def initialize(integration:) + def initialize(integration:, billing_entity:) @integration = integration + @billing_entity = billing_entity end def billable_metric_item(fee) - integration - .integration_mappings - .find_by(mappable_type: "BillableMetric", mappable_id: fee.billable_metric.id) || fallback_item + lookup_mapping("BillableMetric", fee.billable_metric.id) end def add_on_item(fee) - integration - .integration_mappings - .find_by(mappable_type: "AddOn", mappable_id: fee.add_on_id) || fallback_item + lookup_mapping("AddOn", fee.add_on_id) end def account_item - @account_item ||= collection_mapping(:account) || fallback_item + lookup_collection_mapping(:account) end def tax_item - @tax_item ||= collection_mapping(:tax) + lookup_collection_mapping(:tax, with_fallback_item: false) end def commitment_item - @commitment_item ||= collection_mapping(:minimum_commitment) || fallback_item + lookup_collection_mapping(:minimum_commitment) end def subscription_item - @subscription_item ||= collection_mapping(:subscription_fee) || fallback_item + lookup_collection_mapping(:subscription_fee) end def coupon_item - @coupon_item ||= collection_mapping(:coupon) || fallback_item + lookup_collection_mapping(:coupon) end def credit_item - @credit_item ||= collection_mapping(:prepaid_credit) || fallback_item + lookup_collection_mapping(:prepaid_credit) end def credit_note_item - @credit_note_item ||= collection_mapping(:credit_note) || fallback_item - end - - def fallback_item - @fallback_item ||= collection_mapping(:fallback_item) + lookup_collection_mapping(:credit_note) end def amount(amount_cents, resource:) @@ -67,13 +60,46 @@ def amount(amount_cents, resource:) amount_cents.round.fdiv(currency.subunit_to_unit) end - def collection_mapping(type) - integration.integration_collection_mappings.where(mapping_type: type)&.first + private + + attr_reader :integration, :billing_entity + + def fallback_item(scope) + mappings = integration.integration_collection_mappings + fallback_items = mappings.filter { |mapping| mapping.mapping_type.to_sym == :fallback_item } + if scope == :billing_entity && billing_entity + return fallback_items.find { |mapping| mapping.billing_entity_id == billing_entity.id } + end + + fallback_items.find { |mapping| mapping.billing_entity_id.nil? } end - private + def lookup_collection_mapping(mapping_type, with_fallback_item: true) + mappings = integration.integration_collection_mappings + matching_mappings = mappings.filter { |mapping| mapping.mapping_type.to_sym == mapping_type.to_sym } + billing_entity_mapping = matching_mappings.find { |mapping| mapping.billing_entity_id == billing_entity.id } + organization_mapping = matching_mappings.find { |mapping| mapping.billing_entity_id.nil? } + if with_fallback_item + return billing_entity_mapping || + fallback_item(:billing_entity) || + organization_mapping || + fallback_item(:organization) + end - attr_reader :integration + billing_entity_mapping || + organization_mapping + end + + def lookup_mapping(mappable_type, mappable_id) + mappings = integration.integration_mappings + matching_mappings = mappings.filter { |mapping| mapping.mappable_type == mappable_type && mapping.mappable_id == mappable_id } + billing_entity_mapping = matching_mappings.find { |mapping| mapping.billing_entity_id == billing_entity.id } + organization_mapping = matching_mappings.find { |mapping| mapping.billing_entity_id.nil? } + billing_entity_mapping || + fallback_item(:billing_entity) || + organization_mapping || + fallback_item(:organization) + end def tax_item_complete? tax_item&.tax_nexus.present? && tax_item&.tax_type.present? && tax_item&.tax_code.present? diff --git a/app/services/integrations/aggregator/contacts/payloads/base_payload.rb b/app/services/integrations/aggregator/contacts/payloads/base_payload.rb index 2490a1ab336..ade7f05703a 100644 --- a/app/services/integrations/aggregator/contacts/payloads/base_payload.rb +++ b/app/services/integrations/aggregator/contacts/payloads/base_payload.rb @@ -6,7 +6,7 @@ module Contacts module Payloads class BasePayload < Integrations::Aggregator::BasePayload def initialize(integration:, customer:, integration_customer: nil, subsidiary_id: nil) - super(integration:) + super(integration:, billing_entity: customer.billing_entity) @customer = customer @integration_customer = integration_customer diff --git a/app/services/integrations/aggregator/credit_notes/payloads/base_payload.rb b/app/services/integrations/aggregator/credit_notes/payloads/base_payload.rb index ec537a31436..274ec8bc6db 100644 --- a/app/services/integrations/aggregator/credit_notes/payloads/base_payload.rb +++ b/app/services/integrations/aggregator/credit_notes/payloads/base_payload.rb @@ -6,7 +6,7 @@ module CreditNotes module Payloads class BasePayload < Integrations::Aggregator::BasePayload def initialize(integration_customer:, credit_note:) - super(integration: integration_customer.integration) + super(integration: integration_customer.integration, billing_entity: credit_note.customer.billing_entity) @credit_note = credit_note @integration_customer = integration_customer diff --git a/app/services/integrations/aggregator/invoices/payloads/base_payload.rb b/app/services/integrations/aggregator/invoices/payloads/base_payload.rb index 702a714e26a..01b9a67527d 100644 --- a/app/services/integrations/aggregator/invoices/payloads/base_payload.rb +++ b/app/services/integrations/aggregator/invoices/payloads/base_payload.rb @@ -6,7 +6,7 @@ module Invoices module Payloads class BasePayload < Integrations::Aggregator::BasePayload def initialize(integration_customer:, invoice:) - super(integration: integration_customer.integration) + super(integration: integration_customer.integration, billing_entity: integration_customer.customer.billing_entity) @invoice = invoice @integration_customer = integration_customer diff --git a/app/services/integrations/aggregator/payments/payloads/base_payload.rb b/app/services/integrations/aggregator/payments/payloads/base_payload.rb index 7c97e0f9938..f0c640c51b7 100644 --- a/app/services/integrations/aggregator/payments/payloads/base_payload.rb +++ b/app/services/integrations/aggregator/payments/payloads/base_payload.rb @@ -6,7 +6,7 @@ module Payments module Payloads class BasePayload < Integrations::Aggregator::BasePayload def initialize(integration:, payment:) - super(integration:) + super(integration:, billing_entity: payment.payable.customer.billing_entity) @payment = payment end diff --git a/app/services/integrations/aggregator/subscriptions/payloads/base_payload.rb b/app/services/integrations/aggregator/subscriptions/payloads/base_payload.rb index dc1b480b5b9..340b73f6092 100644 --- a/app/services/integrations/aggregator/subscriptions/payloads/base_payload.rb +++ b/app/services/integrations/aggregator/subscriptions/payloads/base_payload.rb @@ -6,7 +6,7 @@ module Subscriptions module Payloads class BasePayload < Integrations::Aggregator::BasePayload def initialize(integration_customer:, subscription:) - super(integration: integration_customer.integration) + super(integration: integration_customer.integration, billing_entity: subscription.customer.billing_entity) @subscription = subscription @integration_customer = integration_customer diff --git a/app/services/integrations/aggregator/taxes/credit_notes/payloads/anrok.rb b/app/services/integrations/aggregator/taxes/credit_notes/payloads/anrok.rb index 3464cb82d38..e4585c1f93d 100644 --- a/app/services/integrations/aggregator/taxes/credit_notes/payloads/anrok.rb +++ b/app/services/integrations/aggregator/taxes/credit_notes/payloads/anrok.rb @@ -7,7 +7,7 @@ module CreditNotes module Payloads class Anrok < BasePayload def initialize(integration:, customer:, integration_customer:, credit_note:) - super(integration:) + super(integration:, billing_entity: customer.billing_entity) @customer = customer @integration_customer = integration_customer diff --git a/app/services/integrations/aggregator/taxes/credit_notes/payloads/avalara.rb b/app/services/integrations/aggregator/taxes/credit_notes/payloads/avalara.rb index cd550d85b63..14c0bf5ef3a 100644 --- a/app/services/integrations/aggregator/taxes/credit_notes/payloads/avalara.rb +++ b/app/services/integrations/aggregator/taxes/credit_notes/payloads/avalara.rb @@ -7,7 +7,7 @@ module CreditNotes module Payloads class Avalara < BasePayload def initialize(integration:, customer:, integration_customer:, credit_note:) - super(integration:) + super(integration:, billing_entity: customer.billing_entity) @customer = customer @integration_customer = integration_customer diff --git a/app/services/integrations/aggregator/taxes/invoices/payloads/anrok.rb b/app/services/integrations/aggregator/taxes/invoices/payloads/anrok.rb index b0a949e2e19..f14854404f7 100644 --- a/app/services/integrations/aggregator/taxes/invoices/payloads/anrok.rb +++ b/app/services/integrations/aggregator/taxes/invoices/payloads/anrok.rb @@ -7,7 +7,7 @@ module Invoices module Payloads class Anrok < BasePayload def initialize(integration:, customer:, invoice:, integration_customer:, fees: []) - super(integration:) + super(integration:, billing_entity: customer.billing_entity) @customer = customer @integration_customer = integration_customer diff --git a/app/services/integrations/aggregator/taxes/invoices/payloads/avalara.rb b/app/services/integrations/aggregator/taxes/invoices/payloads/avalara.rb index 11f5099894b..708e17b91f3 100644 --- a/app/services/integrations/aggregator/taxes/invoices/payloads/avalara.rb +++ b/app/services/integrations/aggregator/taxes/invoices/payloads/avalara.rb @@ -7,13 +7,12 @@ module Invoices module Payloads class Avalara < BasePayload def initialize(integration:, customer:, invoice:, integration_customer:, fees: []) - super(integration:) + super(integration:, billing_entity: customer.billing_entity) @customer = customer @integration_customer = integration_customer @invoice = invoice @fees = fees - @billing_entity = customer.billing_entity end def body @@ -67,7 +66,7 @@ def fee_item(fee) private - attr_reader :customer, :integration_customer, :invoice, :fees, :billing_entity + attr_reader :customer, :integration_customer, :invoice, :fees def empty_struct @empty_struct ||= OpenStruct.new diff --git a/db/migrate/20251003171653_add_billing_entity_to_integration_collection_mappings.rb b/db/migrate/20251003171653_add_billing_entity_to_integration_collection_mappings.rb new file mode 100644 index 00000000000..844b6725106 --- /dev/null +++ b/db/migrate/20251003171653_add_billing_entity_to_integration_collection_mappings.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class AddBillingEntityToIntegrationCollectionMappings < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :integration_collection_mappings, + :billing_entity, + type: :uuid, + null: true, + index: {algorithm: :concurrently} + + add_foreign_key :integration_collection_mappings, :billing_entities, on_delete: :cascade, validate: false + add_index :integration_collection_mappings, + [:mapping_type, :integration_id, :billing_entity_id], + where: "billing_entity_id IS NOT NULL", + unique: true, + algorithm: :concurrently, + name: "index_int_collection_mappings_unique_billing_entity_is_not_null" + add_index :integration_collection_mappings, + [:mapping_type, :integration_id, :organization_id], + where: "billing_entity_id IS NULL", + unique: true, + algorithm: :concurrently, + name: "index_int_collection_mappings_unique_billing_entity_is_null" + remove_index :integration_collection_mappings, [:mapping_type, :integration_id], name: "index_int_collection_mappings_on_mapping_type_and_int_id" + end +end diff --git a/db/migrate/20251003171658_validate_billing_entity_foreign_key_on_integration_collection_mappings.rb b/db/migrate/20251003171658_validate_billing_entity_foreign_key_on_integration_collection_mappings.rb new file mode 100644 index 00000000000..e4fbe8dea16 --- /dev/null +++ b/db/migrate/20251003171658_validate_billing_entity_foreign_key_on_integration_collection_mappings.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateBillingEntityForeignKeyOnIntegrationCollectionMappings < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :integration_collection_mappings, :billing_entities + end +end diff --git a/db/migrate/20251007082809_add_billing_entity_to_integration_mappings.rb b/db/migrate/20251007082809_add_billing_entity_to_integration_mappings.rb new file mode 100644 index 00000000000..9f30cc158a5 --- /dev/null +++ b/db/migrate/20251007082809_add_billing_entity_to_integration_mappings.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddBillingEntityToIntegrationMappings < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :integration_mappings, :billing_entity_id, :uuid, null: true + + add_foreign_key :integration_mappings, :billing_entities, on_delete: :cascade, validate: false + + # Add unique indexes for billing entity mappings and organization-wide mappings + add_index :integration_mappings, [:mappable_type, :mappable_id, :integration_id, :billing_entity_id], + where: "billing_entity_id IS NOT NULL", + unique: true, + algorithm: :concurrently, + name: "index_integration_mappings_unique_billing_entity_id_is_not_null" + + add_index :integration_mappings, [:mappable_type, :mappable_id, :integration_id, :organization_id], + where: "billing_entity_id IS NULL", + unique: true, + algorithm: :concurrently, + name: "index_integration_mappings_unique_billing_entity_id_is_null" + end +end diff --git a/db/migrate/20251007082822_validate_billing_entity_foreign_key_on_integration_mappings.rb b/db/migrate/20251007082822_validate_billing_entity_foreign_key_on_integration_mappings.rb new file mode 100644 index 00000000000..0b9ef86f644 --- /dev/null +++ b/db/migrate/20251007082822_validate_billing_entity_foreign_key_on_integration_mappings.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateBillingEntityForeignKeyOnIntegrationMappings < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :integration_mappings, :billing_entities + end +end diff --git a/db/structure.sql b/db/structure.sql index ffde422d8b6..4be2cfa1da7 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -32,6 +32,7 @@ ALTER TABLE IF EXISTS ONLY public.plans_taxes DROP CONSTRAINT IF EXISTS fk_rails ALTER TABLE IF EXISTS ONLY public.customers_taxes DROP CONSTRAINT IF EXISTS fk_rails_e86903e081; ALTER TABLE IF EXISTS ONLY public.subscriptions DROP CONSTRAINT IF EXISTS fk_rails_e744efbe51; ALTER TABLE IF EXISTS ONLY public.charge_filters DROP CONSTRAINT IF EXISTS fk_rails_e711e8089e; +ALTER TABLE IF EXISTS ONLY public.integration_mappings DROP CONSTRAINT IF EXISTS fk_rails_e4a58fbcac; ALTER TABLE IF EXISTS ONLY public.usage_monitoring_triggered_alerts DROP CONSTRAINT IF EXISTS fk_rails_e3cf54daac; ALTER TABLE IF EXISTS ONLY public.integration_collection_mappings DROP CONSTRAINT IF EXISTS fk_rails_e148d17c1f; ALTER TABLE IF EXISTS ONLY public.customer_metadata DROP CONSTRAINT IF EXISTS fk_rails_dfac602b2c; @@ -148,6 +149,7 @@ ALTER TABLE IF EXISTS ONLY public.integration_resources DROP CONSTRAINT IF EXIST ALTER TABLE IF EXISTS ONLY public.subscriptions DROP CONSTRAINT IF EXISTS fk_rails_66eb6b32c1; ALTER TABLE IF EXISTS ONLY public.fixed_charges_taxes DROP CONSTRAINT IF EXISTS fk_rails_665ae33492; ALTER TABLE IF EXISTS ONLY public.billing_entities_taxes DROP CONSTRAINT IF EXISTS fk_rails_651eadaaa4; +ALTER TABLE IF EXISTS ONLY public.integration_collection_mappings DROP CONSTRAINT IF EXISTS fk_rails_650fccfc41; ALTER TABLE IF EXISTS ONLY public.memberships DROP CONSTRAINT IF EXISTS fk_rails_64267aab58; ALTER TABLE IF EXISTS ONLY public.subscriptions DROP CONSTRAINT IF EXISTS fk_rails_63d3df128b; ALTER TABLE IF EXISTS ONLY public.pricing_unit_usages DROP CONSTRAINT IF EXISTS fk_rails_63ca8e33c5; @@ -439,6 +441,8 @@ DROP INDEX IF EXISTS public.index_integrations_on_code_and_organization_id; DROP INDEX IF EXISTS public.index_integration_resources_on_syncable; DROP INDEX IF EXISTS public.index_integration_resources_on_organization_id; DROP INDEX IF EXISTS public.index_integration_resources_on_integration_id; +DROP INDEX IF EXISTS public.index_integration_mappings_unique_billing_entity_id_is_null; +DROP INDEX IF EXISTS public.index_integration_mappings_unique_billing_entity_id_is_not_null; DROP INDEX IF EXISTS public.index_integration_mappings_on_organization_id; DROP INDEX IF EXISTS public.index_integration_mappings_on_mappable; DROP INDEX IF EXISTS public.index_integration_mappings_on_integration_id; @@ -451,8 +455,10 @@ DROP INDEX IF EXISTS public.index_integration_customers_on_customer_id_and_type; DROP INDEX IF EXISTS public.index_integration_customers_on_customer_id; DROP INDEX IF EXISTS public.index_integration_collection_mappings_on_organization_id; DROP INDEX IF EXISTS public.index_integration_collection_mappings_on_integration_id; +DROP INDEX IF EXISTS public.index_integration_collection_mappings_on_billing_entity_id; DROP INDEX IF EXISTS public.index_int_items_on_external_id_and_int_id_and_type; -DROP INDEX IF EXISTS public.index_int_collection_mappings_on_mapping_type_and_int_id; +DROP INDEX IF EXISTS public.index_int_collection_mappings_unique_billing_entity_is_null; +DROP INDEX IF EXISTS public.index_int_collection_mappings_unique_billing_entity_is_not_null; DROP INDEX IF EXISTS public.index_inbound_webhooks_on_status_and_processing_at; DROP INDEX IF EXISTS public.index_inbound_webhooks_on_status_and_created_at; DROP INDEX IF EXISTS public.index_inbound_webhooks_on_organization_id; @@ -3591,7 +3597,8 @@ CREATE TABLE public.integration_collection_mappings ( settings jsonb DEFAULT '{}'::jsonb NOT NULL, created_at timestamp(6) without time zone NOT NULL, updated_at timestamp(6) without time zone NOT NULL, - organization_id uuid NOT NULL + organization_id uuid NOT NULL, + billing_entity_id uuid ); @@ -3642,7 +3649,8 @@ CREATE TABLE public.integration_mappings ( settings jsonb DEFAULT '{}'::jsonb NOT NULL, created_at timestamp(6) without time zone NOT NULL, updated_at timestamp(6) without time zone NOT NULL, - organization_id uuid NOT NULL + organization_id uuid NOT NULL, + billing_entity_id uuid ); @@ -6837,10 +6845,17 @@ CREATE INDEX index_inbound_webhooks_on_status_and_processing_at ON public.inboun -- --- Name: index_int_collection_mappings_on_mapping_type_and_int_id; Type: INDEX; Schema: public; Owner: - +-- Name: index_int_collection_mappings_unique_billing_entity_is_not_null; Type: INDEX; Schema: public; Owner: - -- -CREATE UNIQUE INDEX index_int_collection_mappings_on_mapping_type_and_int_id ON public.integration_collection_mappings USING btree (mapping_type, integration_id); +CREATE UNIQUE INDEX index_int_collection_mappings_unique_billing_entity_is_not_null ON public.integration_collection_mappings USING btree (mapping_type, integration_id, billing_entity_id) WHERE (billing_entity_id IS NOT NULL); + + +-- +-- Name: index_int_collection_mappings_unique_billing_entity_is_null; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_int_collection_mappings_unique_billing_entity_is_null ON public.integration_collection_mappings USING btree (mapping_type, integration_id, organization_id) WHERE (billing_entity_id IS NULL); -- @@ -6850,6 +6865,13 @@ CREATE UNIQUE INDEX index_int_collection_mappings_on_mapping_type_and_int_id ON CREATE UNIQUE INDEX index_int_items_on_external_id_and_int_id_and_type ON public.integration_items USING btree (external_id, integration_id, item_type); +-- +-- Name: index_integration_collection_mappings_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_collection_mappings_on_billing_entity_id ON public.integration_collection_mappings USING btree (billing_entity_id); + + -- -- Name: index_integration_collection_mappings_on_integration_id; Type: INDEX; Schema: public; Owner: - -- @@ -6934,6 +6956,20 @@ CREATE INDEX index_integration_mappings_on_mappable ON public.integration_mappin CREATE INDEX index_integration_mappings_on_organization_id ON public.integration_mappings USING btree (organization_id); +-- +-- Name: index_integration_mappings_unique_billing_entity_id_is_not_null; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_integration_mappings_unique_billing_entity_id_is_not_null ON public.integration_mappings USING btree (mappable_type, mappable_id, integration_id, billing_entity_id) WHERE (billing_entity_id IS NOT NULL); + + +-- +-- Name: index_integration_mappings_unique_billing_entity_id_is_null; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_integration_mappings_unique_billing_entity_id_is_null ON public.integration_mappings USING btree (mappable_type, mappable_id, integration_id, organization_id) WHERE (billing_entity_id IS NULL); + + -- -- Name: index_integration_resources_on_integration_id; Type: INDEX; Schema: public; Owner: - -- @@ -8959,6 +8995,14 @@ ALTER TABLE ONLY public.memberships ADD CONSTRAINT fk_rails_64267aab58 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); +-- +-- Name: integration_collection_mappings fk_rails_650fccfc41; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_collection_mappings + ADD CONSTRAINT fk_rails_650fccfc41 FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id) ON DELETE CASCADE; + + -- -- Name: billing_entities_taxes fk_rails_651eadaaa4; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -9887,6 +9931,14 @@ ALTER TABLE ONLY public.usage_monitoring_triggered_alerts ADD CONSTRAINT fk_rails_e3cf54daac FOREIGN KEY (usage_monitoring_alert_id) REFERENCES public.usage_monitoring_alerts(id); +-- +-- Name: integration_mappings fk_rails_e4a58fbcac; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_mappings + ADD CONSTRAINT fk_rails_e4a58fbcac FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id) ON DELETE CASCADE; + + -- -- Name: charge_filters fk_rails_e711e8089e; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -10098,6 +10150,10 @@ INSERT INTO "schema_migrations" (version) VALUES ('20251010092830'), ('20251010073504'), ('20251007160309'), +('20251007082822'), +('20251007082809'), +('20251003171658'), +('20251003171653'), ('20250926185510'), ('20250919124523'), ('20250919124037'), diff --git a/schema.graphql b/schema.graphql index 6d048753c9f..41abbeb8e4e 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1020,6 +1020,7 @@ type ChargeUsage { } type CollectionMapping { + billingEntityId: ID externalAccountCode: String externalId: String! externalName: String @@ -2821,6 +2822,8 @@ input CreateHubspotIntegrationInput { Autogenerated input type of CreateIntegrationCollectionMapping """ input CreateIntegrationCollectionMappingInput { + billingEntityId: ID + """ A unique identifier for the client performing the mutation. """ @@ -2839,6 +2842,8 @@ input CreateIntegrationCollectionMappingInput { Autogenerated input type of CreateIntegrationMapping """ input CreateIntegrationMappingInput { + billingEntityId: ID + """ A unique identifier for the client performing the mutation. """ @@ -6155,6 +6160,7 @@ enum MappableTypeEnum { } type Mapping { + billingEntityId: ID externalAccountCode: String externalId: String! externalName: String diff --git a/schema.json b/schema.json index 370d56033c3..1d73264c2bb 100644 --- a/schema.json +++ b/schema.json @@ -6497,6 +6497,18 @@ "name": "CollectionMapping", "description": null, "fields": [ + { + "name": "billingEntityId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "externalAccountCode", "description": null, @@ -11941,6 +11953,18 @@ "description": "Autogenerated input type of CreateIntegrationCollectionMapping", "fields": null, "inputFields": [ + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "externalAccountCode", "description": null, @@ -12072,6 +12096,18 @@ "description": "Autogenerated input type of CreateIntegrationMapping", "fields": null, "inputFields": [ + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "externalAccountCode", "description": null, @@ -31925,6 +31961,18 @@ "name": "Mapping", "description": null, "fields": [ + { + "name": "billingEntityId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "externalAccountCode", "description": null, diff --git a/spec/factories/fees.rb b/spec/factories/fees.rb index 4e466b6ca37..cff198340e9 100644 --- a/spec/factories/fees.rb +++ b/spec/factories/fees.rb @@ -100,7 +100,7 @@ factory :minimum_commitment_fee, class: "Fee" do invoice fee_type { "commitment" } - subscription + subscription { invoice&.subscriptions&.first || association(:subscription) } organization { invoice&.organization || association(:organization) } billing_entity { invoice&.billing_entity || association(:billing_entity) } diff --git a/spec/factories/integration_collection_mappings.rb b/spec/factories/integration_collection_mappings.rb index 716ceb9b9c0..64f5df80a3c 100644 --- a/spec/factories/integration_collection_mappings.rb +++ b/spec/factories/integration_collection_mappings.rb @@ -5,6 +5,7 @@ association :integration, factory: :netsuite_integration mapping_type { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit].sample } organization { integration&.organization || association(:organization) } + billing_entity { nil } settings do { @@ -22,6 +23,7 @@ association :integration, factory: :xero_integration mapping_type { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit account].sample } organization { integration&.organization || association(:organization) } + billing_entity { nil } settings do { @@ -31,4 +33,34 @@ } end end + + factory :anrok_collection_mapping, class: "IntegrationCollectionMappings::AnrokCollectionMapping" do + association :integration, factory: :anrok_integration + mapping_type { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit account].sample } + organization { integration&.organization || association(:organization) } + billing_entity { nil } + + settings do + { + external_id: "anrok-123", + external_account_code: "anrok-code-1", + external_name: "Credits and Discounts" + } + end + end + + factory :avalara_collection_mapping, class: "IntegrationCollectionMappings::AvalaraCollectionMapping" do + association :integration, factory: :avalara_integration + mapping_type { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit account].sample } + organization { integration&.organization || association(:organization) } + billing_entity { nil } + + settings do + { + external_id: "avalara-123", + external_account_code: "avalara-code-1", + external_name: "Credits and Discounts" + } + end + end end diff --git a/spec/factories/integration_mappings.rb b/spec/factories/integration_mappings.rb index 45b543e084f..59216b9f168 100644 --- a/spec/factories/integration_mappings.rb +++ b/spec/factories/integration_mappings.rb @@ -1,31 +1,25 @@ # frozen_string_literal: true FactoryBot.define do - factory :netsuite_mapping, class: "IntegrationMappings::NetsuiteMapping" do - association :integration, factory: :netsuite_integration - association :mappable, factory: :add_on - organization { integration&.organization || association(:organization) } + [ + :netsuite, + :xero, + :anrok, + :avalara + ].each do |integration_type| + factory "#{integration_type}_mapping", class: "IntegrationMappings::#{integration_type.to_s.classify}Mapping" do + association :integration, factory: "#{integration_type}_integration" + association :mappable, factory: :add_on + organization { integration&.organization || association(:organization) } + billing_entity { nil } - settings do - { - external_id: "netsuite-123", - external_account_code: "netsuite-code-1", - external_name: "Credits and Discounts" - } - end - end - - factory :xero_mapping, class: "IntegrationMappings::XeroMapping" do - association :integration, factory: :xero_integration - association :mappable, factory: :add_on - organization { integration&.organization || association(:organization) } - - settings do - { - external_id: "xero-123", - external_account_code: "xero-code-1", - external_name: "Credits and Discounts" - } + settings do + { + external_id: "#{integration_type}-123", + external_account_code: "#{integration_type}-code-1", + external_name: "Credits and Discounts" + } + end end end end diff --git a/spec/factories/invoices.rb b/spec/factories/invoices.rb index 58c21ae32ac..3d2cd4fbfef 100644 --- a/spec/factories/invoices.rb +++ b/spec/factories/invoices.rb @@ -54,7 +54,7 @@ trait :with_subscriptions do transient do - subscriptions { [create(:subscription)] } + subscriptions { [create(:subscription, organization:)] } end after :create do |invoice, evaluator| diff --git a/spec/graphql/mutations/integration_collection_mappings/create_spec.rb b/spec/graphql/mutations/integration_collection_mappings/create_spec.rb index c7cd64f56c9..304d14e5d46 100644 --- a/spec/graphql/mutations/integration_collection_mappings/create_spec.rb +++ b/spec/graphql/mutations/integration_collection_mappings/create_spec.rb @@ -26,37 +26,65 @@ } GQL end + let(:input) do + { + integrationId: integration.id, + mappingType: mapping_type, + externalAccountCode: external_account_code, + externalId: external_id, + externalName: external_name, + **(billing_entity_id ? {billingEntityId: billing_entity_id} : {}) + } + end + let(:billing_entity_id) { nil } + + def create_integration_collection_mapping(input:, raw: false) + result = execute_query(query: mutation, input:) + raw ? result : result["data"]["createIntegrationCollectionMapping"] + end it_behaves_like "requires current user" it_behaves_like "requires current organization" it_behaves_like "requires permission", "organization:integrations:update" it "creates an integration collection mapping" do - result = execute_graphql( - current_user: membership.user, - current_organization: membership.organization, - permissions: required_permission, - query: mutation, - variables: { - input: { - integrationId: integration.id, - mappingType: mapping_type, - externalAccountCode: external_account_code, - externalId: external_id, - externalName: external_name - } - } + result = create_integration_collection_mapping(input:) + + expect(result).to match( + "id" => be_present, + "integrationId" => integration.id, + "mappingType" => mapping_type, + "externalAccountCode" => external_account_code, + "externalId" => external_id, + "externalName" => external_name ) + end + + context "with billing entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:billing_entity_id) { billing_entity.id } + + it "creates an integration collection mapping with billing entity" do + result = create_integration_collection_mapping(input:) + + expect(result).to match( + "id" => be_present, + "integrationId" => integration.id, + "mappingType" => mapping_type, + "externalAccountCode" => external_account_code, + "externalId" => external_id, + "externalName" => external_name + ) + end + + context "when billing entity belongs to different organization" do + let(:billing_entity) { create(:billing_entity, organization: create(:organization)) } - result_data = result["data"]["createIntegrationCollectionMapping"] + it "returns an error when billing entity belongs to different organization" do + result = create_integration_collection_mapping(input:, raw: true) - aggregate_failures do - expect(result_data["id"]).to be_present - expect(result_data["integrationId"]).to eq(integration.id) - expect(result_data["mappingType"]).to eq(mapping_type) - expect(result_data["externalAccountCode"]).to eq(external_account_code) - expect(result_data["externalId"]).to eq(external_id) - expect(result_data["externalName"]).to eq(external_name) + expect_graphql_error(result:, message: "Resource not found") + end end end end diff --git a/spec/graphql/mutations/integration_mappings/create_spec.rb b/spec/graphql/mutations/integration_mappings/create_spec.rb index 9ecdc49eb9d..ae5028e1a90 100644 --- a/spec/graphql/mutations/integration_mappings/create_spec.rb +++ b/spec/graphql/mutations/integration_mappings/create_spec.rb @@ -11,7 +11,6 @@ let(:external_account_code) { Faker::Barcode.ean } let(:external_id) { SecureRandom.uuid } let(:external_name) { Faker::Commerce.department } - let(:mutation) do <<-GQL mutation($input: CreateIntegrationMappingInput!) { @@ -20,6 +19,7 @@ integrationId, mappableId, mappableType, + billingEntityId, externalAccountCode, externalId, externalName @@ -27,39 +27,70 @@ } GQL end + let(:input) do + { + integrationId: integration.id, + mappableId: mappable.id, + mappableType: "AddOn", + externalAccountCode: external_account_code, + externalId: external_id, + externalName: external_name, + **(billing_entity_id ? {billingEntityId: billing_entity_id} : {}) + } + end + let(:billing_entity_id) { nil } + + def create_integration_mapping(input:, raw: false) + result = execute_query(query: mutation, input:) + raw ? result : result["data"]["createIntegrationMapping"] + end it_behaves_like "requires current user" it_behaves_like "requires current organization" it_behaves_like "requires permission", "organization:integrations:update" it "creates an integration mapping" do - result = execute_graphql( - current_user: membership.user, - current_organization: membership.organization, - permissions: required_permission, - query: mutation, - variables: { - input: { - integrationId: integration.id, - mappableId: mappable.id, - mappableType: "AddOn", - externalAccountCode: external_account_code, - externalId: external_id, - externalName: external_name - } - } + result = create_integration_mapping(input:) + + expect(result).to match( + "id" => be_present, + "integrationId" => integration.id, + "mappableId" => mappable.id, + "mappableType" => "AddOn", + "billingEntityId" => nil, + "externalAccountCode" => external_account_code, + "externalId" => external_id, + "externalName" => external_name ) + end + + context "with billing entity" do + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:billing_entity_id) { billing_entity.id } + + it "creates an integration mapping with billing entity" do + result = create_integration_mapping(input:) + + expect(result).to match( + "id" => be_present, + "integrationId" => integration.id, + "mappableId" => mappable.id, + "mappableType" => "AddOn", + "billingEntityId" => billing_entity.id, + "externalAccountCode" => external_account_code, + "externalId" => external_id, + "externalName" => external_name + ) + end + + context "when billing entity belongs to different organization" do + let(:billing_entity) { create(:billing_entity, organization: create(:organization)) } - result_data = result["data"]["createIntegrationMapping"] + it "returns an error when billing entity belongs to different organization" do + result = create_integration_mapping(input:, raw: true) - aggregate_failures do - expect(result_data["id"]).to be_present - expect(result_data["integrationId"]).to eq(integration.id) - expect(result_data["mappableId"]).to eq(mappable.id) - expect(result_data["mappableType"]).to eq("AddOn") - expect(result_data["externalAccountCode"]).to eq(external_account_code) - expect(result_data["externalId"]).to eq(external_id) - expect(result_data["externalName"]).to eq(external_name) + expect_graphql_error(result:, message: "Resource not found") + end end end end diff --git a/spec/graphql/types/integration_collection_mappings/create_input_spec.rb b/spec/graphql/types/integration_collection_mappings/create_input_spec.rb index a8702ce228b..04c642a6af7 100644 --- a/spec/graphql/types/integration_collection_mappings/create_input_spec.rb +++ b/spec/graphql/types/integration_collection_mappings/create_input_spec.rb @@ -6,6 +6,7 @@ subject { described_class } it do + expect(subject).to accept_argument(:billing_entity_id).of_type("ID") expect(subject).to accept_argument(:integration_id).of_type("ID!") expect(subject).to accept_argument(:mapping_type).of_type("MappingTypeEnum!") expect(subject).to accept_argument(:external_account_code).of_type("String") diff --git a/spec/graphql/types/integration_collection_mappings/object_spec.rb b/spec/graphql/types/integration_collection_mappings/object_spec.rb index ab4d6e521fe..827ab5869f9 100644 --- a/spec/graphql/types/integration_collection_mappings/object_spec.rb +++ b/spec/graphql/types/integration_collection_mappings/object_spec.rb @@ -6,6 +6,7 @@ subject { described_class } it do + expect(subject).to have_field(:billing_entity_id).of_type("ID") expect(subject).to have_field(:id).of_type("ID!") expect(subject).to have_field(:integration_id).of_type("ID!") expect(subject).to have_field(:mapping_type).of_type("MappingTypeEnum!") diff --git a/spec/models/billing_entity_spec.rb b/spec/models/billing_entity_spec.rb index 7939e38f458..a9ce86c630e 100644 --- a/spec/models/billing_entity_spec.rb +++ b/spec/models/billing_entity_spec.rb @@ -8,18 +8,20 @@ it_behaves_like "paper_trail traceable" it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:applied_dunning_campaign).class_name("DunningCampaign").optional } it { is_expected.to have_many(:customers) } it { is_expected.to have_many(:invoices) } it { is_expected.to have_many(:fees) } it { is_expected.to have_many(:payment_receipts) } + it { is_expected.to have_many(:applied_invoice_custom_sections).class_name("BillingEntity::AppliedInvoiceCustomSection").dependent(:destroy) } + it { is_expected.to have_many(:integration_collection_mappings).class_name("IntegrationCollectionMappings::BaseCollectionMapping").dependent(:destroy) } + it { is_expected.to have_many(:integration_mappings).class_name("IntegrationMappings::BaseMapping").dependent(:destroy) } + it { is_expected.to have_many(:subscriptions).through(:customers) } it { is_expected.to have_many(:wallets).through(:customers) } it { is_expected.to have_many(:wallet_transactions).through(:wallets) } it { is_expected.to have_many(:credit_notes).through(:invoices) } - it { is_expected.to belong_to(:applied_dunning_campaign).class_name("DunningCampaign").optional } - - it { is_expected.to have_many(:applied_invoice_custom_sections).class_name("BillingEntity::AppliedInvoiceCustomSection").dependent(:destroy) } it { is_expected.to have_many(:selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) } it { is_expected.to have_many(:manual_selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) } it { is_expected.to have_many(:system_generated_selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) } diff --git a/spec/models/integration_collection_mappings/base_collection_mapping_spec.rb b/spec/models/integration_collection_mappings/base_collection_mapping_spec.rb index 6cc2edd60de..054b2530f9d 100644 --- a/spec/models/integration_collection_mappings/base_collection_mapping_spec.rb +++ b/spec/models/integration_collection_mappings/base_collection_mapping_spec.rb @@ -13,35 +13,110 @@ it { is_expected.to belong_to(:integration) } it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:billing_entity).optional } - it { is_expected.to define_enum_for(:mapping_type).with_values(mapping_types) } + it { is_expected.to define_enum_for(:mapping_type).with_values(mapping_types).validating } describe "validations" do describe "of mapping type uniqueness" do - let(:errors) { mapping.errors } let(:mapping_type) { :fallback_item } - let(:type) { "IntegrationCollectionMappings::NetsuiteCollectionMapping" } + let(:organization) { create(:organization) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:other_integration) { create(:netsuite_integration, organization: organization) } + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:other_billing_entity) { create(:billing_entity, organization: organization) } - context "when it is unique in scope of integration" do - it "does not add an error" do - expect(errors.where(:mapping_type, :taken)).not_to be_present + context "when billing entity is nil" do + subject(:mapping) do + build(:netsuite_collection_mapping, mapping_type:, organization:, integration:) + end + + context "when it is unique in scope of integration" do + before do + create(:netsuite_collection_mapping, mapping_type: :coupon, organization:, integration:) + create(:netsuite_collection_mapping, organization:, integration: other_integration) + create(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity: other_billing_entity, integration:) + end + + it "does not add an error" do + expect(mapping).to be_valid + end + end + + context "when it is not unique in scope of integration and billing entity" do + before do + create(:netsuite_collection_mapping, mapping_type:, organization: integration.organization, integration:) + end + + it "adds an error" do + expect(mapping).not_to be_valid + expect(mapping.errors.where(:mapping_type, :taken)).to be_present + + expect { mapping.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique, /duplicate key value violates unique constraint "index_int_collection_mappings_unique_billing_entity_is_null"/) + end end end - context "when it is not unique in scope of integration" do + context "when billing entity is not nil" do subject(:mapping) do - described_class.new(integration:, type:, mapping_type:, organization: integration.organization) + build(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity:, integration:) + end + + context "when it is unique in scope of integration and billing entity" do + before do + create(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity: nil, integration:) + create(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity: other_billing_entity, integration:) + create(:netsuite_collection_mapping, mapping_type: :coupon, organization:, billing_entity:, integration:) + create(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity:, integration: other_integration) + end + + it "does not add an error" do + expect(mapping).to be_valid + end + end + + context "when it is not unique in scope of integration and billing entity" do + before do + create(:netsuite_collection_mapping, mapping_type:, organization: integration.organization, billing_entity:, integration:) + end + + it "adds an error" do + expect(mapping).not_to be_valid + expect(mapping.errors.where(:mapping_type, :taken)).to be_present + + expect { mapping.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique, /duplicate key value violates unique constraint "index_int_collection_mappings_unique_billing_entity_is_not_null"/) + end + end + end + end + + describe "billing entity organization validation" do + subject(:mapping) { build(:netsuite_collection_mapping, integration:, billing_entity:) } + + let(:integration) { create(:netsuite_integration) } + + context "when billing entity belongs to the same organization" do + let(:billing_entity) { create(:billing_entity, organization: integration.organization) } + + it "is valid" do + expect(mapping).to be_valid end + end - let(:integration) { create(:netsuite_integration) } + context "when billing entity belongs to a different organization" do + let(:billing_entity) { create(:billing_entity) } - before do - described_class.create(integration:, type:, mapping_type:, organization: integration.organization) - mapping.valid? + it "is not valid" do + expect(mapping).not_to be_valid + expect(mapping.errors[:billing_entity]).to include("is invalid") end + end + + context "when billing entity is nil" do + let(:billing_entity) { nil } - it "adds an error" do - expect(errors.where(:mapping_type, :taken)).to be_present + it "is valid" do + expect(mapping).to be_valid end end end diff --git a/spec/models/integration_mappings/base_mapping_spec.rb b/spec/models/integration_mappings/base_mapping_spec.rb index abe71ee0107..1217fe45376 100644 --- a/spec/models/integration_mappings/base_mapping_spec.rb +++ b/spec/models/integration_mappings/base_mapping_spec.rb @@ -7,8 +7,142 @@ it_behaves_like "paper_trail traceable" - it { is_expected.to belong_to(:integration) } - it { is_expected.to belong_to(:organization) } + describe "associations" do + it { is_expected.to belong_to(:integration) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:mappable) } + it { is_expected.to belong_to(:billing_entity).optional } + end + + describe "validations" do + it { is_expected.to validate_inclusion_of(:mappable_type).in_array(%w[AddOn BillableMetric]) } + + describe "uniqueness validations" do + let(:mapping) do + build(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity:) + end + let(:organization) { create(:organization) } + let(:integration) { create(:netsuite_integration, organization: organization) } + let(:add_on) { create(:add_on, organization: organization) } + let(:other_add_on) { create(:add_on, organization: organization) } + let(:billable_metric) { create(:billable_metric, id: add_on.id, organization: organization) } + let(:other_integration) { create(:netsuite_integration, organization: organization) } + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:other_billing_entity) { create(:billing_entity, organization: organization) } + let(:other_organization) { create(:organization) } + + context "without billing entity" do + let(:mapping) do + build(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: nil) + end + + context "when it is unique in scope of mappable_id, integration_id" do + before do + create(:netsuite_mapping, integration: other_integration, organization:, mappable: add_on, billing_entity: nil) + create(:netsuite_mapping, integration:, organization:, mappable: other_add_on, billing_entity: nil) + create(:netsuite_mapping, integration:, mappable: add_on, billing_entity: nil, organization: other_organization) + create(:netsuite_mapping, integration:, organization:, mappable: billable_metric, billing_entity: nil) + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: other_billing_entity) + end + + it "does not add an error" do + expect(mapping).to be_valid + end + end + + context "when it is not unique in scope of mappable_id, integration_id, and billing_entity_id" do + before do + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: nil) + end + + it "adds an error" do + expect(mapping).not_to be_valid + expect(mapping.errors.where(:mappable_type, :taken)).to be_present + + expect { mapping.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique, /duplicate key value violates unique constraint "index_integration_mappings_unique_billing_entity_id_is_null"/) + end + end + end + + context "with billing entity" do + context "when it is unique in scope of mappable_id, integration_id, and billing_entity_id" do + before do + create(:netsuite_mapping, integration: other_integration, organization:, mappable: add_on, billing_entity:) + create(:netsuite_mapping, integration:, organization:, mappable: other_add_on, billing_entity:) + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: other_billing_entity) + create(:netsuite_mapping, integration:, organization:, mappable: billable_metric, billing_entity:) + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: nil) + end + + it "does not add an error" do + expect(mapping).to be_valid + end + end + + context "when it is not unique in scope of mappable_id, integration_id, and billing_entity_id" do + before do + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity:) + end + + it "adds an error" do + expect(mapping).not_to be_valid + expect(mapping.errors.where(:mappable_type, :taken)).to be_present + + expect { mapping.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique, /duplicate key value violates unique constraint "index_integration_mappings_unique_billing_entity_id_is_not_null"/) + end + end + end + end + + describe "billing entity organization validation" do + let(:organization) { create(:organization) } + let(:different_organization) { create(:organization) } + let(:integration) { create(:netsuite_integration, organization: organization) } + let(:billing_entity) { create(:billing_entity, organization: different_organization) } + let(:add_on) { create(:add_on, organization: organization) } + + it "validates billing entity belongs to same organization" do + mapping = build( + :netsuite_mapping, + integration: integration, + organization: organization, + billing_entity: billing_entity, + mappable: add_on, + external_id: "test_id" + ) + + expect(mapping).not_to be_valid + expect(mapping.errors[:billing_entity]).to include("must belong to the same organization") + end + + it "is valid when billing entity belongs to same organization" do + billing_entity.update!(organization: organization) + mapping = build( + :netsuite_mapping, + integration: integration, + organization: organization, + billing_entity: billing_entity, + mappable: add_on, + external_id: "test_id" + ) + + expect(mapping).to be_valid + end + + it "is valid when billing entity is nil" do + mapping = build( + :netsuite_mapping, + integration: integration, + organization: organization, + billing_entity: nil, + mappable: add_on, + external_id: "test_id" + ) + + expect(mapping).to be_valid + end + end + end describe "#push_to_settings" do it "push the value into settings" do diff --git a/spec/services/integration_collection_mappings/create_service_spec.rb b/spec/services/integration_collection_mappings/create_service_spec.rb index 53401d1251d..726111e12f5 100644 --- a/spec/services/integration_collection_mappings/create_service_spec.rb +++ b/spec/services/integration_collection_mappings/create_service_spec.rb @@ -28,14 +28,12 @@ integration_collection_mapping = IntegrationCollectionMappings::NetsuiteCollectionMapping.order(:created_at).last - aggregate_failures do - expect(integration_collection_mapping.organization).to eq(organization) - expect(integration_collection_mapping.mapping_type).to eq("fallback_item") - expect(integration_collection_mapping.integration_id).to eq(integration.id) - expect(integration_collection_mapping.tax_nexus).to eq(create_args[:tax_nexus]) - expect(integration_collection_mapping.tax_code).to eq(create_args[:tax_code]) - expect(integration_collection_mapping.tax_type).to eq(create_args[:tax_type]) - end + expect(integration_collection_mapping.organization).to eq(organization) + expect(integration_collection_mapping.mapping_type).to eq("fallback_item") + expect(integration_collection_mapping.integration_id).to eq(integration.id) + expect(integration_collection_mapping.tax_nexus).to eq(create_args[:tax_nexus]) + expect(integration_collection_mapping.tax_code).to eq(create_args[:tax_code]) + expect(integration_collection_mapping.tax_type).to eq(create_args[:tax_type]) end it "returns an integration collection mapping in result object" do @@ -45,6 +43,67 @@ end end + context "with billing entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:create_args) do + { + mapping_type: :fallback_item, + integration_id: integration.id, + billing_entity_id: billing_entity.id, + tax_nexus: "123", + tax_code: "456", + tax_type: "tax-type-1" + } + end + + it "creates an integration collection mapping with billing entity" do + expect { service_call }.to change(IntegrationCollectionMappings::NetsuiteCollectionMapping, :count).by(1) + + integration_collection_mapping = + IntegrationCollectionMappings::NetsuiteCollectionMapping.order(:created_at).last + + expect(integration_collection_mapping.billing_entity).to eq(billing_entity) + end + end + + context "with invalid billing entity" do + let(:other_organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization: other_organization) } + let(:create_args) do + { + mapping_type: :fallback_item, + integration_id: integration.id, + billing_entity_id: billing_entity.id + } + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billing_entity_not_found") + end + end + + context "with non-existent billing entity" do + let(:create_args) do + { + mapping_type: :fallback_item, + integration_id: integration.id, + billing_entity_id: "non-existent-id" + } + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billing_entity_not_found") + end + end + context "with validation error" do let(:create_args) do { @@ -56,11 +115,9 @@ it "returns an error" do result = service_call - aggregate_failures do - expect(result).not_to be_success - expect(result.error).to be_a(BaseService::NotFoundFailure) - expect(result.error.message).to eq("integration_not_found") - end + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("integration_not_found") end end end diff --git a/spec/services/integration_mappings/create_service_spec.rb b/spec/services/integration_mappings/create_service_spec.rb index f315db47141..32d9216d9a3 100644 --- a/spec/services/integration_mappings/create_service_spec.rb +++ b/spec/services/integration_mappings/create_service_spec.rb @@ -17,7 +17,8 @@ { mappable_type: "AddOn", mappable_id: add_on.id, - integration_id: integration.id + integration_id: integration.id, + external_id: "external_123" } end @@ -27,11 +28,9 @@ integration_mapping = IntegrationMappings::NetsuiteMapping.order(:created_at).last - aggregate_failures do - expect(integration_mapping.mappable_type).to eq("AddOn") - expect(integration_mapping.mappable_id).to eq(add_on.id) - expect(integration_mapping.integration_id).to eq(integration.id) - end + expect(integration_mapping.mappable_type).to eq("AddOn") + expect(integration_mapping.mappable_id).to eq(add_on.id) + expect(integration_mapping.integration_id).to eq(integration.id) end it "returns an integration mapping in result object" do @@ -39,6 +38,41 @@ expect(result.integration_mapping).to be_a(IntegrationMappings::NetsuiteMapping) end + + context "with billing entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:create_args) do + { + mappable_type: "AddOn", + mappable_id: add_on.id, + integration_id: integration.id, + billing_entity_id: billing_entity.id, + external_id: "external_123" + } + end + + it "creates an integration mapping with billing entity" do + expect { service_call }.to change(IntegrationMappings::NetsuiteMapping, :count).by(1) + + integration_mapping = IntegrationMappings::NetsuiteMapping.order(:created_at).last + + expect(integration_mapping.mappable_type).to eq("AddOn") + expect(integration_mapping.mappable_id).to eq(add_on.id) + expect(integration_mapping.integration_id).to eq(integration.id) + expect(integration_mapping.billing_entity_id).to eq(billing_entity.id) + expect(integration_mapping.external_id).to eq("external_123") + end + + context "when billing entity belongs to different organization" do + let(:billing_entity) { create(:billing_entity, organization: create(:organization)) } + + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error).to be_a(BaseService::NotFoundFailure) + expect(service_call.error.message).to eq("billing_entity_not_found") + end + end + end end context "with validation error" do @@ -52,11 +86,9 @@ it "returns an error" do result = service_call - aggregate_failures do - expect(result).not_to be_success - expect(result.error).to be_a(BaseService::NotFoundFailure) - expect(result.error.message).to eq("integration_not_found") - end + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("integration_not_found") end end end diff --git a/spec/services/integrations/aggregator/credit_notes/payloads/anrok_spec.rb b/spec/services/integrations/aggregator/credit_notes/payloads/anrok_spec.rb new file mode 100644 index 00000000000..bce8ba8ec4c --- /dev/null +++ b/spec/services/integrations/aggregator/credit_notes/payloads/anrok_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CreditNotes::Payloads::Anrok do + describe "#body" do + subject(:payload) { described_class.new(integration_customer:, credit_note:).body } + + it_behaves_like "an integration payload", :anrok do + def build_expected_payload(mapping_codes) + [ + { + "currency" => "EUR", + "external_contact_id" => integration_customer.external_customer_id, + "fees" => + [ + { + "account_code" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on Fee", + "external_id" => mapping_codes.dig(:add_on, :external_id), + "precise_unit_amount" => 1.9, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Standard Charge Fee", + "external_id" => mapping_codes.dig(:billable_metric, :external_id), + "precise_unit_amount" => 1.8, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Minimum Commitment Fee", + "external_id" => mapping_codes.dig(:minimum_commitment, :external_id), + "precise_unit_amount" => 1.7, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Subscription", + "external_id" => mapping_codes.dig(:subscription, :external_id), + "precise_unit_amount" => 1.6, + "taxes_amount_cents" => 0.0, + "units" => 1 + } + ], + "issuing_date" => "2024-07-08T00:00:00Z", + "number" => credit_note.number, + "status" => "AUTHORISED", + "type" => "ACCRECCREDIT" + } + ] + end + end + end +end diff --git a/spec/services/integrations/aggregator/credit_notes/payloads/netsuite_spec.rb b/spec/services/integrations/aggregator/credit_notes/payloads/netsuite_spec.rb new file mode 100644 index 00000000000..78af74b4fe3 --- /dev/null +++ b/spec/services/integrations/aggregator/credit_notes/payloads/netsuite_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CreditNotes::Payloads::Netsuite do + describe "#body" do + subject(:payload) { described_class.new(integration_customer:, credit_note:).body } + + it_behaves_like "an integration payload", :netsuite do + def build_expected_payload(mapping_codes) + { + "columns" => { + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_id" => credit_note.id, + "entity" => integration_customer.external_customer_id, + "otherrefnum" => credit_note.number, + "taxdetailsoverride" => true, + "taxregoverride" => true, + "tranId" => credit_note.id, + "tranid" => credit_note.number + }, + "isDynamic" => true, + "lines" => [ + { + "lineItems" => [ + { + "account" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on", + "item" => mapping_codes.dig(:add_on, :external_id), + "quantity" => 1, + "rate" => 1.9, + "taxdetailsreference" => add_on_credit_note_item.id + }, + { + "account" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Billable Metric", + "item" => mapping_codes.dig(:billable_metric, :external_id), + "quantity" => 1, + "rate" => 1.8, + "taxdetailsreference" => billable_metric_credit_note_item.id + }, + { + "account" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Plan", + "item" => mapping_codes.dig(:minimum_commitment, :external_id), + "quantity" => 1, + "rate" => 1.7, + "taxdetailsreference" => minimum_commitment_credit_note_item.id + }, + {"account" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Plan", + "item" => mapping_codes.dig(:subscription, :external_id), + "quantity" => 1, + "rate" => 1.6, + "taxdetailsreference" => subscription_credit_note_item.id} + ], + "sublistId" => "item" + } + ], + "options" => {"ignoreMandatoryFields" => false}, + "type" => "creditmemo" + } + end + end + end +end diff --git a/spec/services/integrations/aggregator/credit_notes/payloads/xero_spec.rb b/spec/services/integrations/aggregator/credit_notes/payloads/xero_spec.rb new file mode 100644 index 00000000000..18b2d165d69 --- /dev/null +++ b/spec/services/integrations/aggregator/credit_notes/payloads/xero_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CreditNotes::Payloads::Xero do + describe "#body" do + subject(:payload) { described_class.new(integration_customer:, credit_note:).body } + + it_behaves_like "an integration payload", :xero do + def build_expected_payload(mapping_codes) + [ + { + "currency" => "EUR", + "external_contact_id" => integration_customer.external_customer_id, + "fees" => + [ + { + "account_code" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on Fee", + "external_id" => mapping_codes.dig(:add_on, :external_id), + "precise_unit_amount" => 1.9, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Standard Charge Fee", + "external_id" => mapping_codes.dig(:billable_metric, :external_id), + "precise_unit_amount" => 1.8, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Minimum Commitment Fee", + "external_id" => mapping_codes.dig(:minimum_commitment, :external_id), + "precise_unit_amount" => 1.7, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Subscription", + "external_id" => mapping_codes.dig(:subscription, :external_id), + "precise_unit_amount" => 1.6, + "taxes_amount_cents" => 0.0, + "units" => 1 + } + ], + "issuing_date" => "2024-07-08T00:00:00Z", + "number" => credit_note.number, + "status" => "AUTHORISED", + "type" => "ACCRECCREDIT" + } + ] + end + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/payloads/anrok_spec.rb b/spec/services/integrations/aggregator/invoices/payloads/anrok_spec.rb new file mode 100644 index 00000000000..250660919a2 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/payloads/anrok_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Payloads::Anrok do + describe "#body" do + subject(:payload) { described_class.new(integration_customer:, invoice:).body } + + it_behaves_like "an integration payload", :anrok do + def build_expected_payload(mapping_codes) + [ + { + "external_contact_id" => integration_customer.external_customer_id, + "status" => "AUTHORISED", + "issuing_date" => "2024-07-08T00:00:00Z", + "payment_due_date" => "2024-07-08T00:00:00Z", + "number" => invoice.number, + "currency" => "EUR", + "type" => "ACCREC", + "fees" => + [ + { + "account_code" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on Fee", + "external_id" => mapping_codes.dig(:add_on, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.2e1 + }, + { + "account_code" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Standard Charge Fee", + "external_id" => mapping_codes.dig(:billable_metric, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.3e1 + }, + { + "account_code" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Minimum Commitment Fee", + "external_id" => mapping_codes.dig(:minimum_commitment, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.4e1 + }, + { + "account_code" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Subscription", + "external_id" => mapping_codes.dig(:subscription, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.5e1 + }, + { + "account_code" => mapping_codes.dig(:coupon, :external_account_code), + "description" => "Coupons", + "external_id" => mapping_codes.dig(:coupon, :external_id), + "precise_unit_amount" => -2.0, + "taxes_amount_cents" => -292, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:prepaid_credit, :external_account_code), + "description" => "Prepaid credit", + "external_id" => mapping_codes.dig(:prepaid_credit, :external_id), + "precise_unit_amount" => -3.0, + "taxes_amount_cents" => 0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:prepaid_credit, :external_account_code), + "description" => "Usage already billed", + "external_id" => mapping_codes.dig(:prepaid_credit, :external_id), + "precise_unit_amount" => -1.0, + "taxes_amount_cents" => 0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:credit_note, :external_account_code), + "description" => "Credit note", + "external_id" => mapping_codes.dig(:credit_note, :external_id), + "precise_unit_amount" => -5.0, + "taxes_amount_cents" => 0, + "units" => 1 + } + ] + } + ] + end + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/payloads/xero_spec.rb b/spec/services/integrations/aggregator/invoices/payloads/xero_spec.rb index 88a497ed370..1f359ee551a 100644 --- a/spec/services/integrations/aggregator/invoices/payloads/xero_spec.rb +++ b/spec/services/integrations/aggregator/invoices/payloads/xero_spec.rb @@ -4,215 +4,89 @@ RSpec.describe Integrations::Aggregator::Invoices::Payloads::Xero do describe "#body" do - subject(:body_call) { payload.body } - - let(:payload) { described_class.new(integration_customer:, invoice:) } - let(:integration_customer) { FactoryBot.create(:xero_customer, integration:, customer:) } - let(:integration) { create(:netsuite_integration, organization:) } - let(:customer) { create(:customer, organization:) } - let(:organization) { create(:organization) } - let(:add_on) { create(:add_on, organization:) } - let(:billable_metric) { create(:billable_metric, organization:) } - let(:charge) { create(:standard_charge, billable_metric:) } - let(:current_time) { Time.current } - - let(:integration_collection_mapping1) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :fallback_item, - settings: {external_id: "1", external_account_code: "11", external_name: ""} - ) - end - - let(:integration_collection_mapping2) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :coupon, - settings: {external_id: "2", external_account_code: "22", external_name: ""} - ) - end - - let(:integration_collection_mapping3) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :subscription_fee, - settings: {external_id: "3", external_account_code: "33", external_name: ""} - ) - end - - let(:integration_collection_mapping4) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :minimum_commitment, - settings: {external_id: "4", external_account_code: "44", external_name: ""} - ) - end - - let(:integration_collection_mapping6) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :prepaid_credit, - settings: {external_id: "6", external_account_code: "66", external_name: ""} - ) - end - - let(:integration_mapping_add_on) do - create( - :netsuite_mapping, - integration:, - mappable_type: "AddOn", - mappable_id: add_on.id, - settings: {external_id: "m1", external_account_code: "m11", external_name: ""} - ) - end - - let(:integration_mapping_bm) do - create( - :netsuite_mapping, - integration:, - mappable_type: "BillableMetric", - mappable_id: billable_metric.id, - settings: {external_id: "m2", external_account_code: "m22", external_name: ""} - ) - end - - let(:invoice) do - create( - :invoice, - customer:, - organization:, - coupons_amount_cents: 2000, - prepaid_credit_amount_cents: 4000, - progressive_billing_credit_amount_cents: 100, - credit_notes_amount_cents: 6000, - taxes_amount_cents: 200, - issuing_date: DateTime.new(2024, 7, 8) - ) - end - - let(:fee_sub) do - create( - :fee, - invoice:, - amount_cents: 10_000, - taxes_amount_cents: 200, - created_at: current_time - 3.seconds - ) - end - - let(:minimum_commitment_fee) do - create( - :minimum_commitment_fee, - invoice:, - created_at: current_time - 2.seconds - ) - end - - let(:charge_fee) do - create( - :charge_fee, - invoice:, - charge:, - units: 2, - precise_unit_amount: 4.12121212123337777, - created_at: current_time - ) - end - - let(:body) do - [ - { - "external_contact_id" => integration_customer.external_customer_id, - "status" => "AUTHORISED", - "issuing_date" => "2024-07-08T00:00:00Z", - "payment_due_date" => "2024-07-08T00:00:00Z", - "number" => invoice.number, - "currency" => "EUR", - "type" => "ACCREC", - "fees" => [ - { - "external_id" => "3", - "description" => "Subscription", - "units" => 0.0, - "precise_unit_amount" => 0.0, - "account_code" => "33", - "taxes_amount_cents" => 200 - }, - { - "external_id" => "4", - "description" => minimum_commitment_fee.invoice_name, - "units" => 0.0, - "precise_unit_amount" => 0.0, - "account_code" => "44", - "taxes_amount_cents" => 2 - }, - { - "external_id" => "m2", - "description" => charge_fee.invoice_name, - "units" => 1, - "amount_cents" => charge_fee.amount_cents, - "account_code" => "m22", - "taxes_amount_cents" => 2 - }, - { - "account_code" => "22", - "description" => "Coupons", - "external_id" => "2", - "precise_unit_amount" => -20.0, - "taxes_amount_cents" => -4, - "units" => 1 - }, - { - "external_id" => "6", - "description" => "Prepaid credit", - "units" => 1, - "precise_unit_amount" => -40.0, - "taxes_amount_cents" => 0, - "account_code" => "66" - }, - { - "external_id" => "6", - "description" => "Usage already billed", - "units" => 1, - "precise_unit_amount" => -1.0, - "taxes_amount_cents" => 0, - "account_code" => "66" - }, - { - "external_id" => "1", - "description" => "Credit note", - "units" => 1, - "precise_unit_amount" => -60.0, - "taxes_amount_cents" => 0, - "account_code" => "11" - } - ] - } - ] - end - - before do - integration_customer - charge - integration_collection_mapping1 - integration_collection_mapping2 - integration_collection_mapping3 - integration_collection_mapping4 - integration_collection_mapping6 - integration_mapping_add_on - integration_mapping_bm - fee_sub - minimum_commitment_fee - charge_fee - end - - it "returns payload body" do - expect(subject).to eq(body) + subject(:payload) { described_class.new(integration_customer:, invoice:).body } + + it_behaves_like "an integration payload", :xero do + def build_expected_payload(mapping_codes) + [ + { + "external_contact_id" => integration_customer.external_customer_id, + "status" => "AUTHORISED", + "issuing_date" => "2024-07-08T00:00:00Z", + "payment_due_date" => "2024-07-08T00:00:00Z", + "number" => invoice.number, + "currency" => "EUR", + "type" => "ACCREC", + "fees" => + [ + { + "account_code" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on Fee", + "external_id" => mapping_codes.dig(:add_on, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.2e1 + }, + { + "account_code" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Standard Charge Fee", + "external_id" => mapping_codes.dig(:billable_metric, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.3e1 + }, + { + "account_code" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Minimum Commitment Fee", + "external_id" => mapping_codes.dig(:minimum_commitment, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.4e1 + }, + { + "account_code" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Subscription", + "external_id" => mapping_codes.dig(:subscription, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.5e1 + }, + { + "account_code" => mapping_codes.dig(:coupon, :external_account_code), + "description" => "Coupons", + "external_id" => mapping_codes.dig(:coupon, :external_id), + "precise_unit_amount" => -2.0, + "taxes_amount_cents" => -292, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:prepaid_credit, :external_account_code), + "description" => "Prepaid credit", + "external_id" => mapping_codes.dig(:prepaid_credit, :external_id), + "precise_unit_amount" => -3.0, + "taxes_amount_cents" => 0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:prepaid_credit, :external_account_code), + "description" => "Usage already billed", + "external_id" => mapping_codes.dig(:prepaid_credit, :external_id), + "precise_unit_amount" => -1.0, + "taxes_amount_cents" => 0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:credit_note, :external_account_code), + "description" => "Credit note", + "external_id" => mapping_codes.dig(:credit_note, :external_id), + "precise_unit_amount" => -5.0, + "taxes_amount_cents" => 0, + "units" => 1 + } + ] + } + ] + end end end end diff --git a/spec/services/integrations/aggregator/payments/payloads/base_payload_spec.rb b/spec/services/integrations/aggregator/payments/payloads/base_payload_spec.rb deleted file mode 100644 index 888177e8c7a..00000000000 --- a/spec/services/integrations/aggregator/payments/payloads/base_payload_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe Integrations::Aggregator::Payments::Payloads::BasePayload do - let(:payload) { described_class.new(integration:, payment:) } - let(:payment) { create(:payment, payable: invoice) } - let(:invoice) { create(:invoice, customer:, organization:) } - let(:integration) { create(:netsuite_integration, organization:) } - let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } - let(:customer) { create(:customer, organization:) } - let(:organization) { create(:organization) } - - describe "#initialize" do - it "assigns the payment" do - expect(payload.instance_variable_get(:@payment)).to eq(payment) - end - end - - describe "#integration_customer" do - subject(:method_call) { payload.__send__(:integration_customer) } - - before do - integration_customer - create(:hubspot_customer, customer:) - end - - it "returns the first accounting kind integration customer" do - expect(subject).to eq(integration_customer) - end - - it "memoizes the integration customer" do - subject - expect(payload.instance_variable_get(:@integration_customer)).to eq(integration_customer) - end - end - - describe "#body" do - let(:integration_invoice) { create(:integration_resource, syncable: invoice, integration:) } - - before { integration_invoice } - - it "returns correct body" do - expect(payload.body).to eq( - [ - { - "invoice_id" => integration_invoice.external_id, - "account_code" => nil, - "date" => payment.created_at.utc.iso8601, - "amount_cents" => payment.amount_cents - } - ] - ) - end - end -end diff --git a/spec/services/integrations/aggregator/payments/payloads/xero_spec.rb b/spec/services/integrations/aggregator/payments/payloads/xero_spec.rb new file mode 100644 index 00000000000..a40b4729886 --- /dev/null +++ b/spec/services/integrations/aggregator/payments/payloads/xero_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Payments::Payloads::Xero do + let(:payload) { described_class.new(integration:, payment:).body } + + describe "#body" do + it_behaves_like "an integration payload", :xero do + let!(:integration_invoice) { create(:integration_resource, syncable: invoice, integration:) } + + before { integration_invoice } + + def build_expected_payload(mapping_codes) + [ + { + "invoice_id" => integration_invoice.external_id, + "account_code" => mapping_codes.dig(:account, :external_account_code), + "date" => payment.created_at.utc.iso8601, + "amount_cents" => payment.amount_cents + } + ] + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/credit_notes/payloads/anrok_spec.rb b/spec/services/integrations/aggregator/taxes/credit_notes/payloads/anrok_spec.rb index 6fbcd14e753..25a716e415e 100644 --- a/spec/services/integrations/aggregator/taxes/credit_notes/payloads/anrok_spec.rb +++ b/spec/services/integrations/aggregator/taxes/credit_notes/payloads/anrok_spec.rb @@ -3,125 +3,51 @@ require "rails_helper" RSpec.describe Integrations::Aggregator::Taxes::CreditNotes::Payloads::Anrok do - subject(:service_call) { payload.body } - - let(:payload) { described_class.new(integration:, customer:, integration_customer:, credit_note:) } - let(:integration) { create(:anrok_integration, organization:) } - let(:integration_customer) { create(:anrok_customer, integration:, customer:, external_customer_id: nil) } - let(:customer) { create(:customer, organization:) } - let(:organization) { create(:organization) } - let(:add_on) { create(:add_on, organization:) } - let(:add_on_two) { create(:add_on, organization:) } - let(:current_time) { Time.current } - - let(:integration_collection_mapping1) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :fallback_item, - settings: {external_id: "1", external_account_code: "11", external_name: ""} - ) - end - let(:integration_mapping_add_on) do - create( - :netsuite_mapping, - integration:, - mappable_type: "AddOn", - mappable_id: add_on.id, - settings: {external_id: "m1", external_account_code: "m11", external_name: ""} - ) - end - - let(:invoice) do - create( - :invoice, - customer:, - organization: - ) - end - let(:fee_add_on) do - create( - :fee, - invoice:, - add_on:, - created_at: current_time - 3.seconds, - amount_cents: 200, - precise_amount_cents: 200 - ) - end - let(:fee_add_on_two) do - create( - :fee, - invoice:, - add_on: add_on_two, - created_at: current_time - 2.seconds, - amount_cents: 200, - precise_amount_cents: 200, - precise_coupons_amount_cents: 20 - ) - end - let(:credit_note) do - create( - :credit_note, - customer:, - invoice:, - status: "finalized", - organization: - ) - end - - let(:credit_note_item1) do - create(:credit_note_item, credit_note:, fee: fee_add_on, amount_cents: 190) - end - let(:credit_note_item2) do - create(:credit_note_item, credit_note:, fee: fee_add_on_two, amount_cents: 180) - end + describe "#body" do + subject(:payload) { described_class.new(integration:, customer:, integration_customer:, credit_note:).body } - let(:body) do - [ - { - "id" => "cn_#{credit_note.id}", - "issuing_date" => credit_note.issuing_date, - "currency" => credit_note.currency, - "contact" => { - "external_id" => customer.external_id, - "name" => customer.name, - "address_line_1" => customer.address_line1, - "city" => customer.city, - "zip" => customer.zipcode, - "country" => customer.country, - "taxable" => false, - "tax_number" => nil - }, - "fees" => [ + it_behaves_like "an integration payload", :anrok do + def build_expected_payload(mapping_codes) + [ { - "item_id" => fee_add_on.item_id, - "item_code" => "m1", - "amount_cents" => -190 - }, - { - "item_id" => fee_add_on_two.item_id, - "item_code" => "1", - "amount_cents" => -162 + "id" => "cn_#{credit_note.id}", + "issuing_date" => credit_note.issuing_date, + "currency" => credit_note.currency, + "contact" => { + "external_id" => integration_customer.external_customer_id, + "name" => customer.name, + "address_line_1" => customer.address_line1, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "taxable" => false, + "tax_number" => nil + }, + "fees" => match_array([ + { + "item_id" => add_on.id, + "amount_cents" => -190, + "item_code" => mapping_codes.dig(:add_on, :external_id) + }, + { + "item_id" => billable_metric.id, + "amount_cents" => -180, + "item_code" => mapping_codes.dig(:billable_metric, :external_id) + }, + { + "item_id" => subscription.id, + "amount_cents" => -170, + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id) + }, + { + "item_id" => subscription.id, + "amount_cents" => -160, + "item_code" => mapping_codes.dig(:subscription, :external_id) + } + ]) } ] - } - ] - end - - before do - integration_customer - integration_collection_mapping1 - integration_mapping_add_on - fee_add_on - fee_add_on_two - credit_note_item1 - credit_note_item2 - end - - describe "#body" do - it "returns payload" do - expect(service_call).to eq(body) + end end end end diff --git a/spec/services/integrations/aggregator/taxes/credit_notes/payloads/avalara_spec.rb b/spec/services/integrations/aggregator/taxes/credit_notes/payloads/avalara_spec.rb index 9b270538f54..d5a992de899 100644 --- a/spec/services/integrations/aggregator/taxes/credit_notes/payloads/avalara_spec.rb +++ b/spec/services/integrations/aggregator/taxes/credit_notes/payloads/avalara_spec.rb @@ -3,136 +3,62 @@ require "rails_helper" RSpec.describe Integrations::Aggregator::Taxes::CreditNotes::Payloads::Avalara do - subject(:service_call) { payload.body } + subject(:payload) { described_class.new(integration:, customer:, integration_customer:, credit_note:).body } - let(:payload) { described_class.new(integration:, customer:, integration_customer:, credit_note:) } - let(:integration) { create(:avalara_integration, organization:) } - let(:integration_customer) { create(:avalara_customer, integration:, customer:) } - let(:customer) { create(:customer, organization:) } - let(:organization) { create(:organization) } - let(:add_on) { create(:add_on, organization:) } - let(:add_on_two) { create(:add_on, organization:) } - let(:current_time) { Time.current } - - let(:integration_collection_mapping1) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :fallback_item, - settings: {external_id: "1", external_account_code: "11", external_name: ""} - ) - end - let(:integration_mapping_add_on) do - create( - :netsuite_mapping, - integration:, - mappable_type: "AddOn", - mappable_id: add_on.id, - settings: {external_id: "m1", external_account_code: "m11", external_name: ""} - ) - end - - let(:invoice) do - create( - :invoice, - customer:, - organization: - ) - end - let(:fee_add_on) do - create( - :fee, - invoice:, - add_on:, - created_at: current_time - 3.seconds, - amount_cents: 200, - precise_amount_cents: 200 - ) - end - let(:fee_add_on_two) do - create( - :fee, - invoice:, - add_on: add_on_two, - created_at: current_time - 2.seconds, - amount_cents: 200, - precise_amount_cents: 200, - precise_coupons_amount_cents: 20 - ) - end - let(:credit_note) do - create( - :credit_note, - customer:, - invoice:, - status: "finalized", - organization: - ) - end - - let(:credit_note_item1) do - create(:credit_note_item, credit_note:, fee: fee_add_on, amount_cents: 190) - end - let(:credit_note_item2) do - create(:credit_note_item, credit_note:, fee: fee_add_on_two, amount_cents: 180) - end - - let(:body) do - [ - { - "id" => "cn_#{credit_note.id}", - "type" => "returnInvoice", - "issuing_date" => credit_note.issuing_date, - "currency" => credit_note.currency, - "contact" => { - "external_id" => integration_customer&.external_customer_id || customer.external_id, - "name" => customer.name, - "address_line_1" => customer.shipping_address_line1 || customer.address_line1, - "city" => customer.shipping_city || customer.city, - "zip" => customer.shipping_zipcode || customer.zipcode, - "region" => customer.shipping_state || customer.state, - "country" => customer.shipping_country || customer.country, - "taxable" => customer.tax_identification_number.present?, - "tax_number" => customer.tax_identification_number - }, - "billing_entity" => { - "address_line_1" => customer.billing_entity.address_line1, - "city" => customer.billing_entity.city, - "zip" => customer.billing_entity.zipcode, - "region" => customer.billing_entity.state, - "country" => customer.billing_entity.country - }, - "fees" => [ - { - "item_id" => fee_add_on.item_id, - "item_code" => "m1", - "unit" => 0.0, - "amount" => "-1.9" + it_behaves_like "an integration payload", :avalara do + def build_expected_payload(mapping_codes) + [ + { + "id" => "cn_#{credit_note.id}", + "type" => "returnInvoice", + "issuing_date" => credit_note.issuing_date, + "currency" => credit_note.currency, + "contact" => { + "external_id" => integration_customer&.external_customer_id || customer.external_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "region" => customer.shipping_state || customer.state, + "country" => customer.shipping_country || customer.country, + "taxable" => customer.tax_identification_number.present?, + "tax_number" => customer.tax_identification_number }, - { - "item_id" => fee_add_on_two.item_id, - "item_code" => "1", - "unit" => 0.0, - "amount" => "-1.62" - } - ] - } - ] - end - - before do - integration_customer - integration_collection_mapping1 - integration_mapping_add_on - fee_add_on - fee_add_on_two - credit_note_item1 - credit_note_item2 - end - - describe "#body" do - it "returns payload" do - expect(service_call).to eq(body) + "billing_entity" => { + "address_line_1" => customer.billing_entity.address_line1, + "city" => customer.billing_entity.city, + "zip" => customer.billing_entity.zipcode, + "region" => customer.billing_entity.state, + "country" => customer.billing_entity.country + }, + "fees" => match_array([ + { + "item_id" => add_on.id, + "amount" => "-1.9", + "unit" => 2.0, + "item_code" => mapping_codes.dig(:add_on, :external_id) + }, + { + "item_id" => billable_metric.id, + "amount" => "-1.8", + "unit" => 3.0, + "item_code" => mapping_codes.dig(:billable_metric, :external_id) + }, + { + "item_id" => subscription.id, + "amount" => "-1.7", + "unit" => 4.0, + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id) + }, + { + "item_id" => subscription.id, + "amount" => "-1.6", + "unit" => 5.0, + "item_code" => mapping_codes.dig(:subscription, :external_id) + } + ]) + } + ] end end end diff --git a/spec/services/integrations/aggregator/taxes/invoices/payloads/anrok_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/payloads/anrok_spec.rb index 539a8fca651..1ad46f5d015 100644 --- a/spec/services/integrations/aggregator/taxes/invoices/payloads/anrok_spec.rb +++ b/spec/services/integrations/aggregator/taxes/invoices/payloads/anrok_spec.rb @@ -3,108 +3,60 @@ require "rails_helper" RSpec.describe Integrations::Aggregator::Taxes::Invoices::Payloads::Anrok do - let(:integration) { create(:anrok_integration, organization:) } - let(:organization) { create(:organization) } - let(:integration_customer) { create(:anrok_customer, customer:, integration:) } - let(:customer) { create(:customer, organization:) } - let(:payload) { described_class.new(integration:, customer:, invoice:, integration_customer:, fees:) } - let(:add_on) { create(:add_on, organization:) } - let(:add_on_two) { create(:add_on, organization:) } - let(:current_time) { Time.current } - let(:fees) { invoice.fees } - let(:integration_collection_mapping1) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :fallback_item, - settings: {external_id: "1", external_account_code: "11", external_name: ""} - ) - end - let(:integration_mapping_add_on) do - create( - :netsuite_mapping, - integration:, - mappable_type: "AddOn", - mappable_id: add_on.id, - settings: {external_id: "m1", external_account_code: "m11", external_name: ""} - ) - end - let(:invoice) do - create( - :invoice, - customer:, - organization: - ) - end - let(:fee_add_on) do - create( - :fee, - invoice:, - add_on:, - created_at: current_time - 3.seconds - ) - end - let(:fee_add_on_two) do - create( - :fee, - invoice:, - add_on: add_on_two, - created_at: current_time - 2.seconds - ) - end - - before do - integration_customer - integration_collection_mapping1 - integration_mapping_add_on - fee_add_on - fee_add_on_two - end - describe "#body" do - subject(:call) { payload.body } + subject(:payload) { described_class.new(integration:, customer:, invoice:, integration_customer:, fees:).body } - let(:payload_body) do - [ - { - "issuing_date" => invoice.issuing_date, - "currency" => invoice.currency, - "contact" => { - "external_id" => integration_customer&.external_customer_id || customer.external_id, - "name" => customer.name, - "address_line_1" => customer.shipping_address_line1 || customer.address_line1, - "city" => customer.shipping_city || customer.city, - "zip" => customer.shipping_zipcode || customer.zipcode, - "country" => customer.shipping_country || customer.country, - "taxable" => customer.tax_identification_number.present?, - "tax_number" => customer.tax_identification_number - }, - "fees" => [ - { - "item_key" => fee_add_on.item_key, - "item_id" => fee_add_on.id, - "item_code" => "m1", - "amount_cents" => 200 + it_behaves_like "an integration payload", :avalara do + def build_expected_payload(mapping_codes) + [ + { + "issuing_date" => invoice.issuing_date, + "currency" => invoice.currency, + "contact" => { + "external_id" => integration_customer&.external_customer_id || customer.external_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "country" => customer.shipping_country || customer.country, + "taxable" => customer.tax_identification_number.present?, + "tax_number" => customer.tax_identification_number }, - { - "item_key" => fee_add_on_two.item_key, - "item_id" => fee_add_on_two.id, - "item_code" => "1", - "amount_cents" => 200 - } - ] - } - ] - end - - it "returns the payload body" do - expect(call).to eq payload_body - end + "fees" => match_array([ + { + "item_key" => add_on_fee.item_key, + "item_id" => add_on_fee.id, + "amount_cents" => 200, + "item_code" => mapping_codes.dig(:add_on, :external_id) + }, + { + "item_key" => billable_metric_fee.item_key, + "item_id" => billable_metric_fee.id, + "amount_cents" => 300, + "item_code" => mapping_codes.dig(:billable_metric, :external_id) + }, + { + "item_key" => minimum_commitment_fee.item_key, + "item_id" => minimum_commitment_fee.id, + "amount_cents" => 400, + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id) + }, + { + "item_key" => subscription_fee.item_key, + "item_id" => subscription_fee.id, + "amount_cents" => 500, + "item_code" => mapping_codes.dig(:subscription, :external_id) + } + ]) + } + ] + end - context "when invoice.issuing_date is too far in the future" do - it "uses issuing date 30 days in the future at most" do - invoice.issuing_date = 61.days.from_now.to_date - expect(call.sole["issuing_date"]).to eq 30.days.from_now.to_date + context "when invoice.issuing_date is too far in the future" do + it "uses issuing date 30 days in the future at most" do + invoice.issuing_date = 61.days.from_now.to_date + expect(payload.sole["issuing_date"]).to eq 30.days.from_now.to_date + end end end end diff --git a/spec/services/integrations/aggregator/taxes/invoices/payloads/avalara_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/payloads/avalara_spec.rb index 5c4563ceae5..2ecf9e49e29 100644 --- a/spec/services/integrations/aggregator/taxes/invoices/payloads/avalara_spec.rb +++ b/spec/services/integrations/aggregator/taxes/invoices/payloads/avalara_spec.rb @@ -3,120 +3,11 @@ require "rails_helper" RSpec.describe Integrations::Aggregator::Taxes::Invoices::Payloads::Avalara do - let(:integration) { create(:avalara_integration, organization:) } - let(:organization) { create(:organization) } - let(:integration_customer) { create(:avalara_customer, customer:, integration:) } - let(:customer) { create(:customer, organization:) } - let(:payload) { described_class.new(integration:, customer:, invoice:, integration_customer:, fees:) } - let(:add_on) { create(:add_on, organization:) } - let(:add_on_two) { create(:add_on, organization:) } - let(:current_time) { Time.current } - let(:fees) { invoice.fees } - let(:integration_collection_mapping1) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :fallback_item, - settings: {external_id: "1", external_account_code: "11", external_name: ""} - ) - end - let(:integration_mapping_add_on) do - create( - :netsuite_mapping, - integration:, - mappable_type: "AddOn", - mappable_id: add_on.id, - settings: {external_id: "m1", external_account_code: "m11", external_name: ""} - ) - end - let(:invoice) do - create( - :invoice, - customer:, - organization: - ) - end - let(:fee_add_on) do - create( - :fee, - invoice:, - add_on:, - units: 1, - amount_cents: 2035, - created_at: current_time - 3.seconds - ) - end - let(:fee_add_on_two) do - create( - :fee, - invoice:, - add_on: add_on_two, - units: 1, - amount_cents: 2035, - created_at: current_time - 2.seconds - ) - end - - before do - integration_customer - integration_collection_mapping1 - integration_mapping_add_on - fee_add_on - fee_add_on_two - end - describe "#body" do - subject(:call) { payload.body } + subject(:payload) { described_class.new(integration:, customer:, invoice:, integration_customer:, fees:).body } - let(:payload_body) do - [ - { - "issuing_date" => invoice.issuing_date, - "currency" => invoice.currency, - "contact" => { - "external_id" => integration_customer&.external_customer_id || customer.external_id, - "name" => customer.name, - "address_line_1" => customer.shipping_address_line1 || customer.address_line1, - "city" => customer.shipping_city || customer.city, - "zip" => customer.shipping_zipcode || customer.zipcode, - "region" => customer.shipping_state || customer.state, - "country" => customer.shipping_country || customer.country, - "taxable" => customer.tax_identification_number.present?, - "tax_number" => customer.tax_identification_number - }, - "billing_entity" => { - "address_line_1" => customer.billing_entity.address_line1, - "city" => customer.billing_entity.city, - "zip" => customer.billing_entity.zipcode, - "region" => customer.billing_entity.state, - "country" => customer.billing_entity.country - }, - "fees" => [ - { - "item_key" => fee_add_on.item_key, - "item_id" => fee_add_on.id, - "item_code" => "m1", - "unit" => 1, - "amount" => "20.35" - }, - { - "item_key" => fee_add_on_two.item_key, - "item_id" => fee_add_on_two.id, - "item_code" => "1", - "unit" => 1, - "amount" => "20.35" - } - ] - } - ] - end - - it "returns the payload body" do - expect(call).to eq payload_body - end - - context "when invoice is voided" do - let(:payload_body) do + it_behaves_like "an integration payload", :avalara do + def build_expected_payload(mapping_codes, negative_amount: false) [ { "issuing_date" => invoice.issuing_date, @@ -139,30 +30,46 @@ "region" => customer.billing_entity.state, "country" => customer.billing_entity.country }, - "fees" => [ + "fees" => match_array([ + { + "item_key" => add_on_fee.item_key, + "item_id" => add_on_fee.id, + "amount" => negative_amount ? "-2.0" : "2.0", + "unit" => 2.0, + "item_code" => mapping_codes.dig(:add_on, :external_id) + }, + { + "item_key" => billable_metric_fee.item_key, + "item_id" => billable_metric_fee.id, + "amount" => negative_amount ? "-3.0" : "3.0", + "unit" => 3.0, + "item_code" => mapping_codes.dig(:billable_metric, :external_id) + }, { - "item_key" => fee_add_on.item_key, - "item_id" => fee_add_on.id, - "item_code" => "m1", - "unit" => 1, - "amount" => "-20.35" + "item_key" => minimum_commitment_fee.item_key, + "item_id" => minimum_commitment_fee.id, + "amount" => negative_amount ? "-4.0" : "4.0", + "unit" => 4.0, + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id) }, { - "item_key" => fee_add_on_two.item_key, - "item_id" => fee_add_on_two.id, - "item_code" => "1", - "unit" => 1, - "amount" => "-20.35" + "item_key" => subscription_fee.item_key, + "item_id" => subscription_fee.id, + "amount" => negative_amount ? "-5.0" : "5.0", + "unit" => 5.0, + "item_code" => mapping_codes.dig(:subscription, :external_id) } - ] + ]) } ] end - before { invoice.voided! } + context "when invoice is voided" do + before { invoice.voided! } - it "returns the payload body" do - expect(call).to eq payload_body + it "returns the payload body" do + expect(payload).to match_array build_expected_payload(default_mapping_codes, negative_amount: true) + end end end end diff --git a/spec/support/shared_examples/an_integration_payload.rb b/spec/support/shared_examples/an_integration_payload.rb new file mode 100644 index 00000000000..11f08681dbf --- /dev/null +++ b/spec/support/shared_examples/an_integration_payload.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true + +# This is a shared example that is used to test the payload of an integration. +# It will test the fallback behavior of the integration from billing entity to organization. +# +# It expects a `build_expected_payload` method to be defined in the spec +# ``` +# it_behaves_like "an integration payload", :avalara do +# def build_expected_payload(mapping_codes, some_extra_parameter_with_defaults: false) +# [ +# { +# "issuing_date" => invoice.issuing_date, +# "currency" => invoice.currency, +# "some_extra_parameter_with_defaults" => some_extra_parameter_with_defaults, +# "fees" => match_array([ +# { +# "item_key" => add_on_fee.item_key, +# "item_id" => add_on_fee.id, +# "amount" => "2.0", +# "unit" => 2.0, +# "item_code" => mapping_codes.dig(:add_on, :external_id) +# } +# ]) +# } +# ] +# end +# end +# ``` +# +RSpec.shared_examples "an integration payload" do |integration_type| + let(:integration_type) { integration_type.to_sym } + let(:mappings_on) { [:billing_entity, :organization] } + let(:fallback_items_on) { [:billing_entity, :organization] } + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:integration) { create("#{integration_type}_integration", organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:integration_customer) { create("#{integration_type}_customer", customer:, integration:) } + + let(:add_on) { create(:add_on, organization:, name: "Add-on") } + let(:billable_metric) { create(:billable_metric, organization:, name: "Billable Metric") } + let(:plan) { create(:plan, organization:, name: "Plan") } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:invoice) do + invoice = create( + :invoice, + customer:, + organization:, + billing_entity:, + coupons_amount_cents: 200, + prepaid_credit_amount_cents: 300, + progressive_billing_credit_amount_cents: 100, + credit_notes_amount_cents: 500, + taxes_amount_cents: 300, + issuing_date: DateTime.new(2024, 7, 8) + ) + create(:invoice_subscription, invoice:, subscription:) + invoice + end + let(:payment) { create(:payment, payable: invoice) } + + let(:add_on_fee) { create(:add_on_fee, invoice:, add_on:, units: 2, amount_cents: 200, precise_unit_amount: 100.0, invoice_display_name: "Add-on Fee") } + let(:billable_metric_fee) { create(:charge_fee, invoice:, billable_metric:, units: 3, amount_cents: 300, charge:, invoice_display_name: "Standard Charge Fee", precise_unit_amount: 100.0) } + let(:minimum_commitment_fee) { create(:minimum_commitment_fee, invoice:, units: 4, amount_cents: 400, invoice_display_name: "Minimum Commitment Fee", precise_unit_amount: 100.0) } + let(:subscription_fee) { create(:fee, invoice:, subscription:, units: 5, amount_cents: 500, precise_unit_amount: 100.0) } + let(:fees) { invoice.fees } + + let(:credit_note) { create(:credit_note, customer:, invoice:, issuing_date: DateTime.new(2024, 7, 8)) } + + let(:add_on_credit_note_item) { create(:credit_note_item, credit_note:, fee: add_on_fee, amount_cents: 190) } + let(:billable_metric_credit_note_item) { create(:credit_note_item, credit_note:, fee: billable_metric_fee, amount_cents: 180) } + let(:minimum_commitment_credit_note_item) { create(:credit_note_item, credit_note:, fee: minimum_commitment_fee, amount_cents: 170) } + let(:subscription_credit_note_item) { create(:credit_note_item, credit_note:, fee: subscription_fee, amount_cents: 160) } + + let(:add_on_mapping_on_billing_entity) do + settings = {external_id: "add_on_on_billing_entity", external_account_code: "11", external_name: "add_on_on_billing_entity"} + create_mapping("AddOn", add_on.id, billing_entity:, settings:) + end + let(:billable_metric_mapping_on_billing_entity) do + settings = {external_id: "billable_metric_on_billing_entity", external_account_code: "12", external_name: "billable_metric_on_billing_entity"} + create_mapping("BillableMetric", billable_metric.id, billing_entity:, settings:) + end + let(:commitment_mapping_on_billing_entity) do + settings = {external_id: "commitment_on_billing_entity", external_account_code: "13", external_name: "commitment_on_billing_entity"} + create_collection_mapping(:minimum_commitment, billing_entity:, settings:) + end + let(:subscription_mapping_on_billing_entity) do + settings = {external_id: "subscription_on_billing_entity", external_account_code: "14", external_name: "subscription_on_billing_entity"} + create_collection_mapping(:subscription_fee, billing_entity:, settings:) + end + let(:account_mapping_on_billing_entity) do + settings = {external_id: "account_on_billing_entity", external_account_code: "15", external_name: "account_on_billing_entity"} + create_collection_mapping(:account, billing_entity:, settings:) + end + let(:credit_note_mapping_on_billing_entity) do + settings = {external_id: "credit_note_on_billing_entity", external_account_code: "16", external_name: "credit_note_on_billing_entity"} + create_collection_mapping(:credit_note, billing_entity:, settings:) + end + let(:prepaid_credit_mapping_on_billing_entity) do + settings = {external_id: "prepaid_credit_on_billing_entity", external_account_code: "17", external_name: "prepaid_credit_on_billing_entity"} + create_collection_mapping(:prepaid_credit, billing_entity:, settings:) + end + let(:tax_mapping_on_billing_entity) do + settings = {external_id: "tax_on_billing_entity", external_account_code: "18", external_name: "tax_on_billing_entity"} + create_collection_mapping(:tax, billing_entity:, settings:) + end + let(:coupon_mapping_on_billing_entity) do + settings = {external_id: "coupon_on_billing_entity", external_account_code: "19", external_name: "coupon_on_billing_entity"} + create_collection_mapping(:coupon, billing_entity:, settings:) + end + let(:fallback_item_on_billing_entity) do + settings = {external_id: "fallback_item_on_billing_entity", external_account_code: "20", external_name: "fallback_item_on_billing_entity"} + create_collection_mapping(:fallback_item, billing_entity:, settings:) + end + + let(:add_on_mapping_on_organization) do + settings = {external_id: "add_on_on_organization", external_account_code: "111", external_name: "add_on_on_organization"} + create_mapping("AddOn", add_on.id, billing_entity: nil, settings:) + end + let(:billable_metric_mapping_on_organization) do + settings = {external_id: "billable_metric_on_organization", external_account_code: "112", external_name: "billable_metric_on_organization"} + create_mapping("BillableMetric", billable_metric.id, billing_entity: nil, settings:) + end + let(:commitment_mapping_on_organization) do + settings = {external_id: "commitment_on_organization", external_account_code: "113", external_name: "commitment_on_organization"} + create_collection_mapping(:minimum_commitment, billing_entity: nil, settings:) + end + let(:subscription_mapping_on_organization) do + settings = {external_id: "subscription_on_organization", external_account_code: "114", external_name: "subscription_on_organization"} + create_collection_mapping(:subscription_fee, billing_entity: nil, settings:) + end + let(:account_mapping_on_organization) do + settings = {external_id: "account_on_organization", external_account_code: "115", external_name: "account_on_organization"} + create_collection_mapping(:account, billing_entity: nil, settings:) + end + let(:credit_note_mapping_on_organization) do + settings = {external_id: "credit_note_on_organization", external_account_code: "116", external_name: "credit_note_on_organization"} + create_collection_mapping(:credit_note, billing_entity: nil, settings:) + end + let(:prepaid_credit_mapping_on_organization) do + settings = {external_id: "prepaid_credit_on_organization", external_account_code: "117", external_name: "prepaid_credit_on_organization"} + create_collection_mapping(:prepaid_credit, billing_entity: nil, settings:) + end + let(:tax_mapping_on_organization) do + settings = {external_id: "tax_on_organization", external_account_code: "118", external_name: "tax_on_organization"} + create_collection_mapping(:tax, billing_entity: nil, settings:) + end + let(:coupon_mapping_on_organization) do + settings = {external_id: "coupon_on_organization", external_account_code: "119", external_name: "coupon_on_organization"} + create_collection_mapping(:coupon, billing_entity: nil, settings:) + end + let(:fallback_item_on_organization) do + settings = {external_id: "fallback_item_on_organization", external_account_code: "120", external_name: "fallback_item_on_organization"} + create_collection_mapping(:fallback_item, billing_entity: nil, settings:) + end + + let(:default_mapping_codes) do + { + add_on: {external_id: "add_on_on_billing_entity", external_account_code: "11", external_name: "add_on_on_billing_entity"}, + billable_metric: {external_id: "billable_metric_on_billing_entity", external_account_code: "12", external_name: "billable_metric_on_billing_entity"}, + minimum_commitment: {external_id: "commitment_on_billing_entity", external_account_code: "13", external_name: "commitment_on_billing_entity"}, + subscription: {external_id: "subscription_on_billing_entity", external_account_code: "14", external_name: "subscription_on_billing_entity"}, + account: {external_id: "account_on_billing_entity", external_account_code: "15", external_name: "account_on_billing_entity"}, + credit_note: {external_id: "credit_note_on_billing_entity", external_account_code: "16", external_name: "credit_note_on_billing_entity"}, + prepaid_credit: {external_id: "prepaid_credit_on_billing_entity", external_account_code: "17", external_name: "prepaid_credit_on_billing_entity"}, + tax: {external_id: "tax_on_billing_entity", external_account_code: "18", external_name: "tax_on_billing_entity"}, + coupon: {external_id: "coupon_on_billing_entity", external_account_code: "19", external_name: "coupon_on_billing_entity"}, + fallback_item: {external_id: "fallback_item_on_billing_entity", external_account_code: "20", external_name: "fallback_item_on_billing_entity"} + } + end + + before do + add_on_mapping_on_billing_entity + billable_metric_mapping_on_billing_entity + commitment_mapping_on_billing_entity + subscription_mapping_on_billing_entity + account_mapping_on_billing_entity + credit_note_mapping_on_billing_entity + prepaid_credit_mapping_on_billing_entity + tax_mapping_on_billing_entity + coupon_mapping_on_billing_entity + + add_on_mapping_on_organization + billable_metric_mapping_on_organization + commitment_mapping_on_organization + subscription_mapping_on_organization + account_mapping_on_organization + credit_note_mapping_on_organization + prepaid_credit_mapping_on_organization + tax_mapping_on_organization + coupon_mapping_on_organization + + fallback_item_on_billing_entity + + fallback_item_on_organization + + integration_customer + add_on_credit_note_item + billable_metric_credit_note_item + minimum_commitment_credit_note_item + subscription_credit_note_item + credit_note.reload + + payment + end + + def skip_mapping?(billing_entity) + create_mapping_for_billing_entity = (billing_entity.present? && mappings_on.include?(:billing_entity)) || + (billing_entity.blank? && mappings_on.include?(:organization)) + !create_mapping_for_billing_entity + end + + def skip_fallback_item?(billing_entity) + create_fallback_items_for_billing_entity = (billing_entity.present? && fallback_items_on.include?(:billing_entity)) || + (billing_entity.blank? && fallback_items_on.include?(:organization)) + !create_fallback_items_for_billing_entity + end + + def create_mapping(mappable_type, mappable_id, billing_entity: nil, settings: {}) + return if skip_mapping?(billing_entity) + + create("#{integration_type}_mapping", integration:, mappable_type:, mappable_id:, billing_entity:, settings:) + end + + def create_collection_mapping(mapping_type, billing_entity: nil, settings: {}) + return if mapping_type == :fallback_item && skip_fallback_item?(billing_entity) + return if mapping_type != :fallback_item && skip_mapping?(billing_entity) + + create("#{integration_type}_collection_mapping", integration:, billing_entity:, mapping_type:, settings:) + end + + context "when the mapping is on the billing entity" do + it "returns the payload body" do + expect(payload).to match build_expected_payload(default_mapping_codes) + end + end + + context "when the mapping is not on the billing entity but there are fallback items" do + let(:mappings_on) { [:organization] } + let(:fallback_items_on) { [:billing_entity] } + + it "returns the payload body" do + fallback = {external_id: "fallback_item_on_billing_entity", external_account_code: "20", external_name: "fallback_item_on_billing_entity"} + expect(payload).to match build_expected_payload({ + add_on: fallback, + billable_metric: fallback, + minimum_commitment: fallback, + subscription: fallback, + account: fallback, + credit_note: fallback, + prepaid_credit: fallback, + tax: fallback, + coupon: fallback + }) + end + end + + context "when the mapping is only on the organization" do + let(:mappings_on) { [:organization] } + let(:fallback_items_on) { [:organization] } + + it "returns the payload body" do + expect(payload).to match build_expected_payload({ + add_on: {external_id: "add_on_on_organization", external_account_code: "111", external_name: "add_on_on_organization"}, + billable_metric: {external_id: "billable_metric_on_organization", external_account_code: "112", external_name: "billable_metric_on_organization"}, + minimum_commitment: {external_id: "commitment_on_organization", external_account_code: "113", external_name: "commitment_on_organization"}, + subscription: {external_id: "subscription_on_organization", external_account_code: "114", external_name: "subscription_on_organization"}, + account: {external_id: "account_on_organization", external_account_code: "115", external_name: "account_on_organization"}, + credit_note: {external_id: "credit_note_on_organization", external_account_code: "116", external_name: "credit_note_on_organization"}, + prepaid_credit: {external_id: "prepaid_credit_on_organization", external_account_code: "117", external_name: "prepaid_credit_on_organization"}, + tax: {external_id: "tax_on_organization", external_account_code: "118", external_name: "tax_on_organization"}, + coupon: {external_id: "coupon_on_organization", external_account_code: "119", external_name: "coupon_on_organization"} + }) + end + end + + context "when there are only fallback items on the organization" do + let(:mappings_on) { [] } + let(:fallback_items_on) { [:organization] } + + it "returns the payload body" do + fallback = {external_id: "fallback_item_on_organization", external_account_code: "120", external_name: "fallback_item_on_organization"} + expect(payload).to match build_expected_payload({ + add_on: fallback, + billable_metric: fallback, + minimum_commitment: fallback, + subscription: fallback, + account: fallback, + credit_note: fallback, + prepaid_credit: fallback, + tax: fallback, + coupon: fallback + }) + end + end +end