diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
new file mode 100644
index 0000000000..0ec277834d
--- /dev/null
+++ b/app/controllers/admin/reports_controller.rb
@@ -0,0 +1,29 @@
+module Admin
+ class ReportsController < BaseAdminController
+ before_action :ensure_service_operator
+
+ def index
+ end
+
+ def show
+ respond_to do |format|
+ format.csv do
+ send_data(report.to_csv, filename: report.filename)
+ end
+ end
+ end
+
+ private
+
+ def report
+ @report ||= case params[:name]
+ when "fe-approved-claims-with-failing-provider-verification"
+ Reports::FeApprovedClaimsWithFailingProviderVerification.new
+ when "approved-claims-failing-qualification-task"
+ Reports::ApprovedClaimsFailingQualificationTask.new
+ else
+ raise ActiveRecord::RecordNotFound
+ end
+ end
+ end
+end
diff --git a/app/models/admin/reports/approved_claims_failing_qualification_task.rb b/app/models/admin/reports/approved_claims_failing_qualification_task.rb
new file mode 100644
index 0000000000..c870c05ae0
--- /dev/null
+++ b/app/models/admin/reports/approved_claims_failing_qualification_task.rb
@@ -0,0 +1,121 @@
+module Admin
+ module Reports
+ class ApprovedClaimsFailingQualificationTask
+ HEADERS = [
+ "Claim reference",
+ "Teacher reference number",
+ "Policy",
+ "Status",
+ "Decision date",
+ "Decision agent",
+ "Qualification",
+ "ITT start year",
+ "ITT subject",
+ "ITT subjects",
+ "ITT start date",
+ "QTS award date",
+ "Qualification name"
+ ]
+
+ def filename
+ "approved_claims_failing_qualification_task.csv"
+ end
+
+ def to_csv
+ CSV.generate(
+ row_sep: "\r\n",
+ write_headers: true,
+ headers: HEADERS
+ ) do |csv|
+ rows.each { |row| csv << row }
+ end
+ end
+
+ private
+
+ def rows
+ scope.map(&ClaimPresenter.method(:new)).map(&:to_a)
+ end
+
+ def scope
+ Claim
+ .approved
+ .where(academic_year: AcademicYear.current)
+ .joins(:tasks)
+ .merge(Task.where(name: "qualifications", passed: false))
+ .includes(:eligibility, decisions: :created_by)
+ end
+
+ class ClaimPresenter
+ include Admin::ClaimsHelper
+
+ def initialize(claim)
+ @claim = claim
+ end
+
+ def to_a
+ [
+ claim.reference,
+ claim.eligibility.teacher_reference_number,
+ I18n.t("#{claim.policy.locale_key}.policy_acronym"),
+ status(claim),
+ I18n.l(approval.created_at.to_date, format: :day_month_year),
+ approval.created_by.full_name,
+ qualification,
+ itt_academic_year&.to_s,
+ eligible_itt_subject,
+ dqt_teacher_record.itt_subjects.join(", "),
+ I18n.l(dqt_teacher_record.itt_start_date, format: :day_month_year),
+ I18n.l(dqt_teacher_record.qts_award_date, format: :day_month_year),
+ dqt_teacher_record.qualification_name
+ ]
+ end
+
+ private
+
+ attr_reader :claim
+
+ def approval
+ @approval ||= claim.decisions.reject(&:undone).last
+ end
+
+ # StudentLoans doesn't have an eligible_itt_subject
+ def eligible_itt_subject
+ claim.eligibility.try(:eligible_itt_subject)
+ end
+
+ # StudentLoans doesn't have an itt_academic_year
+ def itt_academic_year
+ claim.eligibility.try(:itt_academic_year)
+ end
+
+ # StudentLoans doesn't have a qualification
+ def qualification
+ claim.eligibility.try(:qualification)
+ end
+
+ def itt_subjects
+ dqt_teacher_record&.itt_subjects
+ end
+
+ def itt_start_date
+ dqt_teacher_record&.itt_start_date
+ end
+
+ def qts_award_date
+ dqt_teacher_record&.qts_award_date
+ end
+
+ def qualification_name
+ dqt_teacher_record&.qualification_name
+ end
+
+ def dqt_teacher_record
+ @dqt_teacher_record ||= if claim.has_dqt_record?
+ Dqt::Teacher.new(claim.dqt_teacher_status)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/admin/reports/fe_approved_claims_with_failing_provider_verification.rb b/app/models/admin/reports/fe_approved_claims_with_failing_provider_verification.rb
new file mode 100644
index 0000000000..b6ffaf8c09
--- /dev/null
+++ b/app/models/admin/reports/fe_approved_claims_with_failing_provider_verification.rb
@@ -0,0 +1,105 @@
+module Admin
+ module Reports
+ class FeApprovedClaimsWithFailingProviderVerification
+ HEADERS = [
+ "Claim reference",
+ "Full name",
+ "Claim amount",
+ "Claim status",
+ "Decision date",
+ "Decision agent",
+ "Contract of employment",
+ "Teaching responsibilities",
+ "First 5 years of teaching",
+ "One full term",
+ "Timetabled teaching hours",
+ "Age range taught",
+ "Subject",
+ "Course",
+ "2.5 hours weekly teaching",
+ "Performance",
+ "Disciplinary"
+ ]
+
+ def filename
+ "fe_approved_claims_with_failing_provider_verification.csv"
+ end
+
+ def to_csv
+ CSV.generate(
+ row_sep: "\r\n",
+ write_headers: true,
+ headers: HEADERS
+ ) do |csv|
+ rows.each { |row| csv << row }
+ end
+ end
+
+ private
+
+ def rows
+ scope.map(&ClaimPresenter.method(:new)).map(&:to_a)
+ end
+
+ def scope
+ Claim
+ .by_policy(Policies::FurtherEducationPayments)
+ .approved
+ .joins(:tasks)
+ .merge(Task.where(name: "provider_verification", passed: false))
+ .includes(:eligibility, decisions: :created_by)
+ end
+
+ class ClaimPresenter
+ include Admin::ClaimsHelper
+ include ActionView::Helpers::NumberHelper
+
+ def initialize(claim)
+ @claim = claim
+ end
+
+ def to_a
+ [
+ claim.reference,
+ claim.full_name,
+ number_to_currency(claim.award_amount, precision: 0),
+ status(claim),
+ approval_date,
+ approval.created_by.full_name,
+ present_assertion("contract_type"),
+ present_assertion("teaching_responsibilities"),
+ present_assertion("further_education_teaching_start_year"),
+ present_assertion("taught_at_least_one_term"),
+ present_assertion("teaching_hours_per_week"),
+ present_assertion("half_teaching_hours"),
+ present_assertion("subjects_taught"),
+ "??", # FIXME RL: not sure what courses should be
+ present_assertion("teaching_hours_per_week_next_term"),
+ present_assertion("subject_to_formal_performance_action"),
+ present_assertion("subject_to_disciplinary_action")
+ ]
+ end
+
+ private
+
+ attr_reader :claim
+
+ def approval_date
+ I18n.l(approval.created_at.to_date, format: :day_month_year)
+ end
+
+ def approval
+ @approval ||= claim.decisions.reject(&:undone).last
+ end
+
+ def present_assertion(name)
+ case claim.eligibility.verification_assertion(name)
+ when true then "Yes"
+ when false then "No"
+ else "N/A" # fixed and variable contracts have different assertions
+ end
+ end
+ end
+ end
+ end
+end
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..7989646755
--- /dev/null
+++ b/app/models/automated_checks/claim_verifiers/duplicate_claims_check.rb
@@ -0,0 +1,30 @@
+module AutomatedChecks
+ module ClaimVerifiers
+ class DuplicateClaimsCheck
+ def initialize(claim:)
+ @claim = claim
+ end
+
+ def perform
+ original_claim = matching_attribute_finder.matching_claims.min_by(&:created_at)
+
+ return unless original_claim
+ return if Claims::ClaimDuplicate.exists?(original_claim: original_claim, duplicate_claim: claim)
+
+ Claims::ClaimDuplicate.create!(
+ original_claim: original_claim,
+ duplicate_claim: claim,
+ matching_attributes: matching_attribute_finder.matching_attributes(original_claim)
+ )
+ 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/further_education_payments/eligibility.rb b/app/models/policies/further_education_payments/eligibility.rb
index 33375244af..e501b46d69 100644
--- a/app/models/policies/further_education_payments/eligibility.rb
+++ b/app/models/policies/further_education_payments/eligibility.rb
@@ -94,6 +94,10 @@ def eligible_itt_subject
nil
end
+ def verification_assertion(name)
+ assertion_hash[name]
+ end
+
private
def provider_and_claimant_names_match?
@@ -116,6 +120,12 @@ def provider_first_name
def provider_last_name
verification.dig("verifier", "last_name")
end
+
+ def assertion_hash
+ @assertion_hash ||= verification.fetch("assertions").map do |assertion|
+ [assertion["name"], assertion["outcome"]]
+ end.to_h
+ end
end
end
end
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/app/views/admin/claims/index.html.erb b/app/views/admin/claims/index.html.erb
index 64f5c251ff..d872447b07 100644
--- a/app/views/admin/claims/index.html.erb
+++ b/app/views/admin/claims/index.html.erb
@@ -16,6 +16,7 @@
<%= link_to "Upload TPS data", new_admin_tps_data_upload_path, class: "govuk-button govuk-button--secondary", data: { module: "govuk-button" }, role: :button %>
<%= link_to "Upload SLC data", new_admin_student_loans_data_upload_path, class: "govuk-button govuk-button--secondary", data: { module: "govuk-button" }, role: :button %>
<%= link_to "Upload fraud prevention data", new_admin_fraud_risk_csv_upload_path, class: "govuk-button govuk-button--secondary", data: { module: "govuk-button" }, role: :button %>
+ <%= link_to "Reports", admin_reports_path, class: "govuk-button govuk-button--secondary", data: { module: "govuk-button" }, role: :button %>
<%= render "allocations_form" %>
diff --git a/app/views/admin/reports/index.html.erb b/app/views/admin/reports/index.html.erb
new file mode 100644
index 0000000000..29074aefb4
--- /dev/null
+++ b/app/views/admin/reports/index.html.erb
@@ -0,0 +1,23 @@
+<% content_for :back_link do %>
+ <%= govuk_back_link href: admin_claims_path %>
+<% end %>
+
+
+
+
+ Reports
+
+
+ <%= govuk_button_link_to(
+ "FE TRI approved claims whereby the provider check status is 'failed'",
+ admin_report_path("fe-approved-claims-with-failing-provider-verification", format: :csv),
+ secondary: true
+ ) %>
+
+ <%= govuk_button_link_to(
+ "Approved claims failing qualification task",
+ admin_report_path("approved-claims-failing-qualification-task", format: :csv),
+ secondary: true
+ ) %>
+
+
diff --git a/config/routes.rb b/config/routes.rb
index f2c13854bc..b3aa1dd5cc 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -133,6 +133,7 @@ def matches?(request)
resources :tps_data_uploads, only: [:new, :create]
resources :fraud_risk_csv_uploads, only: [:new, :create]
resource :fraud_risk_csv_download, only: :show
+ resources :reports, only: [:index, :show], param: :name
resources :payroll_runs, only: [:index, :new, :create, :show, :destroy] do
resources :payment_confirmation_report_uploads, only: [:new, :create]
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/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb
new file mode 100644
index 0000000000..aa9e3c2e9d
--- /dev/null
+++ b/spec/features/admin/reports_spec.rb
@@ -0,0 +1,166 @@
+require "rails_helper"
+
+RSpec.describe "Admin reports" do
+ around do |example|
+ travel_to Date.new(2024, 12, 6) do
+ example.run
+ end
+ end
+
+ describe "Approved FE claims with failing provider verification" do
+ it "returns a CSV report" do
+ claim = create(
+ :claim,
+ :approved,
+ policy: Policies::FurtherEducationPayments,
+ first_name: "Elizabeth",
+ surname: "Hoover",
+ qa_required: true,
+ eligibility_attributes: {
+ award_amount: 2_000,
+ contract_type: "permanent",
+ verification: {
+ assertions: [
+ {
+ name: "contract_type",
+ outcome: true
+ },
+ {
+ name: "teaching_responsibilities",
+ outcome: true
+ },
+ {
+ name: "further_education_teaching_start_year",
+ outcome: true
+ },
+ {
+ name: "teaching_hours_per_week",
+ outcome: true
+ },
+ {
+ name: "half_teaching_hours",
+ outcome: false
+ },
+ {
+ name: "subjects_taught",
+ outcome: false
+ },
+ {
+ name: "subject_to_formal_performance_action",
+ outcome: true
+ },
+ {
+ name: "subject_to_disciplinary_action",
+ outcome: true
+ }
+ ]
+ }
+ }
+ )
+
+ create(
+ :task,
+ :failed,
+ name: "provider_verification",
+ claim: claim
+ )
+
+ sign_in_as_service_operator
+
+ visit admin_claims_path
+
+ click_on "Reports"
+
+ click_on(
+ "FE TRI approved claims whereby the provider check status is 'failed'"
+ )
+
+ csv_data = page.body
+
+ csv = CSV.parse(csv_data, headers: true)
+ row = csv.first
+
+ expect(row.fetch("Claim reference")).to eq(claim.reference)
+ expect(row.fetch("Full name")).to eq("Elizabeth Hoover")
+ expect(row.fetch("Claim amount")).to eq("£2,000")
+ expect(row.fetch("Claim status")).to eq("Approved awaiting QA")
+ expect(row.fetch("Decision date")).to eq("06/12/2024")
+ expect(row.fetch("Decision agent")).to eq("Aaron Admin")
+ expect(row.fetch("Contract of employment")).to eq("Yes")
+ expect(row.fetch("Teaching responsibilities")).to eq("Yes")
+ expect(row.fetch("First 5 years of teaching")).to eq("Yes")
+ expect(row.fetch("One full term")).to eq("N/A")
+ expect(row.fetch("Timetabled teaching hours")).to eq("Yes")
+ expect(row.fetch("Age range taught")).to eq("No")
+ expect(row.fetch("Subject")).to eq("No")
+ expect(row.fetch("Course")).to eq("??")
+ expect(row.fetch("2.5 hours weekly teaching")).to eq("N/A")
+ expect(row.fetch("Performance")).to eq("Yes")
+ expect(row.fetch("Disciplinary")).to eq("Yes")
+ end
+ end
+
+ describe "Approved claims failing qualification task" do
+ it "returns a CSV report" do
+ claim = create(
+ :claim,
+ :with_dqt_teacher_status,
+ :approved,
+ policy: Policies::LevellingUpPremiumPayments,
+ first_name: "Elizabeth",
+ surname: "Hoover",
+ eligibility_attributes: {
+ teacher_reference_number: "1234567",
+ qualification: :postgraduate_itt,
+ itt_academic_year: "2023/2024",
+ eligible_itt_subject: :mathematics
+ },
+ dqt_teacher_status: {
+ initial_teacher_training: {
+ programme_start_date: "2022-09-01",
+ subject1: "mathematics",
+ subject1_code: "G100",
+ qualification: "BA (Hons)"
+ },
+ qualified_teacher_status: {
+ qts_date: "2022-12-01"
+ }
+ }
+ )
+
+ create(
+ :task,
+ :failed,
+ name: "qualifications",
+ claim: claim
+ )
+
+ sign_in_as_service_operator
+
+ visit admin_claims_path
+
+ click_on "Reports"
+
+ click_on "Approved claims failing qualification task"
+
+ csv_data = page.body
+
+ csv = CSV.parse(csv_data, headers: true)
+ row = csv.first
+
+ expect(row.fetch("Claim reference")).to eq(claim.reference)
+ expect(row.fetch("Teacher reference number")).to eq("1234567")
+ expect(row.fetch("Policy")).to eq("STRI")
+ expect(row.fetch("Status")).to eq("Approved awaiting payroll")
+ expect(row.fetch("Decision date")).to eq("06/12/2024")
+ expect(row.fetch("Decision agent")).to eq("Aaron Admin")
+ expect(row.fetch("Qualification")).to eq("postgraduate_itt")
+ expect(row.fetch("ITT start year")).to eq("2023/2024")
+ expect(row.fetch("ITT subject")).to eq("mathematics")
+ expect(row.fetch("ITT subjects")).to eq("mathematics")
+ expect(row.fetch("ITT start date")).to eq("01/09/2022")
+ expect(row.fetch("QTS award date")).to eq("01/12/2022")
+ expect(row.fetch("Qualification name")).to eq("BA (Hons)")
+ end
+ end
+end
diff --git a/spec/models/admin/reports/approved_claims_failing_qualification_task_spec.rb b/spec/models/admin/reports/approved_claims_failing_qualification_task_spec.rb
new file mode 100644
index 0000000000..1c95a7d5bd
--- /dev/null
+++ b/spec/models/admin/reports/approved_claims_failing_qualification_task_spec.rb
@@ -0,0 +1,261 @@
+require "rails_helper"
+
+RSpec.describe Admin::Reports::ApprovedClaimsFailingQualificationTask do
+ around do |example|
+ travel_to Date.new(2024, 11, 1) do
+ example.run
+ end
+ end
+
+ describe "to_csv" do
+ it "returns a csv of the claims" do
+ # excluded, claim not approved
+ ecp_claim_unapporved_failed_qualification_task = create(
+ :claim,
+ policy: Policies::EarlyCareerPayments,
+ academic_year: AcademicYear.new(2024)
+ )
+
+ create(
+ :task,
+ :failed,
+ name: "qualifications",
+ claim: ecp_claim_unapporved_failed_qualification_task
+ )
+
+ # excluded, task passed
+ lup_claim_approved_passed_qualification_task = create(
+ :claim,
+ :approved,
+ policy: Policies::LevellingUpPremiumPayments,
+ academic_year: AcademicYear.new(2024)
+ )
+
+ create(
+ :task,
+ :passed,
+ name: "qualifications",
+ claim: lup_claim_approved_passed_qualification_task
+ )
+
+ # excluded, claim not approved
+ tslr_claim_rejected = create(
+ :claim,
+ :rejected,
+ policy: Policies::StudentLoans,
+ academic_year: AcademicYear.new(2024)
+ )
+
+ create(
+ :task,
+ :failed,
+ name: "qualifications",
+ claim: tslr_claim_rejected
+ )
+
+ # excluded, wrong policy!
+ _fe_claim = create(
+ :claim,
+ :approved,
+ policy: Policies::FurtherEducationPayments,
+ academic_year: AcademicYear.new(2024)
+ )
+
+ # excluded, previous academic year
+ ecp_claim_unapporved_failed_qualification_task = create(
+ :claim,
+ :approved,
+ policy: Policies::EarlyCareerPayments,
+ academic_year: AcademicYear.new(2023)
+ )
+
+ create(
+ :task,
+ :failed,
+ name: "qualifications",
+ claim: ecp_claim_unapporved_failed_qualification_task
+ )
+
+ # included
+ ecp_claim_approved_failed_qualification_task = create(
+ :claim,
+ :approved,
+ :flagged_for_qa,
+ policy: Policies::EarlyCareerPayments,
+ academic_year: AcademicYear.new(2024),
+ decision_creator: create(
+ :dfe_signin_user,
+ given_name: "Some",
+ family_name: "admin"
+ ),
+ eligibility_attributes: {
+ teacher_reference_number: "1111111",
+ eligible_itt_subject: :mathematics,
+ itt_academic_year: AcademicYear.new(2021),
+ qualification: :postgraduate_itt
+ },
+ dqt_teacher_status: {
+ qualified_teacher_status: {
+ qts_date: "2023-09-01",
+ name: "Qualified teacher (trained)"
+ },
+ initial_teacher_training: {
+ programme_start_date: "2022-08-01",
+ subject1: "mathematics",
+ subject1_code: "100403",
+ subject2: "physics",
+ subject3_code: "F300",
+ qualification: "Graduate Diploma"
+ }
+ }
+ )
+
+ create(
+ :task,
+ :failed,
+ name: "qualifications",
+ claim: ecp_claim_approved_failed_qualification_task
+ )
+
+ # included
+ lup_claim_approved_failed_qualification_task = create(
+ :claim,
+ :approved,
+ policy: Policies::LevellingUpPremiumPayments,
+ academic_year: AcademicYear.new(2024),
+ decision_creator: create(
+ :dfe_signin_user,
+ given_name: "Some",
+ family_name: "admin"
+ ),
+ eligibility_attributes: {
+ teacher_reference_number: "2222222",
+ eligible_itt_subject: :physics,
+ itt_academic_year: AcademicYear.new(2021),
+ qualification: :postgraduate_itt
+ },
+ dqt_teacher_status: {
+ qualified_teacher_status: {
+ qts_date: "2023-10-01",
+ name: "Qualified teacher (trained)"
+ },
+ initial_teacher_training: {
+ programme_start_date: "2022-08-02",
+ subject1: "physics",
+ subject1_code: "F300",
+ qualification: "Graduate Diploma"
+ }
+ }
+ )
+
+ create(:payment, claims: [lup_claim_approved_failed_qualification_task])
+
+ create(
+ :task,
+ :failed,
+ name: "qualifications",
+ claim: lup_claim_approved_failed_qualification_task
+ )
+
+ # included
+ tslr_claim_approved_failed_qualification_task = create(
+ :claim,
+ :approved,
+ policy: Policies::StudentLoans,
+ academic_year: AcademicYear.new(2024),
+ decision_creator: create(
+ :dfe_signin_user,
+ given_name: "Some",
+ family_name: "admin"
+ ),
+ eligibility_attributes: {
+ teacher_reference_number: "3333333"
+ },
+ dqt_teacher_status: {
+ qualified_teacher_status: {
+ qts_date: "2023-10-01",
+ name: "Qualified teacher (trained)"
+ },
+ initial_teacher_training: {
+ programme_start_date: "2022-08-02",
+ subject1: "physics",
+ subject1_code: "F300",
+ qualification: "Graduate Diploma"
+ }
+ }
+ )
+
+ create(
+ :task,
+ :failed,
+ name: "qualifications",
+ claim: tslr_claim_approved_failed_qualification_task
+ )
+
+ csv = CSV.parse(described_class.new.to_csv, headers: true)
+
+ expect(csv.to_a).to match_array([
+ [
+ "Claim reference",
+ "Teacher reference number",
+ "Policy",
+ "Status",
+ "Decision date",
+ "Decision agent",
+ "Qualification",
+ "ITT start year",
+ "ITT subject",
+ "ITT subjects",
+ "ITT start date",
+ "QTS award date",
+ "Qualification name"
+ ],
+ [
+ ecp_claim_approved_failed_qualification_task.reference,
+ "1111111",
+ "ECP",
+ "Approved awaiting QA",
+ "01/11/2024",
+ "Some admin",
+ "postgraduate_itt",
+ "2021/2022",
+ "mathematics",
+ "mathematics, physics",
+ "01/08/2022",
+ "01/09/2023",
+ "Graduate Diploma"
+ ],
+ [
+ lup_claim_approved_failed_qualification_task.reference,
+ "2222222",
+ "STRI",
+ "Payrolled",
+ "01/11/2024",
+ "Some admin",
+ "postgraduate_itt",
+ "2021/2022",
+ "physics",
+ "physics",
+ "02/08/2022",
+ "01/10/2023",
+ "Graduate Diploma"
+ ],
+ [
+ tslr_claim_approved_failed_qualification_task.reference,
+ "3333333",
+ "TSLR",
+ "Approved awaiting payroll",
+ "01/11/2024",
+ "Some admin",
+ nil,
+ nil,
+ nil,
+ "physics",
+ "02/08/2022",
+ "01/10/2023",
+ "Graduate Diploma"
+ ]
+ ])
+ end
+ end
+end
diff --git a/spec/models/admin/reports/fe_approved_claims_with_failing_provider_verification_spec.rb b/spec/models/admin/reports/fe_approved_claims_with_failing_provider_verification_spec.rb
new file mode 100644
index 0000000000..d4b8e2a705
--- /dev/null
+++ b/spec/models/admin/reports/fe_approved_claims_with_failing_provider_verification_spec.rb
@@ -0,0 +1,207 @@
+require "rails_helper"
+
+RSpec.describe Admin::Reports::FeApprovedClaimsWithFailingProviderVerification do
+ around do |example|
+ travel_to Date.new(2024, 11, 1) do
+ example.run
+ end
+ end
+
+ describe "#to_csv" do
+ it "returns a csv of approved fe claims with failing provider verification" do
+ fe_claim_with_passing_provider_check = create(
+ :claim,
+ :approved,
+ policy: Policies::FurtherEducationPayments
+ )
+
+ create(
+ :task,
+ :passed,
+ name: "provider_verification",
+ claim: fe_claim_with_passing_provider_check
+ )
+
+ fe_fixed_claim_with_failing_provider_check = create(
+ :claim,
+ :approved,
+ policy: Policies::FurtherEducationPayments,
+ first_name: "Elizabeth",
+ surname: "Hoover",
+ qa_required: true,
+ eligibility_attributes: {
+ award_amount: 2_000,
+ contract_type: "permanent",
+ verification: {
+ assertions: [
+ {
+ name: "contract_type",
+ outcome: true
+ },
+ {
+ name: "teaching_responsibilities",
+ outcome: true
+ },
+ {
+ name: "further_education_teaching_start_year",
+ outcome: true
+ },
+ {
+ name: "teaching_hours_per_week",
+ outcome: true
+ },
+ {
+ name: "half_teaching_hours",
+ outcome: false
+ },
+ {
+ name: "subjects_taught",
+ outcome: false
+ },
+ {
+ name: "subject_to_formal_performance_action",
+ outcome: true
+ },
+ {
+ name: "subject_to_disciplinary_action",
+ outcome: true
+ }
+ ]
+ }
+ }
+ )
+
+ create(
+ :task,
+ :failed,
+ name: "provider_verification",
+ claim: fe_fixed_claim_with_failing_provider_check
+ )
+
+ fe_variable_claim_with_failing_provider_check = create(
+ :claim,
+ :approved,
+ policy: Policies::FurtherEducationPayments,
+ first_name: "Edna",
+ surname: "Krabappel",
+ eligibility_attributes: {
+ award_amount: 3_000,
+ contract_type: "variable_hours",
+ verification: {
+ assertions: [
+ {
+ name: "contract_type",
+ outcome: true
+ },
+ {
+ name: "teaching_responsibilities",
+ outcome: true
+ },
+ {
+ name: "further_education_teaching_start_year",
+ outcome: true
+ },
+ {
+ name: "taught_at_least_one_term",
+ outcome: true
+ },
+ {
+ name: "teaching_hours_per_week",
+ outcome: true
+ },
+ {
+ name: "half_teaching_hours",
+ outcome: true
+ },
+ {
+ name: "subjects_taught",
+ outcome: true
+ },
+ {
+ name: "teaching_hours_per_week_next_term",
+ outcome: true
+ },
+ {
+ name: "subject_to_formal_performance_action",
+ outcome: true
+ },
+ {
+ name: "subject_to_disciplinary_action",
+ outcome: false
+ }
+ ]
+ }
+ }
+ )
+
+ create(
+ :task,
+ :failed,
+ name: "provider_verification",
+ claim: fe_variable_claim_with_failing_provider_check
+ )
+
+ csv = CSV.parse(described_class.new.to_csv, headers: true)
+
+ expect(csv.to_a).to match_array([
+ [
+ "Claim reference",
+ "Full name",
+ "Claim amount",
+ "Claim status",
+ "Decision date",
+ "Decision agent",
+ "Contract of employment",
+ "Teaching responsibilities",
+ "First 5 years of teaching",
+ "One full term",
+ "Timetabled teaching hours",
+ "Age range taught",
+ "Subject",
+ "Course",
+ "2.5 hours weekly teaching",
+ "Performance",
+ "Disciplinary"
+ ],
+ [
+ fe_fixed_claim_with_failing_provider_check.reference,
+ "Elizabeth Hoover",
+ "£2,000",
+ "Approved awaiting QA",
+ "01/11/2024",
+ "Aaron Admin",
+ "Yes", # contract of employment
+ "Yes", # teaching responsibilities
+ "Yes", # first 5 years of teaching
+ "N/A", # one full term - not a question for fixed term contracts
+ "Yes", # timetabled teaching hours
+ "No", # age range taught
+ "No", # subject
+ "??", # course
+ "N/A", # 2.5 hours weekly teaching
+ "Yes", # performance
+ "Yes" # disciplinary
+ ],
+ [
+ fe_variable_claim_with_failing_provider_check.reference,
+ "Edna Krabappel",
+ "£3,000",
+ "Approved awaiting payroll",
+ "01/11/2024",
+ "Aaron Admin",
+ "Yes", # contract of employment
+ "Yes", # teaching responsibilities
+ "Yes", # first 5 years of teaching
+ "Yes", # one full term
+ "Yes", # timetabled teaching hours
+ "Yes", # age range taught
+ "Yes", # subject
+ "??", # course
+ "Yes", # 2.5 hours weekly teaching
+ "Yes", # performance
+ "No" # disciplinary
+ ]
+ ])
+ end
+ end
+end
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..300c7ed0df
--- /dev/null
+++ b/spec/models/automated_checks/claim_verifiers/duplicate_claims_check_spec.rb
@@ -0,0 +1,91 @@
+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"
+ )
+
+ _other_duplicate_claim = create(
+ :claim,
+ :submitted,
+ email_address: "test@example.com",
+ first_name: "two"
+ )
+
+ new_claim = create(
+ :claim,
+ :submitted,
+ email_address: "test@example.com",
+ first_name: "three"
+ )
+
+ expect { described_class.new(claim: new_claim).perform }.to(
+ change(Claims::ClaimDuplicate, :count).by(1)
+ )
+
+ 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