diff --git a/app/controllers/admin/fraud_risk_csv_downloads_controller.rb b/app/controllers/admin/fraud_risk_csv_downloads_controller.rb
new file mode 100644
index 0000000000..c7cf4bed4e
--- /dev/null
+++ b/app/controllers/admin/fraud_risk_csv_downloads_controller.rb
@@ -0,0 +1,25 @@
+module Admin
+ class FraudRiskCsvDownloadsController < BaseAdminController
+ before_action :ensure_service_operator
+
+ def show
+ respond_to do |format|
+ format.csv do
+ send_data(csv, filename: "fraud_risk.csv")
+ end
+ end
+ end
+
+ private
+
+ def csv
+ CSV.generate do |csv|
+ csv << %w[field value]
+
+ RiskIndicator.order(created_at: :asc).pluck(:field, :value).each do |row|
+ csv << row
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/admin/fraud_risk_csv_uploads_controller.rb b/app/controllers/admin/fraud_risk_csv_uploads_controller.rb
new file mode 100644
index 0000000000..6924ac5c13
--- /dev/null
+++ b/app/controllers/admin/fraud_risk_csv_uploads_controller.rb
@@ -0,0 +1,28 @@
+module Admin
+ class FraudRiskCsvUploadsController < BaseAdminController
+ before_action :ensure_service_operator
+
+ def new
+ @form = FraudRiskCsvUploadForm.new
+ end
+
+ def create
+ @form = FraudRiskCsvUploadForm.new(fraud_risk_csv_upload_params)
+
+ if @form.save
+ redirect_to(
+ new_admin_fraud_risk_csv_upload_path,
+ notice: "Fraud prevention list uploaded successfully."
+ )
+ else
+ render :new
+ end
+ end
+
+ private
+
+ def fraud_risk_csv_upload_params
+ params.fetch(:admin_fraud_risk_csv_upload_form, {}).permit(:file)
+ end
+ end
+end
diff --git a/app/controllers/admin/tasks_controller.rb b/app/controllers/admin/tasks_controller.rb
index aaf8521f18..9ad419a8df 100644
--- a/app/controllers/admin/tasks_controller.rb
+++ b/app/controllers/admin/tasks_controller.rb
@@ -6,7 +6,7 @@ class Admin::TasksController < Admin::BaseAdminController
def index
@claim_checking_tasks = ClaimCheckingTasks.new(@claim)
- @has_matching_claims = Claim::MatchingAttributeFinder.new(@claim).matching_claims.exists?
+ @banner_messages = set_banner_messages
end
def show
@@ -78,4 +78,29 @@ def load_matching_claims
def current_task_name
@task.name
end
+
+ def set_banner_messages
+ messages = []
+
+ if Claim::MatchingAttributeFinder.new(@claim).matching_claims.exists?
+ claims_link = view_context.link_to(
+ "Multiple claims",
+ admin_claim_task_path(claim_id: @claim.id, name: "matching_details"),
+ class: "govuk-notification-banner__link"
+ )
+
+ messages << "#{claims_link} with matching details have been made in this claim window.".html_safe
+ end
+
+ if @claim.attributes_flagged_by_risk_indicator.any?
+ messages << <<~MSG.html_safe
+ This claim has been flagged as the
+ #{@claim.attributes_flagged_by_risk_indicator.map(&:humanize).to_sentence.downcase}
+ #{@claim.attributes_flagged_by_risk_indicator.many? ? "are" : "is"}
+ included on the fraud prevention list. Speak to a manager.
+ MSG
+ end
+
+ messages
+ end
end
diff --git a/app/forms/admin/fraud_risk_csv_upload_form.rb b/app/forms/admin/fraud_risk_csv_upload_form.rb
new file mode 100644
index 0000000000..4e48fa680a
--- /dev/null
+++ b/app/forms/admin/fraud_risk_csv_upload_form.rb
@@ -0,0 +1,87 @@
+module Admin
+ class FraudRiskCsvUploadForm
+ include ActiveModel::Model
+
+ attr_accessor :file
+
+ validates :file, presence: {message: "CSV file is required"}
+
+ validate :csv_has_required_headers, if: -> { file.present? }
+
+ validate :all_rows_are_valid, if: -> { file.present? && csv_has_required_headers? }
+
+ def initialize(params = {})
+ super
+ end
+
+ def save
+ return false unless valid?
+
+ ApplicationRecord.transaction do
+ RiskIndicator.where.not(id: records.map(&:id)).destroy_all
+
+ records.each(&:save!)
+
+ claims_to_note.each do |claim|
+ AutomatedChecks::ClaimVerifiers::FraudRisk.new(claim: claim).perform
+ end
+ end
+
+ true
+ end
+
+ private
+
+ def csv
+ @csv ||= CSV.parse(file.read, headers: true, skip_blanks: true)
+ end
+
+ def records
+ @records ||= csv.map do |row|
+ RiskIndicator.find_or_initialize_by(row.to_h)
+ end.uniq { |record| record.attributes.slice("field", "value") }
+ end
+
+ def all_rows_are_valid
+ csv.each do |row|
+ unless RiskIndicator::SUPPORTED_FIELDS.include?(row["field"])
+ errors.add(
+ :base,
+ "'#{row["field"]}' is not a valid attribute - " \
+ "must be one of #{RiskIndicator::SUPPORTED_FIELDS.join(", ")}"
+ )
+ end
+
+ if row["value"].blank?
+ errors.add(:base, "'value' can't be blank")
+ end
+ end
+ end
+
+ def csv_has_required_headers
+ unless csv_has_required_headers?
+ errors.add(:base, "csv is missing required headers `field`, `value`")
+ end
+ end
+
+ def csv_has_required_headers?
+ csv.headers.include?("field") && csv.headers.include?("value")
+ end
+
+ def claims_to_note
+ flagged_eligibility_claim_ids = Policies.with_attribute(:teacher_reference_number).flat_map do |policy|
+ policy::Eligibility
+ .where(teacher_reference_number: RiskIndicator.teacher_reference_number.select(:value))
+ .joins(:claim)
+ .select("claims.id")
+ end
+
+ Claim
+ .where(
+ "LOWER(national_insurance_number) IN (?)",
+ RiskIndicator.national_insurance_number.select("LOWER(value)")
+ )
+ .or(Claim.where(id: flagged_eligibility_claim_ids))
+ end
+ end
+end
diff --git a/app/models/automated_checks/claim_verifiers/fraud_risk.rb b/app/models/automated_checks/claim_verifiers/fraud_risk.rb
new file mode 100644
index 0000000000..a0a47c77df
--- /dev/null
+++ b/app/models/automated_checks/claim_verifiers/fraud_risk.rb
@@ -0,0 +1,35 @@
+module AutomatedChecks
+ module ClaimVerifiers
+ class FraudRisk
+ TASK_NAME = "fraud_risk".freeze
+
+ def initialize(claim:)
+ @claim = claim
+ end
+
+ def perform
+ return unless claim.attributes_flagged_by_risk_indicator.any?
+
+ flagged_attributes = @claim
+ .attributes_flagged_by_risk_indicator
+ .map(&:humanize)
+ .to_sentence
+ .downcase
+
+ plural_verbs = claim.attributes_flagged_by_risk_indicator.many? ? "are" : "is"
+
+ body = "This claim has been flagged as the #{flagged_attributes} " \
+ "#{plural_verbs} included on the fraud prevention list."
+
+ claim.notes.create!(
+ body: body,
+ label: TASK_NAME
+ )
+ end
+
+ private
+
+ attr_reader :claim
+ end
+ end
+end
diff --git a/app/models/claim.rb b/app/models/claim.rb
index d579a4612e..cf6482cbce 100644
--- a/app/models/claim.rb
+++ b/app/models/claim.rb
@@ -249,7 +249,7 @@ def submittable?
end
def approvable?
- submitted? && !held? && !payroll_gender_missing? && (!decision_made? || awaiting_qa?) && !payment_prevented_by_other_claims?
+ submitted? && !held? && !payroll_gender_missing? && (!decision_made? || awaiting_qa?) && !payment_prevented_by_other_claims? && attributes_flagged_by_risk_indicator.none?
end
def rejectable?
@@ -457,6 +457,10 @@ def has_early_years_policy?
policy == Policies::EarlyYearsPayments
end
+ def attributes_flagged_by_risk_indicator
+ @attributes_flagged_by_risk_indicator ||= RiskIndicator.flagged_attributes(self)
+ end
+
private
def one_login_idv_name_match?
diff --git a/app/models/policies.rb b/app/models/policies.rb
index 06015e1106..0b6a7ea8c2 100644
--- a/app/models/policies.rb
+++ b/app/models/policies.rb
@@ -37,4 +37,8 @@ def self.[](policy_type)
def self.constantize(policy)
"Policies::#{policy}".constantize
end
+
+ def self.with_attribute(attr)
+ POLICIES.select { |policy| policy::Eligibility.has_attribute?(attr) }
+ end
end
diff --git a/app/models/policies/early_career_payments.rb b/app/models/policies/early_career_payments.rb
index 2248580d05..3495cd8e56 100644
--- a/app/models/policies/early_career_payments.rb
+++ b/app/models/policies/early_career_payments.rb
@@ -19,7 +19,8 @@ module EarlyCareerPayments
AutomatedChecks::ClaimVerifiers::Induction,
AutomatedChecks::ClaimVerifiers::CensusSubjectsTaught,
AutomatedChecks::ClaimVerifiers::Employment,
- AutomatedChecks::ClaimVerifiers::StudentLoanPlan
+ AutomatedChecks::ClaimVerifiers::StudentLoanPlan,
+ AutomatedChecks::ClaimVerifiers::FraudRisk
].freeze
POLICY_START_YEAR = AcademicYear.new(2021).freeze
diff --git a/app/models/policies/further_education_payments.rb b/app/models/policies/further_education_payments.rb
index 7fe6b406ee..02d032c6d1 100644
--- a/app/models/policies/further_education_payments.rb
+++ b/app/models/policies/further_education_payments.rb
@@ -20,7 +20,8 @@ module FurtherEducationPayments
AutomatedChecks::ClaimVerifiers::Identity,
AutomatedChecks::ClaimVerifiers::ProviderVerification,
AutomatedChecks::ClaimVerifiers::Employment,
- AutomatedChecks::ClaimVerifiers::StudentLoanPlan
+ AutomatedChecks::ClaimVerifiers::StudentLoanPlan,
+ AutomatedChecks::ClaimVerifiers::FraudRisk
]
# 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 ed4861b78a..963bb5d07d 100644
--- a/app/models/policies/international_relocation_payments.rb
+++ b/app/models/policies/international_relocation_payments.rb
@@ -3,6 +3,10 @@ module InternationalRelocationPayments
include BasePolicy
extend self
+ VERIFIERS = [
+ AutomatedChecks::ClaimVerifiers::FraudRisk
+ ].freeze
+
ELIGIBILITY_MATCHING_ATTRIBUTES = [["passport_number"]].freeze
OTHER_CLAIMABLE_POLICIES = []
diff --git a/app/models/policies/levelling_up_premium_payments.rb b/app/models/policies/levelling_up_premium_payments.rb
index 827a46e72b..2ecc3a79b0 100644
--- a/app/models/policies/levelling_up_premium_payments.rb
+++ b/app/models/policies/levelling_up_premium_payments.rb
@@ -9,7 +9,8 @@ module LevellingUpPremiumPayments
AutomatedChecks::ClaimVerifiers::Qualifications,
AutomatedChecks::ClaimVerifiers::CensusSubjectsTaught,
AutomatedChecks::ClaimVerifiers::Employment,
- AutomatedChecks::ClaimVerifiers::StudentLoanPlan
+ AutomatedChecks::ClaimVerifiers::StudentLoanPlan,
+ AutomatedChecks::ClaimVerifiers::FraudRisk
].freeze
# Used in
diff --git a/app/models/policies/student_loans.rb b/app/models/policies/student_loans.rb
index 5de4c4828b..54687727ff 100644
--- a/app/models/policies/student_loans.rb
+++ b/app/models/policies/student_loans.rb
@@ -18,7 +18,8 @@ module StudentLoans
AutomatedChecks::ClaimVerifiers::Qualifications,
AutomatedChecks::ClaimVerifiers::CensusSubjectsTaught,
AutomatedChecks::ClaimVerifiers::Employment,
- AutomatedChecks::ClaimVerifiers::StudentLoanAmount
+ AutomatedChecks::ClaimVerifiers::StudentLoanAmount,
+ AutomatedChecks::ClaimVerifiers::FraudRisk
].freeze
POLICY_START_YEAR = AcademicYear.new(2013).freeze
diff --git a/app/models/risk_indicator.rb b/app/models/risk_indicator.rb
new file mode 100644
index 0000000000..50d9caeb23
--- /dev/null
+++ b/app/models/risk_indicator.rb
@@ -0,0 +1,26 @@
+class RiskIndicator < ApplicationRecord
+ SUPPORTED_FIELDS = %w[
+ teacher_reference_number
+ national_insurance_number
+ ].freeze
+
+ enum field: SUPPORTED_FIELDS.index_by(&:itself)
+
+ validates :value, presence: {
+ message: "'value' can't be blank"
+ }
+
+ validates :value, uniqueness: {scope: :field}
+
+ def self.flagged_attributes(claim)
+ where(
+ "field = 'national_insurance_number' AND LOWER(value) = :value",
+ value: claim.national_insurance_number&.downcase
+ ).or(
+ where(
+ field: "teacher_reference_number",
+ value: claim.eligibility.try(:teacher_reference_number)
+ )
+ ).pluck(:field).compact
+ end
+end
diff --git a/app/views/admin/claims/index.html.erb b/app/views/admin/claims/index.html.erb
index 3d6cf32b9e..85599ec91b 100644
--- a/app/views/admin/claims/index.html.erb
+++ b/app/views/admin/claims/index.html.erb
@@ -15,6 +15,7 @@
<%= link_to "Upload School Workforce Census data", new_admin_school_workforce_census_data_upload_path, class: "govuk-button govuk-button--secondary", data: { module: "govuk-button" }, role: :button %>
<%= 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 %>
<%= render "allocations_form" %>
diff --git a/app/views/admin/decisions/_decision_form.html.erb b/app/views/admin/decisions/_decision_form.html.erb
index ffe191fdcf..c32f6c73b2 100644
--- a/app/views/admin/decisions/_decision_form.html.erb
+++ b/app/views/admin/decisions/_decision_form.html.erb
@@ -29,6 +29,21 @@
<% end %>
+<% if claim.attributes_flagged_by_risk_indicator.any? %>
+
+
!
+
+ Warning
+
+ This claim cannot be approved because the
+ <%= @claim.attributes_flagged_by_risk_indicator.map(&:humanize).to_sentence.downcase %>
+ <%= @claim.attributes_flagged_by_risk_indicator.many? ? "are" : "is" %>
+ included on the fraud prevention list.
+
+
+
+<% end %>
+
<%= form_for decision, url: admin_claim_decisions_path(claim), html: { id: "claim_decision_form" } do |form| %>
<%= hidden_field_tag :qa, params[:qa] %>
diff --git a/app/views/admin/fraud_risk_csv_uploads/new.html.erb b/app/views/admin/fraud_risk_csv_uploads/new.html.erb
new file mode 100644
index 0000000000..8784f19c3f
--- /dev/null
+++ b/app/views/admin/fraud_risk_csv_uploads/new.html.erb
@@ -0,0 +1,31 @@
+
+
+
+ Fraud risk CSV upload
+
+
+ <%= form_with(
+ url: admin_fraud_risk_csv_uploads_path,
+ model: @form,
+ builder: GOVUKDesignSystemFormBuilder::FormBuilder
+ ) do |f| %>
+ <%= f.govuk_error_summary %>
+
+ <%= f.govuk_file_field(
+ :file,
+ label: { text: "Upload fraud risk CSV file" },
+ hint: {
+ text: "Currently supported attributes are #{RiskIndicator::SUPPORTED_FIELDS.join(", ")}."
+ }
+ ) %>
+
+ <%= f.govuk_submit "Upload CSV" %>
+ <% end %>
+
+ <%= govuk_link_to(
+ "Download CSV",
+ admin_fraud_risk_csv_download_path(format: :csv),
+ class: "govuk-button"
+ ) %>
+
+
diff --git a/app/views/admin/tasks/_banner.html.erb b/app/views/admin/tasks/_banner.html.erb
new file mode 100644
index 0000000000..b50a12e503
--- /dev/null
+++ b/app/views/admin/tasks/_banner.html.erb
@@ -0,0 +1,26 @@
+
+
+
+ <% if messages.many? %>
+
+ This claim requires the following to be reviewed:
+
+
+
+ <% messages.each do |message| %>
+ -
+ <%= message %>
+
+ <% end %>
+
+ <% else %>
+
+ <%= messages.first %>
+
+ <% end %>
+
+
diff --git a/app/views/admin/tasks/index.html.erb b/app/views/admin/tasks/index.html.erb
index 66889dda47..9c24b33456 100644
--- a/app/views/admin/tasks/index.html.erb
+++ b/app/views/admin/tasks/index.html.erb
@@ -4,19 +4,18 @@
<%= govuk_back_link href: claims_backlink_path %>
<% end %>
-<% if @has_matching_claims %>
-
-
-
-
- <%= link_to "Multiple claims", admin_claim_task_path(claim_id: @claim.id, name: "matching_details") %>
- with matching details have been made in this claim window.
+<% if @banner_messages.any? %>
+ <%= render partial: "banner", locals: { messages: @banner_messages } %>
+<% end %>
+
+<% if false && @claim.attributes_flagged_by_risk_indicator.any? %>
+
+
<% end %>
diff --git a/config/analytics_blocklist.yml b/config/analytics_blocklist.yml
index 4de6714919..d05bf8bfb7 100644
--- a/config/analytics_blocklist.yml
+++ b/config/analytics_blocklist.yml
@@ -135,3 +135,9 @@
- provider_email_address
- practitioner_first_name
- practitioner_surname
+ :risk_indicators:
+ - id
+ - field
+ - value
+ - created_at
+ - updated_at
diff --git a/config/routes.rb b/config/routes.rb
index fb1b3ebfc4..87614e70ff 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -146,6 +146,8 @@ def matches?(request)
resources :school_workforce_census_data_uploads, only: [:new, :create]
resources :student_loans_data_uploads, only: [:new, :create]
resources :tps_data_uploads, only: [:new, :create]
+ resources :fraud_risk_csv_uploads, only: [:new, :create]
+ resource :fraud_risk_csv_download, only: :show
resources :payroll_runs, only: [:index, :new, :create, :show, :destroy] do
resources :payment_confirmation_report_uploads, only: [:new, :create]
diff --git a/db/migrate/20241017160838_create_risk_indicators.rb b/db/migrate/20241017160838_create_risk_indicators.rb
new file mode 100644
index 0000000000..ff3f20cab7
--- /dev/null
+++ b/db/migrate/20241017160838_create_risk_indicators.rb
@@ -0,0 +1,12 @@
+class CreateRiskIndicators < ActiveRecord::Migration[7.0]
+ def change
+ create_table :risk_indicators, id: :uuid do |t|
+ t.string :field, null: false
+ t.string :value, null: false
+
+ t.index %i[field value], unique: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 94eee59299..9a6c43d4ed 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -428,6 +428,14 @@
t.string "itt_subject"
end
+ create_table "risk_indicators", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "field", null: false
+ t.string "value", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["field", "value"], name: "index_risk_indicators_on_field_and_value", unique: true
+ end
+
create_table "school_workforce_censuses", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "teacher_reference_number"
t.datetime "created_at", null: false
diff --git a/spec/factories/risk_indicators.rb b/spec/factories/risk_indicators.rb
new file mode 100644
index 0000000000..718880baa5
--- /dev/null
+++ b/spec/factories/risk_indicators.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :risk_indicator do
+ field { "teacher_reference_number" }
+ value { "1234567" }
+ end
+end
diff --git a/spec/features/admin/admin_fraud_prevention_spec.rb b/spec/features/admin/admin_fraud_prevention_spec.rb
new file mode 100644
index 0000000000..8b8a9f5644
--- /dev/null
+++ b/spec/features/admin/admin_fraud_prevention_spec.rb
@@ -0,0 +1,294 @@
+require "rails_helper"
+
+RSpec.feature "Admin fraud prevention" do
+ let(:fraud_risk_csv) do
+ File.open(Rails.root.join("spec", "fixtures", "files", "fraud_risk.csv"))
+ end
+
+ context "when updating the list of flagged attributes" do
+ it "flags any matching claims" do
+ flagged_claim_trn = create(
+ :claim,
+ :submitted,
+ eligibility_attributes: {
+ teacher_reference_number: "1234567"
+ }
+ )
+
+ flagged_claim_nino = create(
+ :claim,
+ :submitted,
+ national_insurance_number: "QQ123456C"
+ )
+
+ flagged_claim_trn_and_nino = create(
+ :claim,
+ :submitted,
+ eligibility_attributes: {
+ teacher_reference_number: "1234567"
+ },
+ national_insurance_number: "QQ123456C"
+ )
+
+ sign_in_as_service_operator
+ visit new_admin_fraud_risk_csv_upload_path
+ attach_file "Upload fraud risk CSV file", fraud_risk_csv.path
+ click_on "Upload"
+
+ expect(page).to have_content(
+ "Fraud prevention list uploaded successfully."
+ )
+
+ visit admin_claim_tasks_path(flagged_claim_trn)
+
+ expect(page).to have_content(
+ "This claim has been flagged as the " \
+ "teacher reference number is included on the fraud prevention list."
+ )
+
+ visit admin_claim_tasks_path(flagged_claim_nino)
+
+ expect(page).to have_content(
+ "This claim has been flagged as the " \
+ "national insurance number is included on the fraud prevention list."
+ )
+
+ visit admin_claim_tasks_path(flagged_claim_trn_and_nino)
+
+ expect(page).to have_content(
+ "This claim has been flagged as the " \
+ "national insurance number and teacher reference number are included " \
+ "on the fraud prevention list."
+ )
+
+ visit new_admin_claim_decision_path(flagged_claim_trn)
+
+ approval_option = find("input[type=radio][value=approved]")
+
+ expect(approval_option).to be_disabled
+
+ expect(page).to have_content(
+ "This claim cannot be approved because the teacher reference number " \
+ "is included on the fraud prevention list."
+ )
+
+ visit new_admin_claim_decision_path(flagged_claim_nino)
+
+ approval_option = find("input[type=radio][value=approved]")
+
+ expect(approval_option).to be_disabled
+
+ expect(page).to have_content(
+ "This claim cannot be approved because the national insurance number " \
+ "is included on the fraud prevention list."
+ )
+
+ visit new_admin_claim_decision_path(flagged_claim_trn_and_nino)
+
+ approval_option = find("input[type=radio][value=approved]")
+
+ expect(approval_option).to be_disabled
+
+ expect(page).to have_content(
+ "This claim cannot be approved because the national insurance number " \
+ "and teacher reference number are included on the fraud prevention list."
+ )
+
+ visit admin_claim_notes_path(flagged_claim_trn)
+
+ within(".hmcts-timeline:first-of-type") do
+ expect(page).to have_content(
+ "This claim has been flagged as the " \
+ "teacher reference number is included on the fraud prevention list."
+ )
+ end
+
+ visit admin_claim_notes_path(flagged_claim_nino)
+
+ within(".hmcts-timeline:first-of-type") do
+ expect(page).to have_content(
+ "This claim has been flagged as the " \
+ "national insurance number is included on the fraud prevention list."
+ )
+ end
+
+ visit admin_claim_notes_path(flagged_claim_trn_and_nino)
+
+ within(".hmcts-timeline:first-of-type") do
+ expect(page).to have_content(
+ "This claim has been flagged as the " \
+ "national insurance number and teacher reference number are included " \
+ "on the fraud prevention list."
+ )
+ end
+ end
+ end
+
+ it "allows for downloading the csv" do
+ sign_in_as_service_operator
+ visit new_admin_fraud_risk_csv_upload_path
+ attach_file "Upload fraud risk CSV file", fraud_risk_csv.path
+ click_on "Upload"
+
+ click_on "Download"
+ expect(page.body).to eq(fraud_risk_csv.read.chomp)
+ end
+
+ it "creates a note for submitted claims" do
+ create(
+ :risk_indicator,
+ field: "national_insurance_number",
+ value: "QQ123456C"
+ )
+
+ claim = submit_claim(national_insurance_number: "QQ123456C")
+
+ # Stub dqt api call in verifiers job
+ dqt_teacher_resource = instance_double(Dqt::TeacherResource, find: nil)
+ dqt_client = instance_double(Dqt::Client, teacher: dqt_teacher_resource)
+ allow(Dqt::Client).to receive(:new).and_return(dqt_client)
+
+ perform_enqueued_jobs
+
+ sign_in_as_service_operator
+
+ visit admin_claim_notes_path(claim)
+
+ within(".hmcts-timeline:first-of-type") do
+ expect(page).to have_content(
+ "This claim has been flagged as the " \
+ "national insurance number is included on the fraud prevention list."
+ )
+ end
+ end
+
+ def submit_claim(national_insurance_number: "QQ123456C")
+ create(:journey_configuration, :additional_payments)
+
+ school = create(:school, :early_career_payments_eligible)
+
+ visit landing_page_path(Journeys::AdditionalPaymentsForTeaching::ROUTING_NAME)
+ # - Landing (start)
+ click_on "Start now"
+
+ click_on "Continue without signing in"
+
+ # /additional-payments/current-school
+ fill_in :school_search, with: school.name
+ click_on "Continue"
+ # /additional-payments/current-school
+ choose school.name
+ click_on "Continue"
+
+ # /additional-payments/nqt-in-academic-year-after-itt
+ choose "Yes"
+ click_on "Continue"
+
+ # /additional-payments/induction-completed
+ choose "Yes"
+ click_on "Continue"
+
+ # /additional-payments/supply-teacher
+ choose "Yes"
+ click_on "Continue"
+
+ # /additional-payments/entire-term-contract
+ choose "Yes"
+ click_on "Continue"
+
+ # /additional-payments/employed-directly
+ choose "Yes, I'm employed by my school"
+ click_on "Continue"
+
+ # /additional-payments/poor-performance
+ within all(".govuk-fieldset")[0] do
+ choose("No")
+ end
+ within all(".govuk-fieldset")[1] do
+ choose("No")
+ end
+ click_on "Continue"
+
+ # /additional-payments/qualification
+ choose "Postgraduate initial teacher training (ITT)"
+ click_on "Continue"
+
+ # /additional-payments/itt-year
+ choose "2020 to 2021"
+ click_on "Continue"
+
+ # /additional-payments/eligible-itt-subject
+ choose "Chemistry"
+ click_on "Continue"
+
+ # /additional-payments/teaching-subject-now
+ choose "Yes"
+ click_on "Continue"
+
+ # /additional-payments/check-your-answers-part-one
+ click_on "Continue"
+
+ # /additional-payments/eligibility-confirmed
+ click_on "Apply now"
+
+ # /additional-payments/information-provided
+ click_on "Continue"
+
+ # /additional-payments/personal-details
+ fill_in "First name", with: "Seymour"
+ fill_in "Last name", with: "Skinner"
+
+ fill_in "Day", with: "1"
+ fill_in "Month", with: "6"
+ fill_in "Year", with: "1980"
+
+ fill_in "What is your National Insurance number?", with: national_insurance_number
+
+ click_on "Continue"
+
+ # /additional-payments/postcode-search
+ click_on "Enter your address manually"
+
+ # /additional-payments/address
+ fill_in "House number or name", with: "123 Main Street"
+ fill_in "Building and street", with: "Downtown"
+ fill_in "Town or city", with: "Twin Peaks"
+ fill_in "County", with: "Washington"
+ fill_in "Postcode", with: "TE57 1NG"
+
+ click_on "Continue"
+
+ # /additional-payments/email-address
+ fill_in "Email address", with: "test@example.com"
+ click_on "Continue"
+
+ # /additional-payments/email-verification
+ mail = ActionMailer::Base.deliveries.last
+ otp_in_mail_sent = mail[:personalisation].decoded.scan(/\b[0-9]{6}\b/).first
+ fill_in "Enter the 6-digit passcode", with: otp_in_mail_sent
+ click_on "Confirm"
+
+ # /additional-payments/provide-mobile-number
+ choose "No"
+ click_on "Continue"
+
+ # /additional-payments/personal-bank-account
+ fill_in "Name on your account", with: "Seymour Skinner"
+ fill_in "Sort code", with: "123456"
+ fill_in "Account number", with: "87654321"
+ click_on "Continue"
+
+ # /additional-payments/gender
+ choose "Male"
+ click_on "Continue"
+
+ # /additional-payments/teacher-reference-number
+ fill_in "What is your teacher reference number (TRN)?", with: "1234567"
+ click_on "Continue"
+
+ # /additional-payments/check-your-answers
+ click_on "Accept and send"
+
+ Claim.order(:created_at).last
+ end
+end
diff --git a/spec/fixtures/files/fraud_risk.csv b/spec/fixtures/files/fraud_risk.csv
new file mode 100644
index 0000000000..26684e5091
--- /dev/null
+++ b/spec/fixtures/files/fraud_risk.csv
@@ -0,0 +1,4 @@
+field,value
+national_insurance_number,qq123456c
+teacher_reference_number,1234567
+
diff --git a/spec/fixtures/files/fraud_risk_missing_headers.csv b/spec/fixtures/files/fraud_risk_missing_headers.csv
new file mode 100644
index 0000000000..f1abd81ec3
--- /dev/null
+++ b/spec/fixtures/files/fraud_risk_missing_headers.csv
@@ -0,0 +1,4 @@
+national_insurance_number,qq123456c
+teacher_reference_number,1234567
+
+
diff --git a/spec/fixtures/files/fraud_risk_missing_value.csv b/spec/fixtures/files/fraud_risk_missing_value.csv
new file mode 100644
index 0000000000..7b7b74387f
--- /dev/null
+++ b/spec/fixtures/files/fraud_risk_missing_value.csv
@@ -0,0 +1,5 @@
+field,value
+national_insurance_number,qq123456c
+teacher_reference_number,1234567
+teacher_reference_number,
+
diff --git a/spec/fixtures/files/fraud_risk_unknown_attribute.csv b/spec/fixtures/files/fraud_risk_unknown_attribute.csv
new file mode 100644
index 0000000000..c7cb826776
--- /dev/null
+++ b/spec/fixtures/files/fraud_risk_unknown_attribute.csv
@@ -0,0 +1,5 @@
+field,value
+national_insurance_number,qq123456c
+teacher_reference_number,1234567
+test,1234567
+rest,1234567
diff --git a/spec/forms/admin/fraud_risk_csv_upload_form_spec.rb b/spec/forms/admin/fraud_risk_csv_upload_form_spec.rb
new file mode 100644
index 0000000000..a027455cee
--- /dev/null
+++ b/spec/forms/admin/fraud_risk_csv_upload_form_spec.rb
@@ -0,0 +1,182 @@
+require "rails_helper"
+
+RSpec.describe Admin::FraudRiskCsvUploadForm, type: :model do
+ describe "validations" do
+ context "without a file" do
+ it "is invalid" do
+ form = described_class.new
+ expect(form).not_to be_valid
+ expect(form.errors[:file]).to include("CSV file is required")
+ end
+ end
+
+ context "with an invalid csv" do
+ it "is invalid" do
+ file_path = Rails.root.join(
+ "spec", "fixtures", "files", "fraud_risk_missing_headers.csv"
+ )
+
+ file = Rack::Test::UploadedFile.new(file_path)
+
+ form = described_class.new(file: file)
+
+ expect(form).not_to be_valid
+ expect(form.errors[:base]).to include(
+ "csv is missing required headers `field`, `value`"
+ )
+ end
+ end
+
+ context "with a missing value csv" do
+ it "is invalid" do
+ file_path = Rails.root.join(
+ "spec", "fixtures", "files", "fraud_risk_missing_value.csv"
+ )
+
+ file = Rack::Test::UploadedFile.new(file_path)
+
+ form = described_class.new(file: file)
+
+ expect(form).not_to be_valid
+ expect(form.errors[:base]).to include("'value' can't be blank")
+ end
+ end
+
+ context "with an unsupported field" do
+ it "is invalid" do
+ file_path = Rails.root.join(
+ "spec", "fixtures", "files", "fraud_risk_unknown_attribute.csv"
+ )
+
+ file = Rack::Test::UploadedFile.new(file_path)
+
+ form = described_class.new(file: file)
+
+ expect(form).not_to be_valid
+ expect(form.errors[:base]).to include(
+ "'test' is not a valid attribute - must be one of teacher_reference_number, national_insurance_number"
+ )
+ end
+ end
+ end
+
+ describe "#save" do
+ let(:file) do
+ Rack::Test::UploadedFile.new(
+ Rails.root.join("spec", "fixtures", "files", "fraud_risk.csv")
+ )
+ end
+
+ let(:form) { described_class.new(file: file) }
+
+ it "creates risk indicator records for each row" do
+ expect(form.save).to be true
+
+ expect(RiskIndicator.count).to eq(2)
+
+ expect(
+ RiskIndicator.where(field: "teacher_reference_number").first.value
+ ).to eq("1234567")
+
+ expect(
+ RiskIndicator.where(field: "national_insurance_number").first.value
+ ).to eq("qq123456c")
+ end
+
+ it "doesn't duplicate existing risk indicator records" do
+ RiskIndicator.create!(
+ field: "teacher_reference_number",
+ value: "1234567"
+ )
+
+ RiskIndicator.create!(
+ field: "national_insurance_number",
+ value: "qq123456c"
+ )
+
+ expect { form.save }.not_to change(RiskIndicator, :count)
+ end
+
+ it "removes risk indicators that are no longer in the CSV" do
+ RiskIndicator.create!(
+ field: "teacher_reference_number",
+ value: "2234567"
+ )
+
+ RiskIndicator.create!(
+ field: "national_insurance_number",
+ value: "qq111111c"
+ )
+
+ _unchanged_risk_indicator = RiskIndicator.create!(
+ field: "national_insurance_number",
+ value: "qq123456c"
+ )
+
+ expect(form.save).to be true
+
+ expect(
+ RiskIndicator.where(
+ field: "teacher_reference_number",
+ value: "2234567"
+ )
+ ).to be_empty
+
+ expect(
+ RiskIndicator.where(
+ field: "national_insurance_number",
+ value: "qq111111c"
+ )
+ ).to be_empty
+
+ expect(
+ RiskIndicator.where(
+ field: "national_insurance_number",
+ value: "qq123456c"
+ )
+ ).to exist
+ end
+
+ it "adds a note to claims that are flagged" do
+ claim_1 = create(:claim, national_insurance_number: "qq123456c")
+
+ claim_2 = create(
+ :claim,
+ eligibility_attributes: {teacher_reference_number: "1234567"}
+ )
+
+ claim_3 = create(
+ :claim,
+ national_insurance_number: "qq123456c",
+ eligibility_attributes: {teacher_reference_number: "1234567"}
+ )
+
+ form.save
+
+ expect(claim_1.notes.by_label("fraud_risk").last.body).to eq(
+ "This claim has been flagged as the national insurance number is " \
+ "included on the fraud prevention list."
+ )
+
+ expect(claim_2.notes.by_label("fraud_risk").last.body).to eq(
+ "This claim has been flagged as the teacher reference number is " \
+ "included on the fraud prevention list."
+ )
+
+ expect(claim_3.notes.by_label("fraud_risk").last.body).to eq(
+ "This claim has been flagged as the national insurance number and " \
+ "teacher reference number are included on the fraud prevention list."
+ )
+ end
+
+ it "doesn't add a note to claims that aren't flagged" do
+ claim = create(
+ :claim,
+ national_insurance_number: "qq123456d",
+ eligibility_attributes: {teacher_reference_number: "1234568"}
+ )
+
+ expect { form.save }.not_to change { claim.notes.count }
+ end
+ end
+end
diff --git a/spec/models/automated_checks/claim_verifier_spec.rb b/spec/models/automated_checks/claim_verifier_spec.rb
index 65fcde043d..f75939fb19 100644
--- a/spec/models/automated_checks/claim_verifier_spec.rb
+++ b/spec/models/automated_checks/claim_verifier_spec.rb
@@ -53,6 +53,7 @@
double(perform: Task.new),
double(perform: Task.new),
double(perform: Object.new),
+ double(perform: nil),
double(perform: nil)
]
end
diff --git a/spec/models/automated_checks/claim_verifiers/fraud_risk_spec.rb b/spec/models/automated_checks/claim_verifiers/fraud_risk_spec.rb
new file mode 100644
index 0000000000..873c20e6a4
--- /dev/null
+++ b/spec/models/automated_checks/claim_verifiers/fraud_risk_spec.rb
@@ -0,0 +1,44 @@
+require "rails_helper"
+
+RSpec.describe AutomatedChecks::ClaimVerifiers::FraudRisk do
+ describe "#perform" do
+ context "with a claim that has flagged attributes" do
+ it "creates a note" do
+ claim = create(:claim, national_insurance_number: "QQ123456C")
+
+ create(
+ :risk_indicator,
+ field: "national_insurance_number",
+ value: "QQ123456C"
+ )
+
+ described_class.new(claim: claim).perform
+
+ note = claim.notes.last
+
+ expect(note.label).to eq("fraud_risk")
+
+ expect(note.body).to eq(
+ "This claim has been flagged as the national insurance number is " \
+ "included on the fraud prevention list."
+ )
+ end
+ end
+
+ context "with a claim that has no flagged attributes" do
+ it "doesn't create a note" do
+ claim = create(:claim, national_insurance_number: "QQ123456B")
+
+ create(
+ :risk_indicator,
+ field: "national_insurance_number",
+ value: "QQ123456C"
+ )
+
+ expect { described_class.new(claim: claim).perform }.not_to(
+ change { claim.notes.count }
+ )
+ end
+ end
+ end
+end
diff --git a/spec/models/claim_spec.rb b/spec/models/claim_spec.rb
index fdd88b90f2..a526d9bafd 100644
--- a/spec/models/claim_spec.rb
+++ b/spec/models/claim_spec.rb
@@ -342,6 +342,13 @@
expect(create(:claim, :submitted, national_insurance_number: national_insurance_number, date_of_birth: 30.years.ago).approvable?).to eq false
end
+ it "returns false if the claim is flagged by a fraud check" do
+ claim = create(:claim, :submitted, national_insurance_number: "QQ123456C")
+ create(:risk_indicator, field: "national_insurance_number", value: "QQ123456C")
+
+ expect(claim.approvable?).to eq false
+ end
+
context "when the claim is held" do
subject(:claim) { create(:claim, :held) }
it { is_expected.not_to be_approvable }
diff --git a/spec/models/early_career_payments_spec.rb b/spec/models/early_career_payments_spec.rb
index 1c71e9cffd..40f6498a9a 100644
--- a/spec/models/early_career_payments_spec.rb
+++ b/spec/models/early_career_payments_spec.rb
@@ -10,7 +10,8 @@
AutomatedChecks::ClaimVerifiers::Induction,
AutomatedChecks::ClaimVerifiers::CensusSubjectsTaught,
AutomatedChecks::ClaimVerifiers::Employment,
- AutomatedChecks::ClaimVerifiers::StudentLoanPlan
+ AutomatedChecks::ClaimVerifiers::StudentLoanPlan,
+ AutomatedChecks::ClaimVerifiers::FraudRisk
])
end
diff --git a/spec/models/further_education_payments_spec.rb b/spec/models/further_education_payments_spec.rb
index cc1b15704f..e6fec73fa5 100644
--- a/spec/models/further_education_payments_spec.rb
+++ b/spec/models/further_education_payments_spec.rb
@@ -8,7 +8,8 @@
AutomatedChecks::ClaimVerifiers::Identity,
AutomatedChecks::ClaimVerifiers::ProviderVerification,
AutomatedChecks::ClaimVerifiers::Employment,
- AutomatedChecks::ClaimVerifiers::StudentLoanPlan
+ AutomatedChecks::ClaimVerifiers::StudentLoanPlan,
+ AutomatedChecks::ClaimVerifiers::FraudRisk
])
end
diff --git a/spec/models/levelling_up_premium_payments_spec.rb b/spec/models/levelling_up_premium_payments_spec.rb
index 16a7c769bb..702123cab7 100644
--- a/spec/models/levelling_up_premium_payments_spec.rb
+++ b/spec/models/levelling_up_premium_payments_spec.rb
@@ -9,7 +9,8 @@
AutomatedChecks::ClaimVerifiers::Qualifications,
AutomatedChecks::ClaimVerifiers::CensusSubjectsTaught,
AutomatedChecks::ClaimVerifiers::Employment,
- AutomatedChecks::ClaimVerifiers::StudentLoanPlan
+ AutomatedChecks::ClaimVerifiers::StudentLoanPlan,
+ AutomatedChecks::ClaimVerifiers::FraudRisk
])
end
diff --git a/spec/models/student_loans_spec.rb b/spec/models/student_loans_spec.rb
index f02368f0b4..98caffdd89 100644
--- a/spec/models/student_loans_spec.rb
+++ b/spec/models/student_loans_spec.rb
@@ -11,7 +11,8 @@
AutomatedChecks::ClaimVerifiers::Qualifications,
AutomatedChecks::ClaimVerifiers::CensusSubjectsTaught,
AutomatedChecks::ClaimVerifiers::Employment,
- AutomatedChecks::ClaimVerifiers::StudentLoanAmount
+ AutomatedChecks::ClaimVerifiers::StudentLoanAmount,
+ AutomatedChecks::ClaimVerifiers::FraudRisk
])
end