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..3890e12e2d --- /dev/null +++ b/app/forms/admin/fraud_risk_csv_upload_form.rb @@ -0,0 +1,45 @@ +module Admin + class FraudRiskCsvUploadForm + include ActiveModel::Model + + attr_accessor :file + + validates :file, presence: true + + validate :all_records_are_valid, if: -> { file.present? } + + 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 + 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..7fb00d9ef3 --- /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) + end +end 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? %>

@@ -12,10 +12,25 @@

-

- <%= 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..26843f2016 --- /dev/null +++ b/spec/factories/risk_indicators.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :risk_indicator do + field { "MyString" } + value { "MyString" } + 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_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..8e068f4a8e --- /dev/null +++ b/spec/forms/admin/fraud_risk_csv_upload_form_spec.rb @@ -0,0 +1,123 @@ +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 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/risk_indicator_spec.rb b/spec/models/risk_indicator_spec.rb new file mode 100644 index 0000000000..1085ea482b --- /dev/null +++ b/spec/models/risk_indicator_spec.rb @@ -0,0 +1,5 @@ +require "rails_helper" + +RSpec.describe RiskIndicator, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end