diff --git a/app/models/automated_checks/claim_verifiers/duplicate_claims_check.rb b/app/models/automated_checks/claim_verifiers/duplicate_claims_check.rb new file mode 100644 index 0000000000..10faaa2c2a --- /dev/null +++ b/app/models/automated_checks/claim_verifiers/duplicate_claims_check.rb @@ -0,0 +1,37 @@ +module AutomatedChecks + module ClaimVerifiers + class DuplicateClaimsCheck + def initialize(claim:) + @claim = claim + end + + def perform + matching_attribute_finder.matching_claims.each do |existing_claim| + if existing_claim.created_at < claim.created_at + original_claim = existing_claim + duplicate_claim = claim + else + original_claim = claim + duplicate_claim = existing_claim + end + + unless Claims::ClaimDuplicate.exists?(original_claim: original_claim, duplicate_claim: duplicate_claim) + Claims::ClaimDuplicate.create!( + original_claim: original_claim, + duplicate_claim: duplicate_claim, + matching_attributes: matching_attribute_finder.matching_attributes(existing_claim) + ) + end + end + end + + private + + attr_reader :claim + + def matching_attribute_finder + @matching_attribute_finder ||= Claim::MatchingAttributeFinder.new(claim) + end + end + end +end diff --git a/app/models/claim.rb b/app/models/claim.rb index bb0491bc35..6705561c01 100644 --- a/app/models/claim.rb +++ b/app/models/claim.rb @@ -142,6 +142,26 @@ class Claim < ApplicationRecord inverse_of: :assigned_claims, optional: true + has_many :claim_duplicates_as_original_claim, + class_name: "Claims::ClaimDuplicate", + foreign_key: :original_claim_id, + dependent: :destroy + + has_many :claim_duplicates_as_duplicate_claim, + class_name: "Claims::ClaimDuplicate", + foreign_key: :duplicate_claim_id, + dependent: :destroy + + has_many :duplicates, + through: :claim_duplicates_as_original_claim, + source: :duplicate_claim, + class_name: "Claim" + + has_many :originals, + through: :claim_duplicates_as_duplicate_claim, + source: :original_claim, + class_name: "Claim" + enum :payroll_gender, { dont_know: 0, female: 1, diff --git a/app/models/claims.rb b/app/models/claims.rb new file mode 100644 index 0000000000..bd683b58be --- /dev/null +++ b/app/models/claims.rb @@ -0,0 +1,5 @@ +module Claims + def self.table_name_prefix + "claims_" + end +end diff --git a/app/models/claims/claim_duplicate.rb b/app/models/claims/claim_duplicate.rb new file mode 100644 index 0000000000..e84bf0501d --- /dev/null +++ b/app/models/claims/claim_duplicate.rb @@ -0,0 +1,28 @@ +module Claims + class ClaimDuplicate < ApplicationRecord + belongs_to :original_claim, class_name: "Claim" + belongs_to :duplicate_claim, class_name: "Claim" + + validates :duplicate_claim, uniqueness: { + scope: :original_claim, + message: "has already been registered as a duplicate" + } + validate :claims_are_not_the_same, if: -> { original_claim && duplicate_claim } + validate :original_claim_is_older, if: -> { original_claim && duplicate_claim } + validates :matching_attributes, presence: true + + private + + def claims_are_not_the_same + return unless original_claim == duplicate_claim + + errors.add(:duplicate_claim, "can't be the same as the original claim") + end + + def original_claim_is_older + return unless original_claim.created_at > duplicate_claim.created_at + + errors.add(:original_claim, "must be older than the duplicate claim") + end + end +end diff --git a/app/models/policies/early_career_payments.rb b/app/models/policies/early_career_payments.rb index bc5f165769..50f9461210 100644 --- a/app/models/policies/early_career_payments.rb +++ b/app/models/policies/early_career_payments.rb @@ -20,7 +20,8 @@ module EarlyCareerPayments AutomatedChecks::ClaimVerifiers::CensusSubjectsTaught, AutomatedChecks::ClaimVerifiers::Employment, AutomatedChecks::ClaimVerifiers::StudentLoanPlan, - AutomatedChecks::ClaimVerifiers::FraudRisk + AutomatedChecks::ClaimVerifiers::FraudRisk, + AutomatedChecks::ClaimVerifiers::DuplicateClaimsCheck ].freeze POLICY_START_YEAR = AcademicYear.new(2021).freeze diff --git a/app/models/policies/early_years_payments.rb b/app/models/policies/early_years_payments.rb index 379385bd7c..b7e8e6204f 100644 --- a/app/models/policies/early_years_payments.rb +++ b/app/models/policies/early_years_payments.rb @@ -18,7 +18,8 @@ module EarlyYearsPayments VERIFIERS = [ AutomatedChecks::ClaimVerifiers::StudentLoanPlan, - AutomatedChecks::ClaimVerifiers::EarlyYearsPayments::Identity + AutomatedChecks::ClaimVerifiers::EarlyYearsPayments::Identity, + AutomatedChecks::ClaimVerifiers::DuplicateClaimsCheck ] # Attributes to delete from claims submitted before the current academic diff --git a/app/models/policies/further_education_payments.rb b/app/models/policies/further_education_payments.rb index 323fdaa035..e6a329474d 100644 --- a/app/models/policies/further_education_payments.rb +++ b/app/models/policies/further_education_payments.rb @@ -22,7 +22,8 @@ module FurtherEducationPayments AutomatedChecks::ClaimVerifiers::ProviderVerification, AutomatedChecks::ClaimVerifiers::Employment, AutomatedChecks::ClaimVerifiers::StudentLoanPlan, - AutomatedChecks::ClaimVerifiers::FraudRisk + AutomatedChecks::ClaimVerifiers::FraudRisk, + AutomatedChecks::ClaimVerifiers::DuplicateClaimsCheck ] # Options shown to admins when rejecting a claim diff --git a/app/models/policies/international_relocation_payments.rb b/app/models/policies/international_relocation_payments.rb index 963bb5d07d..2fde881a10 100644 --- a/app/models/policies/international_relocation_payments.rb +++ b/app/models/policies/international_relocation_payments.rb @@ -4,7 +4,8 @@ module InternationalRelocationPayments extend self VERIFIERS = [ - AutomatedChecks::ClaimVerifiers::FraudRisk + AutomatedChecks::ClaimVerifiers::FraudRisk, + AutomatedChecks::ClaimVerifiers::DuplicateClaimsCheck ].freeze ELIGIBILITY_MATCHING_ATTRIBUTES = [["passport_number"]].freeze diff --git a/app/models/policies/levelling_up_premium_payments.rb b/app/models/policies/levelling_up_premium_payments.rb index e2a41761a7..de0a89f938 100644 --- a/app/models/policies/levelling_up_premium_payments.rb +++ b/app/models/policies/levelling_up_premium_payments.rb @@ -10,7 +10,8 @@ module LevellingUpPremiumPayments AutomatedChecks::ClaimVerifiers::CensusSubjectsTaught, AutomatedChecks::ClaimVerifiers::Employment, AutomatedChecks::ClaimVerifiers::StudentLoanPlan, - AutomatedChecks::ClaimVerifiers::FraudRisk + AutomatedChecks::ClaimVerifiers::FraudRisk, + AutomatedChecks::ClaimVerifiers::DuplicateClaimsCheck ].freeze # Used in diff --git a/app/models/policies/student_loans.rb b/app/models/policies/student_loans.rb index 16862dba0f..e79e351288 100644 --- a/app/models/policies/student_loans.rb +++ b/app/models/policies/student_loans.rb @@ -19,7 +19,8 @@ module StudentLoans AutomatedChecks::ClaimVerifiers::CensusSubjectsTaught, AutomatedChecks::ClaimVerifiers::Employment, AutomatedChecks::ClaimVerifiers::StudentLoanAmount, - AutomatedChecks::ClaimVerifiers::FraudRisk + AutomatedChecks::ClaimVerifiers::FraudRisk, + AutomatedChecks::ClaimVerifiers::DuplicateClaimsCheck ].freeze POLICY_START_YEAR = AcademicYear.new(2013).freeze diff --git a/db/migrate/20241219115223_create_claims_claim_duplicates.rb b/db/migrate/20241219115223_create_claims_claim_duplicates.rb new file mode 100644 index 0000000000..649f84f0b4 --- /dev/null +++ b/db/migrate/20241219115223_create_claims_claim_duplicates.rb @@ -0,0 +1,33 @@ +class CreateClaimsClaimDuplicates < ActiveRecord::Migration[8.0] + def change + create_table :claims_claim_duplicates, id: :uuid do |t| + t.belongs_to( + :original_claim, + null: false, + foreign_key: { + to_table: :claims + }, + type: :uuid + ) + + t.belongs_to( + :duplicate_claim, + null: false, + foreign_key: { + to_table: :claims + }, + type: :uuid + ) + + t.jsonb :matching_attributes, default: [] + + t.timestamps + end + + add_index( + :claims_claim_duplicates, + [:original_claim_id, :duplicate_claim_id], + unique: true + ) + end +end diff --git a/db/schema.rb b/db/schema.rb index ff5c7e9cb2..18b85d679e 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[8.0].define(version: 2024_11_26_105650) do +ActiveRecord::Schema[8.0].define(version: 2024_12_19_115223) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -123,6 +123,17 @@ t.index ["submitted_at"], name: "index_claims_on_submitted_at" end + create_table "claims_claim_duplicates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "original_claim_id", null: false + t.uuid "duplicate_claim_id", null: false + t.jsonb "matching_attributes", default: [] + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["duplicate_claim_id"], name: "index_claims_claim_duplicates_on_duplicate_claim_id" + t.index ["original_claim_id", "duplicate_claim_id"], name: "idx_on_original_claim_id_duplicate_claim_id_5ce2c01567", unique: true + t.index ["original_claim_id"], name: "index_claims_claim_duplicates_on_original_claim_id" + end + create_table "decisions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.integer "result" t.uuid "claim_id" @@ -585,6 +596,8 @@ add_foreign_key "claim_payments", "claims" add_foreign_key "claim_payments", "payments" add_foreign_key "claims", "journeys_sessions" + add_foreign_key "claims_claim_duplicates", "claims", column: "duplicate_claim_id" + add_foreign_key "claims_claim_duplicates", "claims", column: "original_claim_id" add_foreign_key "decisions", "dfe_sign_in_users", column: "created_by_id" add_foreign_key "early_career_payments_eligibilities", "schools", column: "current_school_id" add_foreign_key "eligible_ey_providers", "local_authorities" diff --git a/spec/models/automated_checks/claim_verifiers/duplicate_claims_check_spec.rb b/spec/models/automated_checks/claim_verifiers/duplicate_claims_check_spec.rb new file mode 100644 index 0000000000..8020c659e3 --- /dev/null +++ b/spec/models/automated_checks/claim_verifiers/duplicate_claims_check_spec.rb @@ -0,0 +1,82 @@ +require "rails_helper" + +RSpec.describe AutomatedChecks::ClaimVerifiers::DuplicateClaimsCheck do + describe "#perform" do + context "when the claim has duplicates" do + it "marks the claim as having duplicates" do + existing_claim = create( + :claim, + :submitted, + email_address: "test@example.com", + first_name: "one" + ) + + new_claim = create( + :claim, + :submitted, + email_address: "test@example.com", + first_name: "two" + ) + + described_class.new(claim: new_claim).perform + + expect(existing_claim.reload.duplicates).to include(new_claim) + expect(new_claim.reload.originals).to include(existing_claim) + + claim_duplicate = new_claim.claim_duplicates_as_duplicate_claim.last + + expect(claim_duplicate.original_claim).to eq(existing_claim) + expect(claim_duplicate.duplicate_claim).to eq(new_claim) + expect(claim_duplicate.matching_attributes).to eq(["email_address"]) + end + + it "is idempotent" do + existing_claim = create( + :claim, + :submitted, + email_address: "test@example.com", + first_name: "one" + ) + + new_claim = create( + :claim, + :submitted, + email_address: "test@example.com", + first_name: "two" + ) + + Claims::ClaimDuplicate.create!( + original_claim: existing_claim, + duplicate_claim: new_claim, + matching_attributes: ["email_address"] + ) + + expect { described_class.new(claim: new_claim).perform }.not_to( + change(existing_claim.duplicates, :count) + ) + end + end + + context "when the claim has no duplicates" do + it "does not mark the claim as having duplicates" do + existing_claim = create( + :claim, + :submitted, + email_address: "test1@example.com", + first_name: "one" + ) + + new_claim = create( + :claim, + :submitted, + email_address: "test2@example.com", + first_name: "two" + ) + + described_class.new(claim: new_claim).perform + + expect(existing_claim.reload.duplicates).not_to include(new_claim) + end + end + end +end diff --git a/spec/models/claims/claim_duplicate_spec.rb b/spec/models/claims/claim_duplicate_spec.rb new file mode 100644 index 0000000000..9eb89b4117 --- /dev/null +++ b/spec/models/claims/claim_duplicate_spec.rb @@ -0,0 +1,92 @@ +require "rails_helper" + +RSpec.describe Claims::ClaimDuplicate, type: :model do + describe "validations" do + it { is_expected.to validate_presence_of(:matching_attributes) } + + describe "uniquness" do + it "doesn't allow a duplicate to be registered more than once" do + original_claim = create(:claim, created_at: 1.day.ago) + duplicate_claim = create(:claim, created_at: Time.zone.now) + + described_class.create!( + original_claim: original_claim, + duplicate_claim: duplicate_claim, + matching_attributes: ["email_address"] + ) + + claim_duplicate = described_class.new( + original_claim: original_claim, + duplicate_claim: duplicate_claim, + matching_attributes: ["email_address"] + ) + + expect(claim_duplicate).not_to be_valid + expect(claim_duplicate.errors[:duplicate_claim]).to include( + "has already been registered as a duplicate" + ) + end + end + + describe "original_claim_is_older" do + it "is valid when the original claim is older" do + original_claim = create(:claim, created_at: 1.day.ago) + duplicate_claim = create(:claim, created_at: Time.zone.now) + + claim_duplicate = described_class.new( + original_claim: original_claim, + duplicate_claim: duplicate_claim, + matching_attributes: ["email_address"] + ) + + expect(claim_duplicate).to be_valid + end + + it "is invalid when the original claim is newer" do + original_claim = create(:claim, created_at: Time.zone.now) + duplicate_claim = create(:claim, created_at: 1.day.ago) + + claim_duplicate = described_class.new( + original_claim: original_claim, + duplicate_claim: duplicate_claim, + matching_attributes: ["email_address"] + ) + + expect(claim_duplicate).not_to be_valid + expect(claim_duplicate.errors[:original_claim]).to include( + "must be older than the duplicate claim" + ) + end + end + + describe "claims_are_not_the_same" do + it "is valid when the claims are different" do + original_claim = create(:claim) + duplicate_claim = create(:claim) + + claim_duplicate = described_class.new( + original_claim: original_claim, + duplicate_claim: duplicate_claim, + matching_attributes: ["email_address"] + ) + + expect(claim_duplicate).to be_valid + end + + it "is invalid when the claims are the same" do + claim = create(:claim) + + claim_duplicate = described_class.new( + original_claim: claim, + duplicate_claim: claim, + matching_attributes: ["email_address"] + ) + + expect(claim_duplicate).not_to be_valid + expect(claim_duplicate.errors[:duplicate_claim]).to include( + "can't be the same as the original claim" + ) + end + end + end +end