diff --git a/app/models/dunning_campaign.rb b/app/models/dunning_campaign.rb index 46c9856c064..b9cace58011 100644 --- a/app/models/dunning_campaign.rb +++ b/app/models/dunning_campaign.rb @@ -2,6 +2,8 @@ class DunningCampaign < ApplicationRecord include PaperTrailTraceable + include Discard::Model + self.discard_column = :deleted_at ORDERS = %w[name code].freeze @@ -15,8 +17,9 @@ class DunningCampaign < ApplicationRecord validates :name, presence: true validates :days_between_attempts, numericality: {greater_than: 0} validates :max_attempts, numericality: {greater_than: 0} - validates :code, uniqueness: {scope: :organization_id} + validates :code, uniqueness: {scope: :organization_id}, unless: :deleted_at + default_scope -> { kept } scope :applied_to_organization, -> { where(applied_to_organization: true) } scope :with_currency_threshold, ->(currencies) { joins(:thresholds) @@ -37,6 +40,7 @@ def self.ransackable_attributes(_auth_object = nil) # applied_to_organization :boolean default(FALSE), not null # code :string not null # days_between_attempts :integer default(1), not null +# deleted_at :datetime # description :text # max_attempts :integer default(1), not null # name :string not null @@ -46,8 +50,9 @@ def self.ransackable_attributes(_auth_object = nil) # # Indexes # +# index_dunning_campaigns_on_deleted_at (deleted_at) # index_dunning_campaigns_on_organization_id (organization_id) -# index_dunning_campaigns_on_organization_id_and_code (organization_id,code) UNIQUE +# index_dunning_campaigns_on_organization_id_and_code (organization_id,code) UNIQUE WHERE (deleted_at IS NULL) # index_unique_applied_to_organization_per_organization (organization_id) UNIQUE WHERE (applied_to_organization = true) # # Foreign Keys diff --git a/db/migrate/20241120085057_add_deleted_at_to_dunning_campaigns.rb b/db/migrate/20241120085057_add_deleted_at_to_dunning_campaigns.rb new file mode 100644 index 00000000000..9a502e33663 --- /dev/null +++ b/db/migrate/20241120085057_add_deleted_at_to_dunning_campaigns.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddDeletedAtToDunningCampaigns < ActiveRecord::Migration[7.1] + def change + add_column :dunning_campaigns, :deleted_at, :timestamp + + safety_assured do + add_index :dunning_campaigns, :deleted_at + end + end +end diff --git a/db/migrate/20241120090305_update_unique_index_on_dunning_campaigns.rb b/db/migrate/20241120090305_update_unique_index_on_dunning_campaigns.rb new file mode 100644 index 00000000000..05e3872d6a7 --- /dev/null +++ b/db/migrate/20241120090305_update_unique_index_on_dunning_campaigns.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class UpdateUniqueIndexOnDunningCampaigns < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + remove_index :dunning_campaigns, %i[organization_id code], unique: true, algorithm: :concurrently + + add_index :dunning_campaigns, + [:organization_id, :code], + unique: true, + where: "deleted_at IS NULL", + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d95355ce3f..c5ef09b7198 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_19_110219) do +ActiveRecord::Schema[7.1].define(version: 2024_11_20_090305) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -551,7 +551,9 @@ t.integer "max_attempts", default: 1, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["organization_id", "code"], name: "index_dunning_campaigns_on_organization_id_and_code", unique: true + t.datetime "deleted_at", precision: nil + t.index ["deleted_at"], name: "index_dunning_campaigns_on_deleted_at" + t.index ["organization_id", "code"], name: "index_dunning_campaigns_on_organization_id_and_code", unique: true, where: "(deleted_at IS NULL)" t.index ["organization_id"], name: "index_dunning_campaigns_on_organization_id" t.index ["organization_id"], name: "index_unique_applied_to_organization_per_organization", unique: true, where: "(applied_to_organization = true)" end diff --git a/spec/models/dunning_campaign_spec.rb b/spec/models/dunning_campaign_spec.rb index 36b8e2f25ef..204bfe8ada8 100644 --- a/spec/models/dunning_campaign_spec.rb +++ b/spec/models/dunning_campaign_spec.rb @@ -17,4 +17,32 @@ it { is_expected.to validate_numericality_of(:max_attempts).is_greater_than(0) } it { is_expected.to validate_uniqueness_of(:code).scoped_to(:organization_id) } + + describe "code validation" do + let(:code) { "123456" } + let(:organization) { create(:organization) } + + it "validates uniqueness of code scoped to organization_id excluding deleted records" do + create(:dunning_campaign, code:, organization:) + new_record = build(:dunning_campaign, code:, organization:) + + expect(new_record).not_to be_valid + expect(new_record.errors[:code]).to include("value_already_exist") + + # Records with deleted_at set should not conflict + deleted_record = create(:dunning_campaign, :deleted, code:, organization:) + expect(deleted_record).to be_valid + end + end + + describe "default scope" do + let(:deleted_dunning_campaign) { create(:dunning_campaign, :deleted) } + + before { deleted_dunning_campaign } + + it "only returns non-deleted dunning_campaign objects" do + expect(described_class.all).to eq([]) + expect(described_class.with_discarded).to eq([deleted_dunning_campaign]) + end + end end