From 0bf1bef30d02bb0b87ea55277d903c1c9db08b2b Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Mon, 28 Oct 2024 13:29:36 +0000 Subject: [PATCH 1/8] WIP: add service to process dunning campaign attempt --- .../process_attempt_service.rb | 59 ++++++++++++ .../process_attempt_service_spec.rb | 94 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 app/services/dunning_campaigns/process_attempt_service.rb create mode 100644 spec/services/dunning_campaigns/process_attempt_service_spec.rb diff --git a/app/services/dunning_campaigns/process_attempt_service.rb b/app/services/dunning_campaigns/process_attempt_service.rb new file mode 100644 index 00000000000..6c122b3f1a3 --- /dev/null +++ b/app/services/dunning_campaigns/process_attempt_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module DunningCampaigns + class ProcessAttemptService < BaseService + def initialize(customer:, dunning_campaign_threshold:) + @customer = customer + @dunning_campaign_threshold = dunning_campaign_threshold + super + end + + def call + return unless organization.auto_dunning_enabled? + # TODO: ensure the campaign is still applicable to customer + # TODO: ensure campaign thresold is still meet + # TODO: ensure customer does not use all attempts + # TODO: ensure time now > last attempt + delay + + 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_threshold + + def organization + customer.organization + end + + def dunning_campaign + dunning_campaign_threshold.dunning_campaign + end + + def overdue_invoices + customer + .invoices + .payment_overdue + .where(currency: dunning_campaign_threshold.currency) + end + end +end diff --git a/spec/services/dunning_campaigns/process_attempt_service_spec.rb b/spec/services/dunning_campaigns/process_attempt_service_spec.rb new file mode 100644 index 00000000000..d5bb7460a54 --- /dev/null +++ b/spec/services/dunning_campaigns/process_attempt_service_spec.rb @@ -0,0 +1,94 @@ +# 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: } + 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 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 From 340374616983a5b230e96b8e4d6d1b0eb960db0a Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Mon, 28 Oct 2024 14:03:42 +0000 Subject: [PATCH 2/8] WIP: add organization applied dunning relationship --- app/models/organization.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/organization.rb b/app/models/organization.rb index 7de77e34f12..64f7bfc33eb 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -46,6 +46,8 @@ class Organization < ApplicationRecord has_many :netsuite_integrations, class_name: 'Integrations::NetsuiteIntegration' has_many :xero_integrations, class_name: 'Integrations::XeroIntegration' + has_one :applied_dunning_campaign, -> { where(applied_to_organization: true) }, class_name: "DunningCampaign" + has_one_attached :logo DOCUMENT_NUMBERINGS = [ From df97c59f590fa6166e35decbc456d9a2d0d8ab48 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Mon, 28 Oct 2024 14:04:03 +0000 Subject: [PATCH 3/8] Clean private methods --- .../dunning_campaigns/process_attempt_service.rb | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/app/services/dunning_campaigns/process_attempt_service.rb b/app/services/dunning_campaigns/process_attempt_service.rb index 6c122b3f1a3..fd94d1f6606 100644 --- a/app/services/dunning_campaigns/process_attempt_service.rb +++ b/app/services/dunning_campaigns/process_attempt_service.rb @@ -5,6 +5,9 @@ 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 @@ -39,15 +42,7 @@ def call private - attr_reader :customer, :dunning_campaign_threshold - - def organization - customer.organization - end - - def dunning_campaign - dunning_campaign_threshold.dunning_campaign - end + attr_reader :customer, :dunning_campaign, :dunning_campaign_threshold, :organization def overdue_invoices customer From 087de776732f5c1a5607dfd1e3797e52cb121d92 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Mon, 28 Oct 2024 14:20:18 +0000 Subject: [PATCH 4/8] do not process dunning campaign attempt if campaign does not match also for excluded customers --- .../process_attempt_service.rb | 11 ++++- .../process_attempt_service_spec.rb | 45 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/services/dunning_campaigns/process_attempt_service.rb b/app/services/dunning_campaigns/process_attempt_service.rb index fd94d1f6606..52dc4746e5b 100644 --- a/app/services/dunning_campaigns/process_attempt_service.rb +++ b/app/services/dunning_campaigns/process_attempt_service.rb @@ -13,7 +13,7 @@ def initialize(customer:, dunning_campaign_threshold:) def call return unless organization.auto_dunning_enabled? - # TODO: ensure the campaign is still applicable to customer + return unless applicable_dunning_campaign? # TODO: ensure campaign thresold is still meet # TODO: ensure customer does not use all attempts # TODO: ensure time now > last attempt + delay @@ -44,6 +44,15 @@ def call 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 overdue_invoices customer .invoices diff --git a/spec/services/dunning_campaigns/process_attempt_service_spec.rb b/spec/services/dunning_campaigns/process_attempt_service_spec.rb index d5bb7460a54..b69599e39c9 100644 --- a/spec/services/dunning_campaigns/process_attempt_service_spec.rb +++ b/spec/services/dunning_campaigns/process_attempt_service_spec.rb @@ -8,7 +8,7 @@ let(:customer) { create :customer, organization:, currency: } let(:organization) { create :organization } let(:currency) { "EUR" } - let(:dunning_campaign) { create :dunning_campaign, organization: } + 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 @@ -73,6 +73,49 @@ 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 + + xit "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 payment request creation fails" do before do payment_request_result.service_failure!(code: "error", message: "failure") From ee81ab25dbe33c1aba1001b527ac0b9fa6ab7d76 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Mon, 28 Oct 2024 14:41:09 +0000 Subject: [PATCH 5/8] Does not process dunning attempt if threshold is not reached --- app/services/dunning_campaigns/process_attempt_service.rb | 6 +++++- .../dunning_campaigns/process_attempt_service_spec.rb | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/services/dunning_campaigns/process_attempt_service.rb b/app/services/dunning_campaigns/process_attempt_service.rb index 52dc4746e5b..41079e24e64 100644 --- a/app/services/dunning_campaigns/process_attempt_service.rb +++ b/app/services/dunning_campaigns/process_attempt_service.rb @@ -14,7 +14,7 @@ def initialize(customer:, dunning_campaign_threshold:) def call return unless organization.auto_dunning_enabled? return unless applicable_dunning_campaign? - # TODO: ensure campaign thresold is still meet + return unless dunning_campaign_threshold_reached? # TODO: ensure customer does not use all attempts # TODO: ensure time now > last attempt + delay @@ -53,6 +53,10 @@ def applicable_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 overdue_invoices customer .invoices diff --git a/spec/services/dunning_campaigns/process_attempt_service_spec.rb b/spec/services/dunning_campaigns/process_attempt_service_spec.rb index b69599e39c9..78daa981732 100644 --- a/spec/services/dunning_campaigns/process_attempt_service_spec.rb +++ b/spec/services/dunning_campaigns/process_attempt_service_spec.rb @@ -78,7 +78,7 @@ create :dunning_campaign_threshold, dunning_campaign:, currency:, amount_cents: 99_01 end - xit "does nothing" do + it "does nothing" do result expect(PaymentRequests::CreateService).not_to have_received(:call) end From 824ad4cbe2be8355ebad50a657dee23698c8b059 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Mon, 28 Oct 2024 14:47:35 +0000 Subject: [PATCH 6/8] Do not run dunning campaign attempt if max attempts reached --- .../dunning_campaigns/process_attempt_service.rb | 6 +++++- .../process_attempt_service_spec.rb | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/services/dunning_campaigns/process_attempt_service.rb b/app/services/dunning_campaigns/process_attempt_service.rb index 41079e24e64..1ba6e85c1d9 100644 --- a/app/services/dunning_campaigns/process_attempt_service.rb +++ b/app/services/dunning_campaigns/process_attempt_service.rb @@ -15,7 +15,7 @@ def call return unless organization.auto_dunning_enabled? return unless applicable_dunning_campaign? return unless dunning_campaign_threshold_reached? - # TODO: ensure customer does not use all attempts + return if dunning_campaign_completed? # TODO: ensure time now > last attempt + delay ActiveRecord::Base.transaction do @@ -57,6 +57,10 @@ def dunning_campaign_threshold_reached? overdue_invoices.sum(:total_amount_cents) >= dunning_campaign_threshold.amount_cents end + def dunning_campaign_completed? + customer.last_dunning_campaign_attempt >= dunning_campaign.max_attempts + end + def overdue_invoices customer .invoices diff --git a/spec/services/dunning_campaigns/process_attempt_service_spec.rb b/spec/services/dunning_campaigns/process_attempt_service_spec.rb index 78daa981732..61f1500f4d9 100644 --- a/spec/services/dunning_campaigns/process_attempt_service_spec.rb +++ b/spec/services/dunning_campaigns/process_attempt_service_spec.rb @@ -116,6 +116,22 @@ 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 payment request creation fails" do before do payment_request_result.service_failure!(code: "error", message: "failure") From 9901c5f8eb5ae1b1f9d9beccd35221e7adc0117a Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Mon, 28 Oct 2024 14:57:02 +0000 Subject: [PATCH 7/8] Do not process dunning campaign attempt... if days between attempts has not passed --- .../process_attempt_service.rb | 8 +++++- .../process_attempt_service_spec.rb | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/services/dunning_campaigns/process_attempt_service.rb b/app/services/dunning_campaigns/process_attempt_service.rb index 1ba6e85c1d9..b3c2f694708 100644 --- a/app/services/dunning_campaigns/process_attempt_service.rb +++ b/app/services/dunning_campaigns/process_attempt_service.rb @@ -15,8 +15,8 @@ def call return unless organization.auto_dunning_enabled? return unless applicable_dunning_campaign? return unless dunning_campaign_threshold_reached? + return unless days_between_attempts_passed? return if dunning_campaign_completed? - # TODO: ensure time now > last attempt + delay ActiveRecord::Base.transaction do payment_request_result = PaymentRequests::CreateService.call( @@ -57,6 +57,12 @@ 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 diff --git a/spec/services/dunning_campaigns/process_attempt_service_spec.rb b/spec/services/dunning_campaigns/process_attempt_service_spec.rb index 61f1500f4d9..9a547f4a26d 100644 --- a/spec/services/dunning_campaigns/process_attempt_service_spec.rb +++ b/spec/services/dunning_campaigns/process_attempt_service_spec.rb @@ -132,6 +132,31 @@ 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") From c2da2c54b6314d2fcd43cb22fbf8d993a4606f5a Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Tue, 29 Oct 2024 12:00:35 +0000 Subject: [PATCH 8/8] Update app/services/dunning_campaigns/process_attempt_service.rb Co-authored-by: Vincent Pochet --- .../dunning_campaigns/process_attempt_service.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/services/dunning_campaigns/process_attempt_service.rb b/app/services/dunning_campaigns/process_attempt_service.rb index b3c2f694708..be4cf323071 100644 --- a/app/services/dunning_campaigns/process_attempt_service.rb +++ b/app/services/dunning_campaigns/process_attempt_service.rb @@ -12,11 +12,11 @@ def initialize(customer:, dunning_campaign_threshold:) end def call - return unless organization.auto_dunning_enabled? - return unless applicable_dunning_campaign? - return unless dunning_campaign_threshold_reached? - return unless days_between_attempts_passed? - return if dunning_campaign_completed? + 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(