Skip to content

Commit

Permalink
feat(dunning): reset customer dunning campaign last attempt data on t…
Browse files Browse the repository at this point in the history
…hreshold changes (#2861)

## Roadmap

👉 https://getlago.canny.io/feature-requests/p/set-up-payment-retry-logic

👉
https://getlago.canny.io/feature-requests/p/send-reminders-for-overdue-invoices

 ## Context

We want to automate dunning process so that our users don't have to look
at each customer to maximize their chances of being paid retrying
payments of overdue balances and sending email reminders.

We are extending dunning campaigns management to edit and delete
campaigns.

 ## Description

When a dunning campaign threshold changes, it resets customers
last_dunning_campaign_attempt fields with the updated campaign
applicable (through direct association or falling back to it through the
organization default) when the thresholds does not match anymore
(currency and/or amount_cents changed or deletion of the threshold).
  • Loading branch information
ancorcruz authored Nov 26, 2024
1 parent 00ab1be commit 5a97420
Show file tree
Hide file tree
Showing 4 changed files with 347 additions and 25 deletions.
2 changes: 1 addition & 1 deletion app/models/dunning_campaign_threshold.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 65 additions & 20 deletions app/services/dunning_campaigns/update_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
7 changes: 3 additions & 4 deletions spec/models/dunning_campaign_threshold_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading

0 comments on commit 5a97420

Please sign in to comment.