From cdeac9a468fd5b874e417f843aed067a6091c395 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Tue, 19 Nov 2024 11:44:53 +0000 Subject: [PATCH] feat(dunning): Add deleted_at to dunning campaign threshold (#2831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 are extending dunning campaigns management to edit and delete campaigns. ## Description This change adds capabilities to soft delete dunning campaign thresholds --- app/models/dunning_campaign_threshold.rb | 10 +++++-- ...leted_at_to_dunning_campaign_thresholds.rb | 11 +++++++ ...ue_index_on_dunning_campaign_thresholds.rb | 15 ++++++++++ db/schema.rb | 6 ++-- .../models/dunning_campaign_threshold_spec.rb | 30 +++++++++++++++++++ 5 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20241118165935_add_deleted_at_to_dunning_campaign_thresholds.rb create mode 100644 db/migrate/20241119110219_update_unique_index_on_dunning_campaign_thresholds.rb diff --git a/app/models/dunning_campaign_threshold.rb b/app/models/dunning_campaign_threshold.rb index f890eb5faae..e499b60387a 100644 --- a/app/models/dunning_campaign_threshold.rb +++ b/app/models/dunning_campaign_threshold.rb @@ -3,12 +3,16 @@ class DunningCampaignThreshold < ApplicationRecord include Currencies include PaperTrailTraceable + include Discard::Model + self.discard_column = :deleted_at belongs_to :dunning_campaign validates :amount_cents, numericality: {greater_than_or_equal_to: 0} validates :currency, inclusion: {in: currency_list} - validates :currency, uniqueness: {scope: :dunning_campaign_id} + validates :currency, uniqueness: {scope: :dunning_campaign_id}, unless: :deleted_at + + default_scope -> { kept } end # == Schema Information @@ -18,13 +22,15 @@ class DunningCampaignThreshold < ApplicationRecord # id :uuid not null, primary key # amount_cents :bigint not null # currency :string not null +# deleted_at :datetime # created_at :datetime not null # updated_at :datetime not null # dunning_campaign_id :uuid not null # # Indexes # -# idx_on_dunning_campaign_id_currency_fbf233b2ae (dunning_campaign_id,currency) UNIQUE +# idx_on_dunning_campaign_id_currency_fbf233b2ae (dunning_campaign_id,currency) UNIQUE WHERE (deleted_at IS NULL) +# index_dunning_campaign_thresholds_on_deleted_at (deleted_at) # index_dunning_campaign_thresholds_on_dunning_campaign_id (dunning_campaign_id) # # Foreign Keys diff --git a/db/migrate/20241118165935_add_deleted_at_to_dunning_campaign_thresholds.rb b/db/migrate/20241118165935_add_deleted_at_to_dunning_campaign_thresholds.rb new file mode 100644 index 00000000000..354389a8e6a --- /dev/null +++ b/db/migrate/20241118165935_add_deleted_at_to_dunning_campaign_thresholds.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddDeletedAtToDunningCampaignThresholds < ActiveRecord::Migration[7.1] + def change + add_column :dunning_campaign_thresholds, :deleted_at, :timestamp + + safety_assured do + add_index :dunning_campaign_thresholds, :deleted_at + end + end +end diff --git a/db/migrate/20241119110219_update_unique_index_on_dunning_campaign_thresholds.rb b/db/migrate/20241119110219_update_unique_index_on_dunning_campaign_thresholds.rb new file mode 100644 index 00000000000..93dfec89810 --- /dev/null +++ b/db/migrate/20241119110219_update_unique_index_on_dunning_campaign_thresholds.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class UpdateUniqueIndexOnDunningCampaignThresholds < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + remove_index :dunning_campaign_thresholds, %i[dunning_campaign_id currency], unique: true, algorithm: :concurrently + + add_index :dunning_campaign_thresholds, + [:dunning_campaign_id, :currency], + unique: true, + where: "deleted_at IS NULL", + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 177d02c4bf4..2d95355ce3f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_11_07_093418) do +ActiveRecord::Schema[7.1].define(version: 2024_11_19_110219) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -535,7 +535,9 @@ t.bigint "amount_cents", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["dunning_campaign_id", "currency"], name: "idx_on_dunning_campaign_id_currency_fbf233b2ae", unique: true + t.datetime "deleted_at", precision: nil + t.index ["deleted_at"], name: "index_dunning_campaign_thresholds_on_deleted_at" + t.index ["dunning_campaign_id", "currency"], name: "idx_on_dunning_campaign_id_currency_fbf233b2ae", unique: true, where: "(deleted_at IS NULL)" t.index ["dunning_campaign_id"], name: "index_dunning_campaign_thresholds_on_dunning_campaign_id" end diff --git a/spec/models/dunning_campaign_threshold_spec.rb b/spec/models/dunning_campaign_threshold_spec.rb index 14008f6759d..4bfd8365cdd 100644 --- a/spec/models/dunning_campaign_threshold_spec.rb +++ b/spec/models/dunning_campaign_threshold_spec.rb @@ -12,4 +12,34 @@ it { is_expected.to validate_numericality_of(:amount_cents).is_greater_than_or_equal_to(0) } it { is_expected.to validate_inclusion_of(:currency).in_array(described_class.currency_list) } it { is_expected.to validate_uniqueness_of(:currency).scoped_to(:dunning_campaign_id) } + + describe "currency validation" do + let(:currency) { "EUR" } + 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:) + 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 + end + end + + describe "default scope" do + let(:deleted_dunning_campaign_threshold) do + create(:dunning_campaign_threshold, :deleted) + end + + before { deleted_dunning_campaign_threshold } + + it "only returns non-deleted dunning_campaign_threshold objects" do + expect(described_class.all).to eq([]) + expect(described_class.with_discarded).to eq([deleted_dunning_campaign_threshold]) + end + end end