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..674306d2d3 100644 --- a/app/models/claim.rb +++ b/app/models/claim.rb @@ -254,7 +254,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? @@ -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/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/index.html.erb b/app/views/admin/tasks/index.html.erb index 597890e667..6bbf83d2c5 100644 --- a/app/views/admin/tasks/index.html.erb +++ b/app/views/admin/tasks/index.html.erb @@ -20,6 +20,21 @@ <% end %> +<% if @claim.attributes_flagged_by_risk_indicator.any? %> +
+ + + Warning +

+ 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. +

+
+
+<% end %> +
<%= render claim_summary_view, claim: @claim %>
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 fd7e4e3b8c..38d1f064e6 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_17_113701) 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" @@ -421,6 +421,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..146eb4825b --- /dev/null +++ b/spec/features/admin/admin_fraud_prevention_spec.rb @@ -0,0 +1,110 @@ +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 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." + ) + 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 diff --git a/spec/models/claim_spec.rb b/spec/models/claim_spec.rb index e63f7660eb..d6dd248cc1 100644 --- a/spec/models/claim_spec.rb +++ b/spec/models/claim_spec.rb @@ -343,6 +343,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 }