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..f87bb44158
--- /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.require(:admin_fraud_risk_csv_upload_form).permit(:file)
+ end
+ 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..80e5b8cfcb
--- /dev/null
+++ b/app/forms/admin/fraud_risk_csv_upload_form.rb
@@ -0,0 +1,57 @@
+module Admin
+ class FraudRiskCsvUploadForm
+ include ActiveModel::Model
+
+ attr_accessor :file
+
+ validates :file, presence: true
+
+ validate :csv_has_required_headers, if: -> { file.present? }
+
+ validate :all_records_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!)
+ 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_records_are_valid
+ records.select(&:invalid?).each do |record|
+ errors.add(:base, record.errors.map(&:message).join(", "))
+ 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
+ end
+end
diff --git a/app/models/claim.rb b/app/models/claim.rb
index 298219227a..702a4b7a33 100644
--- a/app/models/claim.rb
+++ b/app/models/claim.rb
@@ -454,6 +454,10 @@ def awaiting_provider_verification?
eligibility.awaiting_provider_verification?
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/risk_indicator.rb b/app/models/risk_indicator.rb
new file mode 100644
index 0000000000..34614cb18a
--- /dev/null
+++ b/app/models/risk_indicator.rb
@@ -0,0 +1,34 @@
+class RiskIndicator < ApplicationRecord
+ SUPPORTED_FIELDS = %w[
+ teacher_reference_number
+ national_insurance_number
+ ].freeze
+
+ validates :field, presence: {
+ message: "'field' can't be blank"
+ }
+
+ validates :value, presence: {
+ message: "'value' can't be blank"
+ }
+
+ validates :value, uniqueness: {scope: :field}
+
+ validates :field,
+ inclusion: {
+ in: SUPPORTED_FIELDS,
+ message: "'%{value}' is not a valid attribute - must be one of #{SUPPORTED_FIELDS.join(", ")}"
+ }
+
+ 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 ceb39ecd52..1245aafb24 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/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/index.html.erb b/app/views/admin/tasks/index.html.erb
index 597890e667..338eb79039 100644
--- a/app/views/admin/tasks/index.html.erb
+++ b/app/views/admin/tasks/index.html.erb
@@ -4,7 +4,7 @@
<%= govuk_back_link href: claims_backlink_path %>
<% end %>
-<% if @has_matching_claims %>
+<% if @has_matching_claims || @claim.attributes_flagged_by_risk_indicator.any? %>
-
- <%= 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 @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.
+
+ <% end %>
+
+ <% if @claim.attributes_flagged_by_risk_indicator.any? %>
+
@has_matching_claims.present?) %>">
+ This claim has been flagged for fraud prevention as it has a
+ <%= @claim.attributes_flagged_by_risk_indicator.map(&:humanize).to_sentence.downcase %>
+ that is present in the fraud risk CSV.
+
+ <% end %>
+
<% end %>
diff --git a/config/analytics_blocklist.yml b/config/analytics_blocklist.yml
index df4942ffd7..a87650d8bf 100644
--- a/config/analytics_blocklist.yml
+++ b/config/analytics_blocklist.yml
@@ -131,3 +131,9 @@
- verification
:journeys_sessions:
- answers
+ :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 cc19519bba..cc80763cce 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.0].define(version: 2024_10_15_121145) do
+ActiveRecord::Schema[7.0].define(version: 2024_10_17_160838) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "pg_trgm"
@@ -420,6 +420,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..579a665671
--- /dev/null
+++ b/spec/features/admin/admin_fraud_prevention_spec.rb
@@ -0,0 +1,77 @@
+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
+
+ before do
+ sign_in_as_service_operator
+ 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"
+ )
+
+ 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 for fraud prevention as it has a " \
+ "teacher reference number that is present in the fraud risk CSV."
+ )
+
+ visit admin_claim_tasks_path(flagged_claim_nino)
+
+ expect(page).to have_content(
+ "This claim has been flagged for fraud prevention as it has a " \
+ "national insurance number that is present in the fraud risk CSV."
+ )
+
+ visit admin_claim_tasks_path(flagged_claim_trn_and_nino)
+
+ expect(page).to have_content(
+ "This claim has been flagged for fraud prevention as it has a " \
+ "national insurance number and teacher reference number that is " \
+ "present in the fraud risk CSV."
+ )
+ end
+ end
+
+ it "allows for downloading the csv" do
+ 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
+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..71f49162bd
--- /dev/null
+++ b/spec/forms/admin/fraud_risk_csv_upload_form_spec.rb
@@ -0,0 +1,140 @@
+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("can't be blank")
+ 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
+ end
+end