diff --git a/app/models/dunning_campaign_threshold.rb b/app/models/dunning_campaign_threshold.rb index e499b60387a..7c4d3f56634 100644 --- a/app/models/dunning_campaign_threshold.rb +++ b/app/models/dunning_campaign_threshold.rb @@ -10,7 +10,7 @@ class DunningCampaignThreshold < ApplicationRecord validates :amount_cents, numericality: {greater_than_or_equal_to: 0} validates :currency, inclusion: {in: currency_list} - validates :currency, uniqueness: {scope: :dunning_campaign_id}, unless: :deleted_at + validates :currency, uniqueness: {scope: :dunning_campaign_id, conditions: -> { where(deleted_at: nil) }} default_scope -> { kept } end diff --git a/app/services/dunning_campaigns/update_service.rb b/app/services/dunning_campaigns/update_service.rb index 68fe77b2a75..344a85962be 100644 --- a/app/services/dunning_campaigns/update_service.rb +++ b/app/services/dunning_campaigns/update_service.rb @@ -15,22 +15,9 @@ def call return result.not_found_failure!(resource: "dunning_campaign") unless dunning_campaign ActiveRecord::Base.transaction do - dunning_campaign.name = params[:name] if params.key?(:name) - dunning_campaign.code = params[:code] if params.key?(:code) - dunning_campaign.description = params[:description] if params.key?(:description) - dunning_campaign.days_between_attempts = params[:days_between_attempts] if params.key?(:days_between_attempts) - dunning_campaign.max_attempts = params[:max_attempts] if params.key?(:max_attempts) - + dunning_campaign.assign_attributes(permitted_attributes) handle_thresholds if params.key?(:thresholds) - - unless params[:applied_to_organization].nil? - organization.dunning_campaigns.applied_to_organization - .update_all(applied_to_organization: false) # rubocop:disable Rails/SkipsModelValidations - - organization.reset_customers_last_dunning_campaign_attempt - - dunning_campaign.applied_to_organization = params[:applied_to_organization] - end + handle_applied_to_organization_update if params.key?(:applied_to_organization) dunning_campaign.save! end @@ -45,21 +32,79 @@ def call attr_reader :dunning_campaign, :organization, :params + def permitted_attributes + params.slice(:name, :code, :description, :days_between_attempts, :max_attempts) + end + def handle_thresholds + thresholds_updated = false + input_threshold_ids = params[:thresholds].map { |t| t[:id] }.compact # Delete thresholds not included in the payload - dunning_campaign.thresholds.where.not(id: input_threshold_ids).discard_all + discarded_thresholds = dunning_campaign + .thresholds + .where.not(id: input_threshold_ids) + .discard_all + + thresholds_updated = discarded_thresholds.present? # Update or create new thresholds from the input params[:thresholds].each do |threshold_input| - dunning_campaign.thresholds.find_or_initialize_by( + threshold = dunning_campaign.thresholds.find_or_initialize_by( id: threshold_input[:id] - ).update!( - amount_cents: threshold_input[:amount_cents], - currency: threshold_input[:currency] ) + + threshold.assign_attributes(threshold_input.to_h.slice(:amount_cents, :currency)) + + thresholds_updated ||= threshold.changed? && threshold.persisted? + threshold.save! end + + reset_customers_if_no_threshold_match if thresholds_updated + end + + def reset_customers_if_no_threshold_match + customers_applied_campaign = organization + .customers + .with_dunning_campaign_not_completed + .where(applied_dunning_campaign: dunning_campaign) + + customers_fallback_campaign = organization + .customers + .falling_back_to_default_dunning_campaign + .with_dunning_campaign_not_completed + + customers_to_reset = customers_applied_campaign.or(customers_fallback_campaign) + + customers_to_reset + .includes(:invoices) + .where(invoices: {payment_overdue: true}).find_each do |customer| + threshold_matches = dunning_campaign.thresholds.any? do |threshold| + threshold.currency == customer.currency && + customer.overdue_balance_cents >= threshold.amount_cents + end + + unless threshold_matches + customer.update!( + last_dunning_campaign_attempt: 0, + last_dunning_campaign_attempt_at: nil + ) + end + end + end + + def handle_applied_to_organization_update + dunning_campaign.applied_to_organization = params[:applied_to_organization] + + return unless dunning_campaign.applied_to_organization_changed? + + organization + .dunning_campaigns + .applied_to_organization + .update_all(applied_to_organization: false) # rubocop:disable Rails/SkipsModelValidations + + organization.reset_customers_last_dunning_campaign_attempt end end end diff --git a/spec/models/dunning_campaign_threshold_spec.rb b/spec/models/dunning_campaign_threshold_spec.rb index 4bfd8365cdd..8b09772914f 100644 --- a/spec/models/dunning_campaign_threshold_spec.rb +++ b/spec/models/dunning_campaign_threshold_spec.rb @@ -18,15 +18,14 @@ let(:dunning_campaign) { create(:dunning_campaign) } it "validates uniqueness of currency scoped to dunning_campaign_id excluding deleted records" do - create(:dunning_campaign_threshold, currency:, dunning_campaign:) + existing_record = create(:dunning_campaign_threshold, currency:, dunning_campaign:) new_record = build(:dunning_campaign_threshold, currency:, dunning_campaign:) expect(new_record).not_to be_valid expect(new_record.errors[:currency]).to include("value_already_exist") - # Records with deleted_at set should not conflict - deleted_record = create(:dunning_campaign_threshold, :deleted, currency:, dunning_campaign:) - expect(deleted_record).to be_valid + existing_record.discard + expect(new_record).to be_valid end end diff --git a/spec/services/dunning_campaigns/update_service_spec.rb b/spec/services/dunning_campaigns/update_service_spec.rb index 9c09a7fce8f..66d4a830003 100644 --- a/spec/services/dunning_campaigns/update_service_spec.rb +++ b/spec/services/dunning_campaigns/update_service_spec.rb @@ -79,6 +79,40 @@ ] end + let(:customer_completed) do + create( + :customer, + currency: dunning_campaign_threshold.currency, + applied_dunning_campaign: dunning_campaign, + last_dunning_campaign_attempt: 4, + last_dunning_campaign_attempt_at: 1.day.ago, + dunning_campaign_completed: true, + organization: organization + ) + end + let(:customer_defaulting) do + create( + :customer, + currency: dunning_campaign_threshold.currency, + applied_dunning_campaign: nil, + last_dunning_campaign_attempt: 4, + last_dunning_campaign_attempt_at: 1.day.ago, + dunning_campaign_completed: false, + organization: organization + ) + end + let(:customer_assigned) do + create( + :customer, + currency: dunning_campaign_threshold.currency, + applied_dunning_campaign: dunning_campaign, + last_dunning_campaign_attempt: 4, + last_dunning_campaign_attempt_at: 1.day.ago, + dunning_campaign_completed: false, + organization: organization + ) + end + it "updates the dunning campaign" do expect(result).to be_success expect(result.dunning_campaign.name).to eq(params[:name]) @@ -94,6 +128,250 @@ .to have_attributes({amount_cents: 5_55, currency: "CHF"}) end + shared_examples "resets customer last dunning campaign attempt fields" do |customer_name| + let(:customer) { send(customer_name) } + + before { customer } + + it "resets the customer's dunning campaign fields" do + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(result).to be_success + end + end + + shared_examples "does not reset customer last dunning campaign attempt fields" do |customer_name| + let(:customer) { send(customer_name) } + + before { customer } + + it "does not reset the customer's dunning campaign fields" do + expect { result && customer.reload } + .to not_change { customer.last_dunning_campaign_attempt } + .and not_change { customer.last_dunning_campaign_attempt_at&.to_i } + + expect(result).to be_success + end + end + + context "when threshold amount_cents changes and does not apply anymore to the customer" do + let(:thresholds_input) do + [ + { + id: dunning_campaign_threshold.id, + amount_cents: threshold_amount_cents, + currency: dunning_campaign_threshold.currency + } + ] + end + + let(:threshold_amount_cents) { 999_99 } + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: (threshold_amount_cents - 1), + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + include_examples "resets customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "resets customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer already completed the campaign" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_completed + end + end + + context "when threshold currency changes and does not apply anymore to the customer" do + let(:thresholds_input) do + [ + { + id: dunning_campaign_threshold.id, + amount_cents: dunning_campaign_threshold.amount_cents, + currency: "GBP" + } + ] + end + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: dunning_campaign_threshold.amount_cents + 1, + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + include_examples "resets customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "resets customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer already completed the campaign" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_completed + end + end + + context "when threshold amount_cents changes but it still applies to the customer" do + let(:thresholds_input) do + [ + { + id: dunning_campaign_threshold.id, + amount_cents: threshold_amount_cents, + currency: dunning_campaign_threshold.currency + } + ] + end + + let(:threshold_amount_cents) { 50_00 } + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: (threshold_amount_cents + 1), + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer already completed the campaign" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_completed + end + end + + context "when threshold currency changes but it still applies to the customer" do + let(:thresholds_input) do + [ + { + id: not_matching_threshold.id, + amount_cents: 999_99, + currency: "GBP" + }, + { + id: dunning_campaign_threshold.id, + amount_cents: dunning_campaign_threshold.amount_cents, + currency: dunning_campaign_threshold.currency + } + ] + end + + let(:not_matching_threshold) do + create(:dunning_campaign_threshold, dunning_campaign:, currency: "CHF") + end + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: dunning_campaign_threshold.amount_cents + 1, + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer already completed the campaign" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_completed + end + end + + context "when a threshold is discarded and the campaign does not apply anymore to the customer" do + let(:thresholds_input) { [] } # No thresholds remain. + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: (dunning_campaign_threshold.amount_cents + 1), + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + include_examples "resets customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "resets customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer already completed the campaign" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_completed + end + end + + context "when a threshold is discarded and replaced with one that still applies to the customer" do + let(:thresholds_input) do + [ + { + amount_cents: threshold_amount_cents, + currency: dunning_campaign_threshold.currency + } + ] + end + + let(:threshold_amount_cents) { 1_00 } + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: (threshold_amount_cents + 1), + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer already completed the campaign" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_completed + end + end + context "when the input does not include a thresholds" do let(:dunning_campaign_threshold_to_be_deleted) do create(:dunning_campaign_threshold, dunning_campaign:, currency: "EUR")