Skip to content

Commit

Permalink
feat(dunning): process dunning campaign attempt (#2748)
Browse files Browse the repository at this point in the history
## 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
ancorcruz and vincent-pochet authored Oct 29, 2024
1 parent a68edf4 commit d635a7d
Show file tree
Hide file tree
Showing 3 changed files with 257 additions and 0 deletions.
2 changes: 2 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class Organization < ApplicationRecord

has_one :applied_dunning_campaign, -> { where(applied_to_organization: true) }, class_name: "DunningCampaign"

has_one :applied_dunning_campaign, -> { where(applied_to_organization: true) }, class_name: "DunningCampaign"

has_one_attached :logo

DOCUMENT_NUMBERINGS = [
Expand Down
77 changes: 77 additions & 0 deletions app/services/dunning_campaigns/process_attempt_service.rb
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 spec/services/dunning_campaigns/process_attempt_service_spec.rb
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

0 comments on commit d635a7d

Please sign in to comment.