Skip to content

Commit

Permalink
feat(dunning): update customer to fallback to organization dunning (#…
Browse files Browse the repository at this point in the history
…2733)

## 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

Customer update service accepts `applied_dunning_campaign_id` as nil to
unset its applied campaign and fallback to organization's default
dunning campaign.
  • Loading branch information
ancorcruz authored Oct 29, 2024
1 parent 296e988 commit a68edf4
Show file tree
Hide file tree
Showing 8 changed files with 87 additions and 2 deletions.
2 changes: 2 additions & 0 deletions app/graphql/types/organizations/current_organization_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class CurrentOrganizationType < BaseOrganizationType
field :gocardless_payment_providers, [Types::PaymentProviders::Gocardless], permission: 'organization:integrations:view'
field :stripe_payment_providers, [Types::PaymentProviders::Stripe], permission: 'organization:integrations:view'

field :applied_dunning_campaign, Types::DunningCampaigns::Object

def webhook_url
object.webhook_endpoints.map(&:webhook_url).first
end
Expand Down
2 changes: 2 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
12 changes: 10 additions & 2 deletions app/services/customers/update_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,7 @@ def call

if customer.organization.auto_dunning_enabled?
if args.key?(:applied_dunning_campaign_id)
dunning_campaign = DunningCampaign.find(args[:applied_dunning_campaign_id])
customer.applied_dunning_campaign = dunning_campaign
customer.applied_dunning_campaign = applied_dunning_campaign
customer.exclude_from_dunning_campaign = false
end

Expand Down Expand Up @@ -158,6 +157,8 @@ def call
result
rescue ActiveRecord::RecordInvalid => e
result.record_validation_failure!(record: e.record)
rescue ActiveRecord::RecordNotFound => e
result.not_found_failure!(resource: e.model.underscore)
rescue BaseService::FailedResult => e
e.result
end
Expand Down Expand Up @@ -240,5 +241,12 @@ def update_adyen_customer(customer, billing_configuration)
# NOTE: Create service is modifying an other instance of the provider customer
customer.adyen_customer&.reload
end

def applied_dunning_campaign
return customer.applied_dunning_campaign unless args.key?(:applied_dunning_campaign_id)
return unless args[:applied_dunning_campaign_id]

DunningCampaign.find(args[:applied_dunning_campaign_id])
end
end
end
1 change: 1 addition & 0 deletions schema.graphql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,6 @@
it { is_expected.to have_field(:adyen_payment_providers).of_type('[AdyenProvider!]').with_permission('organization:integrations:view') }
it { is_expected.to have_field(:gocardless_payment_providers).of_type('[GocardlessProvider!]').with_permission('organization:integrations:view') }
it { is_expected.to have_field(:stripe_payment_providers).of_type('[StripeProvider!]').with_permission('organization:integrations:view') }

it { is_expected.to have_field(:applied_dunning_campaign).of_type("DunningCampaign") }
end
2 changes: 2 additions & 0 deletions spec/models/organization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
it { is_expected.to have_many(:dunning_campaigns) }
it { is_expected.to have_many(:daily_usages) }

it { is_expected.to have_one(:applied_dunning_campaign).conditions(applied_to_organization: true) }

it { is_expected.to validate_inclusion_of(:default_currency).in_array(described_class.currency_list) }

it 'sets the default value to true' do
Expand Down
54 changes: 54 additions & 0 deletions spec/services/customers/update_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,60 @@
expect(customers_service.call).to be_success
end
end

context "with applied_dunning_campaign_id nil" do
let(:customer) do
create(
:customer,
organization:,
applied_dunning_campaign: dunning_campaign,
exclude_from_dunning_campaign: false,
last_dunning_campaign_attempt: 3,
last_dunning_campaign_attempt_at: 2.days.ago
)
end

let(:update_args) { {applied_dunning_campaign_id: nil} }

it "updates auto dunning config", :aggregate_failures do
expect { customers_service.call }
.to change(customer, :applied_dunning_campaign_id).to(nil)
.and not_change(customer, :exclude_from_dunning_campaign)
.and change(customer, :last_dunning_campaign_attempt).to(0)
.and change(customer, :last_dunning_campaign_attempt_at).to(nil)

expect(customers_service.call).to be_success
end
end

context "when dunning campaign can not be found" do
let(:customer) do
create(
:customer,
organization:,
applied_dunning_campaign: dunning_campaign,
exclude_from_dunning_campaign: false,
last_dunning_campaign_attempt: 3,
last_dunning_campaign_attempt_at: 2.days.ago
)
end

let(:update_args) { {applied_dunning_campaign_id: "not_found_id"} }

it "does not update auto dunning config", :aggregate_failures do
expect { customers_service.call }
.to not_change(customer, :applied_dunning_campaign_id)
.and not_change(customer, :exclude_from_dunning_campaign)
.and not_change(customer, :last_dunning_campaign_attempt)
.and not_change(customer, :last_dunning_campaign_attempt_at)

result = customers_service.call

expect(result).not_to be_success
expect(result.error).to be_a(BaseService::NotFoundFailure)
expect(result.error.error_code).to eq("dunning_campaign_not_found")
end
end
end
end
end
Expand Down

0 comments on commit a68edf4

Please sign in to comment.