-
Notifications
You must be signed in to change notification settings - Fork 103
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(dunning): process dunning campaign attempt (#2748)
## Roadmap Task π 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're first automating the overdue balance payment request, before looking at individual invoices. ## Description This change introduces a new service to process a dunning campaign attempt --------- Co-authored-by: Vincent Pochet <vincent@getlago.com>
- Loading branch information
1 parent
a68edf4
commit d635a7d
Showing
3 changed files
with
257 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
# frozen_string_literal: true | ||
|
||
module DunningCampaigns | ||
class ProcessAttemptService < BaseService | ||
def initialize(customer:, dunning_campaign_threshold:) | ||
@customer = customer | ||
@dunning_campaign_threshold = dunning_campaign_threshold | ||
@dunning_campaign = dunning_campaign_threshold.dunning_campaign | ||
@organization = customer.organization | ||
|
||
super | ||
end | ||
|
||
def call | ||
return result unless organization.auto_dunning_enabled? | ||
return result unless applicable_dunning_campaign? | ||
return result unless dunning_campaign_threshold_reached? | ||
return result unless days_between_attempts_passed? | ||
return result if dunning_campaign_completed? | ||
|
||
ActiveRecord::Base.transaction do | ||
payment_request_result = PaymentRequests::CreateService.call( | ||
organization:, | ||
params: { | ||
external_customer_id: customer.external_id, | ||
lago_invoice_ids: overdue_invoices.pluck(:id) | ||
} | ||
).raise_if_error! | ||
|
||
customer.increment(:last_dunning_campaign_attempt) | ||
customer.last_dunning_campaign_attempt_at = Time.zone.now | ||
customer.save! | ||
|
||
result.customer = customer | ||
result.payment_request = payment_request_result.payment_request | ||
end | ||
|
||
result | ||
rescue ActiveRecord::RecordInvalid => e | ||
result.record_validation_failure!(record: e.record) | ||
end | ||
|
||
private | ||
|
||
attr_reader :customer, :dunning_campaign, :dunning_campaign_threshold, :organization | ||
|
||
def applicable_dunning_campaign? | ||
return false if customer.exclude_from_dunning_campaign? | ||
|
||
custom_campaign = customer.applied_dunning_campaign | ||
default_campaign = organization.applied_dunning_campaign | ||
|
||
custom_campaign == dunning_campaign || (!custom_campaign && default_campaign == dunning_campaign) | ||
end | ||
|
||
def dunning_campaign_threshold_reached? | ||
overdue_invoices.sum(:total_amount_cents) >= dunning_campaign_threshold.amount_cents | ||
end | ||
|
||
def days_between_attempts_passed? | ||
return true unless customer.last_dunning_campaign_attempt_at | ||
|
||
(customer.last_dunning_campaign_attempt_at + dunning_campaign.days_between_attempts.days).past? | ||
end | ||
|
||
def dunning_campaign_completed? | ||
customer.last_dunning_campaign_attempt >= dunning_campaign.max_attempts | ||
end | ||
|
||
def overdue_invoices | ||
customer | ||
.invoices | ||
.payment_overdue | ||
.where(currency: dunning_campaign_threshold.currency) | ||
end | ||
end | ||
end |
178 changes: 178 additions & 0 deletions
178
spec/services/dunning_campaigns/process_attempt_service_spec.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
# frozen_string_literal: true | ||
|
||
require "rails_helper" | ||
|
||
RSpec.describe DunningCampaigns::ProcessAttemptService, type: :service, aggregate_failures: true do | ||
subject(:result) { described_class.call(customer:, dunning_campaign_threshold:) } | ||
|
||
let(:customer) { create :customer, organization:, currency: } | ||
let(:organization) { create :organization } | ||
let(:currency) { "EUR" } | ||
let(:dunning_campaign) { create :dunning_campaign, organization:, applied_to_organization: true } | ||
let(:dunning_campaign_threshold) do | ||
create :dunning_campaign_threshold, dunning_campaign:, currency:, amount_cents: 99_00 | ||
end | ||
|
||
let(:payment_request) { create :payment_request, organization: } | ||
|
||
let(:payment_request_result) do | ||
BaseService::Result.new.tap do |result| | ||
result.payment_request = payment_request | ||
result.customer = customer | ||
end | ||
end | ||
|
||
before do | ||
allow(PaymentRequests::CreateService) | ||
.to receive(:call) | ||
.and_return(payment_request_result) | ||
end | ||
|
||
context "when premium features are enabled" do | ||
let(:organization) { create :organization, premium_integrations: %w[auto_dunning] } | ||
|
||
let(:invoice_1) { create :invoice, organization:, customer:, currency:, payment_overdue: false } | ||
let(:invoice_2) { create :invoice, organization:, customer:, currency:, payment_overdue: true, total_amount_cents: 99_00 } | ||
let(:invoice_3) { create :invoice, organization:, customer:, currency: "USD", payment_overdue: true } | ||
let(:invoice_4) { create :invoice, currency:, payment_overdue: true } | ||
|
||
around { |test| lago_premium!(&test) } | ||
|
||
before do | ||
invoice_1 | ||
invoice_2 | ||
invoice_3 | ||
invoice_4 | ||
end | ||
|
||
it "returns a successful result with customer and payment request object" do | ||
expect(result).to be_success | ||
expect(result.customer).to eq customer | ||
expect(result.payment_request).to eq payment_request | ||
end | ||
|
||
it "creates a payment request with customer overdue invoices" do | ||
result | ||
|
||
expect(PaymentRequests::CreateService) | ||
.to have_received(:call) | ||
.with( | ||
organization:, | ||
params: { | ||
external_customer_id: customer.external_id, | ||
lago_invoice_ids: [invoice_2.id] | ||
} | ||
) | ||
end | ||
|
||
it "updates customer last dunning attempt data" do | ||
freeze_time do | ||
expect { result } | ||
.to change(customer.reload, :last_dunning_campaign_attempt).by(1) | ||
.and change(customer.reload, :last_dunning_campaign_attempt_at).to(Time.zone.now) | ||
end | ||
end | ||
|
||
context "when the campaign threshold is not reached" do | ||
let(:dunning_campaign_threshold) do | ||
create :dunning_campaign_threshold, dunning_campaign:, currency:, amount_cents: 99_01 | ||
end | ||
|
||
it "does nothing" do | ||
result | ||
expect(PaymentRequests::CreateService).not_to have_received(:call) | ||
end | ||
end | ||
|
||
context "when the campaign is not applicable anymore" do | ||
let(:customer) do | ||
create :customer, organization:, currency:, applied_dunning_campaign: | ||
end | ||
|
||
let(:applied_dunning_campaign) { create :dunning_campaign, organization: } | ||
let(:applied_dunning_campaign_threshold) do | ||
create( | ||
:dunning_campaign_threshold, | ||
dunning_campaign: applied_dunning_campaign, | ||
currency:, | ||
amount_cents: 10_00 | ||
) | ||
end | ||
|
||
it "does nothing" do | ||
result | ||
expect(PaymentRequests::CreateService).not_to have_received(:call) | ||
end | ||
end | ||
|
||
context "when the customer is excluded from auto dunning" do | ||
let(:customer) do | ||
create :customer, organization:, currency:, exclude_from_dunning_campaign: true | ||
end | ||
|
||
it "does nothing" do | ||
result | ||
expect(PaymentRequests::CreateService).not_to have_received(:call) | ||
end | ||
end | ||
|
||
context "when the customer reaches dunning campaign max attempts" do | ||
let(:customer) do | ||
create( | ||
:customer, | ||
organization:, | ||
currency:, | ||
last_dunning_campaign_attempt: dunning_campaign.max_attempts | ||
) | ||
end | ||
|
||
it "does nothing" do | ||
result | ||
expect(PaymentRequests::CreateService).not_to have_received(:call) | ||
end | ||
end | ||
|
||
context "when days between attempts has not passed" do | ||
let(:customer) do | ||
create( | ||
:customer, | ||
organization:, | ||
currency:, | ||
last_dunning_campaign_attempt_at: 9.days.ago | ||
) | ||
end | ||
|
||
let(:dunning_campaign) do | ||
create( | ||
:dunning_campaign, | ||
organization:, | ||
applied_to_organization: true, | ||
days_between_attempts: 10 | ||
) | ||
end | ||
|
||
it "does nothing" do | ||
result | ||
expect(PaymentRequests::CreateService).not_to have_received(:call) | ||
end | ||
end | ||
|
||
context "when payment request creation fails" do | ||
before do | ||
payment_request_result.service_failure!(code: "error", message: "failure") | ||
end | ||
|
||
it "does not update customer last dunning campaign attempt data" do | ||
expect { result } | ||
.to not_change(customer.reload, :last_dunning_campaign_attempt) | ||
.and not_change(customer.reload, :last_dunning_campaign_attempt_at) | ||
.and raise_error(BaseService::ServiceFailure) | ||
end | ||
end | ||
end | ||
|
||
it "does nothing" do | ||
result | ||
expect(PaymentRequests::CreateService).not_to have_received(:call) | ||
end | ||
end |