diff --git a/app/models/cohort.rb b/app/models/cohort.rb index aa611c89bc..b83c811f93 100644 --- a/app/models/cohort.rb +++ b/app/models/cohort.rb @@ -91,14 +91,14 @@ def npq_plus_one_or_earlier? start_year <= NPQ_PLUS_1_YEAR end - def previous - self.class.find_by(start_year: start_year - 1) - end - def payments_frozen? payments_frozen_at.present? end + def previous + self.class.find_by(start_year: start_year - 1) + end + # e.g. "2022" def to_param start_year.to_s diff --git a/app/models/participant_profile/ect.rb b/app/models/participant_profile/ect.rb index a4fa790ce4..66bf14270b 100644 --- a/app/models/participant_profile/ect.rb +++ b/app/models/participant_profile/ect.rb @@ -16,6 +16,8 @@ def self.archivable(restrict_to_participant_ids: []) .where("induction_start_date IS NULL OR induction_start_date < make_date(cohorts.start_year, 9, 1)") end + alias_method :completed_training?, :completed_induction? + def ect? true end diff --git a/app/services/induction/amend_participant_cohort.rb b/app/services/induction/amend_participant_cohort.rb index c5679b90fb..386d30bd1f 100644 --- a/app/services/induction/amend_participant_cohort.rb +++ b/app/services/induction/amend_participant_cohort.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true -# Change the current cohort of an ECF participant. +# Change the cohort of an ECF participant. # In doing so it will add an IR with an induction_programme and schedule on the new cohort. # Also, the participant profile will get their cohort and schedule updated. # # The induction_programme will be the default one for the new cohort in the current school. -# The schedule will be the provided one or the equivalent to the current but in destination cohort with start year :target_cohort_start_year +# The schedule will be the provided one or the equivalent to the current one in the cohort with start year :target_cohort_start_year # # Several validations are run before allowing the change. Specially important are existing declarations. # # Examples: -# - This will set induction_programme and schedule for the participant_profile to 2022/23 cohort checking they sits currently -# in 2021/2022 +# - This will set induction_programme and schedule for the participant_profile to 2022/23 cohort +# checking they are sitting currently in 2021/2022 # Induction::AmendParticipantCohort.new(participant_profile:, # source_cohort_start_year: 2021, # target_cohort_start_year: 2022).save @@ -22,6 +22,10 @@ # source_cohort_start_year: 2021, # schedule:).save # +# - For cohort changes on participants whose current cohort has been payments_frozen and are transferred to +# the active registration cohort, it will automatically flag the participant_profile as +# cohort_changed_after_payments_frozen: true +# module Induction class AmendParticipantCohort include ActiveModel::Model @@ -66,10 +70,14 @@ class AmendParticipantCohort validate :target_cohort_start_year_matches_schedule validates :participant_profile, - participant_profile_active: true + active_participant_profile: true + + validate :transfer_from_payments_frozen_cohort, if: :transfer_from_payments_frozen_cohort? + validate :transfer_to_payments_frozen_cohort, if: :back_to_payments_frozen_cohort? validates :participant_declarations, - absence: { message: :billable_or_submitted } + absence: { message: :billable_or_submitted }, + unless: :payments_frozen_transfer? validates :induction_record, presence: { @@ -102,10 +110,22 @@ def initialize(*) @target_cohort_start_year = (@target_cohort_start_year || @schedule&.cohort_start_year).to_i end + def back_to_payments_frozen_cohort? + participant_profile&.cohort_changed_after_payments_frozen? && target_cohort&.payments_frozen? + end + + def billable_declarations_in_cohort?(cohort) + participant_profile.participant_declarations.where(cohort:).billable.exists? + end + def current_induction_record_updated? ActiveRecord::Base.transaction do - Induction::ChangeInductionRecord.call(induction_record:, changes: { induction_programme:, schedule: }) - participant_profile.update!(school_cohort: target_school_cohort, schedule:) + Induction::ChangeInductionRecord.call(induction_record:, + changes: { induction_programme:, + schedule: }) + participant_profile.update!(school_cohort: target_school_cohort, + schedule:, + cohort_changed_after_payments_frozen:) rescue ActiveRecord::RecordInvalid => e errors.add(:induction_record, induction_record.errors.full_messages.first) if induction_record.errors.any? errors.add(:participant_profile, participant_profile.errors.full_messages.first) if participant_profile.errors.any? @@ -127,7 +147,6 @@ def induction_record @induction_record ||= participant_profile.induction_records .active_induction_status - .training_status_active .joins(induction_programme: { school_cohort: :cohort }) .where(cohorts: { start_year: source_cohort_start_year }) .latest @@ -147,11 +166,12 @@ def in_target_schedule?(induction_record) def participant_declarations return false unless participant_profile + return @participant_declarations if instance_variable_defined?(:@participant_declarations) - @participant_declarations ||= participant_profile - .participant_declarations - .billable_or_changeable - .exists? + @participant_declarations = participant_profile + .participant_declarations + .billable_or_changeable + .exists? end def schedule @@ -176,7 +196,32 @@ def target_school_cohort @target_school_cohort ||= SchoolCohort.find_by(school:, cohort: target_cohort) end + def payments_frozen_transfer? + transfer_from_payments_frozen_cohort? || back_to_payments_frozen_cohort? + end + + def transfer_from_payments_frozen_cohort? + source_cohort&.payments_frozen? && target_cohort == Cohort.active_registration_cohort + end + + alias_method :cohort_changed_after_payments_frozen, :transfer_from_payments_frozen_cohort? + # Validations + + def transfer_from_payments_frozen_cohort + unless participant_profile.eligible_to_change_cohort_and_continue_training?(cohort: target_cohort) + errors.add(:participant_profile, :not_eligible_to_be_transferred_from_current_cohort) + end + end + + def transfer_to_payments_frozen_cohort + unless participant_profile.eligible_to_change_cohort_back_to_their_payments_frozen_original?(cohort: target_cohort, current_cohort: source_cohort) + errors.add(:participant_profile, :billable_declarations_in_cohort) if billable_declarations_in_cohort?(source_cohort) + errors.add(:participant_profile, :no_billable_declarations_in_cohort) unless billable_declarations_in_cohort?(target_cohort) + errors.add(:participant_profile, :not_eligible_to_be_transferred_back) + end + end + def target_cohort_start_year_matches_schedule if schedule && target_cohort_start_year != schedule.cohort_start_year errors.add(:target_cohort_start_year, :incompatible_with_schedule) diff --git a/app/validators/active_participant_profile_validator.rb b/app/validators/active_participant_profile_validator.rb new file mode 100644 index 0000000000..ad66bf74b5 --- /dev/null +++ b/app/validators/active_participant_profile_validator.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ActiveParticipantProfileValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, I18n.t("errors.participant_profile.not_a_participant_profile")) unless participant_profile?(value) + record.errors.add(attribute, I18n.t("errors.participant_profile.not_active")) unless active?(value) + end + + def active?(instance) + instance&.active_record? + end + + def participant_profile?(instance) + instance.is_a?(ParticipantProfile) + end +end diff --git a/app/validators/participant_profile_active_validator.rb b/app/validators/participant_profile_active_validator.rb deleted file mode 100644 index 84763425cc..0000000000 --- a/app/validators/participant_profile_active_validator.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class ParticipantProfileActiveValidator < ActiveModel::EachValidator - def validate_each(record, attribute, value) - record.errors.add(attribute, I18n.t("errors.participant_profile.not_active")) unless active?(value) - end - - def active?(participant_profile) - participant_profile&.active_record? && participant_profile&.training_status_active? - end -end diff --git a/config/locales/en.yml b/config/locales/en.yml index e94165c429..4c26623217 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -203,6 +203,7 @@ en: participant_profile: blank: "Not registered" not_active: "Not active" + not_a_participant_profile: "Not a participant profile record" programme: blank: "Select a programme type" provider: @@ -319,12 +320,15 @@ en: no_default_induction_programme: "No default induction programme set for %{start_academic_year} academic year by school %{school_name}" school_cohort_not_setup: "%{start_academic_year} academic year not setup by school %{school_name}" participant_declarations: - exist: "The participant must have no declarations" billable_or_submitted: "The participant has billable or submitted declarations" completed: "The participant has not had a 'completed' declaration submitted for them. Therefore you cannot update their outcome." participant_profile: + billable_declarations_in_cohort: "The participant has billable declarations in their current cohort" + no_billable_declarations_in_cohort: "The participant has no billable declarations in destination cohort" blank: "Not registered" not_active: "Not active" + not_eligible_to_be_transferred_back: "Not eligible to be transferred back to their original cohort" + not_eligible_to_be_transferred_from_current_cohort: "Not eligible to be transferred from their current cohort" source_cohort_start_year: invalid: "Invalid value. Must be an integer between %{start} and %{end}" target_cohort_start_year: diff --git a/spec/services/induction/amend_participant_cohort_spec.rb b/spec/services/induction/amend_participant_cohort_spec.rb index 59f15f2d32..1078e387a2 100644 --- a/spec/services/induction/amend_participant_cohort_spec.rb +++ b/spec/services/induction/amend_participant_cohort_spec.rb @@ -7,7 +7,9 @@ let(:target_cohort_start_year) { Cohort.current.start_year } subject(:form) do - described_class.new(participant_profile:, source_cohort_start_year:, target_cohort_start_year:) + described_class.new(participant_profile:, + source_cohort_start_year:, + target_cohort_start_year:) end context "when the source_cohort_start_year is not an integer" do @@ -88,10 +90,10 @@ let!(:source_cohort) { create(:cohort, start_year: source_cohort_start_year) } let!(:source_school_cohort) { create(:school_cohort, :fip, cohort: source_cohort) } let!(:school) { source_school_cohort.school } - let!(:target_cohort) { create(:cohort, start_year: target_cohort_start_year) } + let!(:target_cohort) { Cohort.find_by_start_year(target_cohort_start_year) } let!(:target_cohort_schedule) { create(:ecf_schedule, cohort: target_cohort) } let!(:participant_profile) do - create(:ect_participant_profile, training_status: :active, school_cohort: source_school_cohort) + create(:ect_participant_profile, school_cohort: source_school_cohort) end before do @@ -140,6 +142,98 @@ end end + context "when they are transferring back to their original cohort" do + before do + target_cohort.update!(payments_frozen_at: 1.month.ago) + participant_profile.update!(cohort_changed_after_payments_frozen: true) + end + + it "do not set errors" do + expect(form.save).to be_falsey + expect(form.errors.map(&:attribute)).not_to include(:target_cohort_start_year) + end + end + + context "when the participant is transferred from their their payments-frozen cohort to the currently one open for registration" do + let(:target_cohort_start_year) { Cohort.active_registration_cohort.start_year } + + before do + source_cohort.update!(payments_frozen_at: Time.current) + end + + context "when the participant is eligible to be transferred" do + before do + allow(participant_profile).to receive(:eligible_to_change_cohort_and_continue_training?).and_return(true) + end + + it "do not set errors" do + expect(form.save).to be_falsey + expect(form.errors.map(&:attribute)).not_to include(:participant_profile) + end + end + + context "when the participant is not eligible to be transferred" do + before do + allow(participant_profile).to receive(:eligible_to_change_cohort_and_continue_training?).and_return(false) + end + + it "set errors on participant profile" do + expect(form.save).to be_falsey + expect(form.errors[:participant_profile]).to include("Not eligible to be transferred from their current cohort") + end + end + end + + context "when the participant is transferred back to their original payments-frozen cohort" do + before do + target_cohort.update!(payments_frozen_at: 1.month.ago) + participant_profile.update!(cohort_changed_after_payments_frozen: true) + end + + context "when the participant has billable declarations in current cohort" do + before do + participant_profile.participant_declarations.create!(declaration_date: Date.new(source_cohort_start_year, 10, 10), + declaration_type: :started, + state: :eligible, + course_identifier: "ecf-induction", + cpd_lead_provider: create(:cpd_lead_provider), + user: participant_profile.user, + cohort: source_cohort) + end + + it "set errors" do + expect(form.save).to be_falsey + expect(form.errors[:participant_profile]).to include("Not eligible to be transferred back to their original cohort") + expect(form.errors[:participant_profile]).to include("The participant has billable declarations in their current cohort") + end + end + + context "when the participant has no billable declarations in destination cohort" do + it "set errors" do + expect(form.save).to be_falsey + expect(form.errors[:participant_profile]).to include("Not eligible to be transferred back to their original cohort") + expect(form.errors[:participant_profile]).to include("The participant has no billable declarations in destination cohort") + end + end + + context "when the participant is eligible to be transferred" do + before do + participant_profile.participant_declarations.create!(declaration_date: Date.new(target_cohort_start_year, 10, 10), + declaration_type: :started, + state: :eligible, + course_identifier: "ecf-induction", + cpd_lead_provider: create(:cpd_lead_provider), + user: participant_profile.user, + cohort: target_cohort) + end + + it "set no errors" do + expect(form.save).to be_falsey + expect(form.errors[:participant_profile]).to be_blank + end + end + end + %i[submitted eligible payable paid].each do |declaration_state| context "when the participant has #{declaration_state} declarations" do before do @@ -152,7 +246,7 @@ cohort: participant_profile.schedule.cohort) end - it "returns false and set errors" do + it "returns false and set errors on declarations" do expect(form.save).to be_falsey expect(form.errors.first.attribute).to eq(:participant_declarations) expect(form.errors.first.message).to eq("The participant has billable or submitted declarations") @@ -160,6 +254,32 @@ end end + %i[voided ineligible awaiting_clawback clawed_back].each do |declaration_state| + context "when the participant has #{declaration_state} declarations" do + before do + participant_profile.participant_declarations.create!(declaration_date: Date.new(2020, 10, 10), + declaration_type: :started, + state: declaration_state, + course_identifier: "ecf-induction", + cpd_lead_provider: create(:cpd_lead_provider), + user: participant_profile.user, + cohort: participant_profile.schedule.cohort) + end + + it "do not set errors on declarations" do + expect(form.save).to be_falsey + expect(form.errors.map(&:attribute)).not_to include(:participant_declarations) + end + end + end + + context "when the participant has no declarations" do + it "do not set errors on declarations" do + expect(form.save).to be_falsey + expect(form.errors.map(&:attribute)).not_to include(:participant_declarations) + end + end + context "when the participant is not enrolled on the source school cohort" do it "returns false and set errors" do expect(form.save).to be_falsey @@ -272,10 +392,31 @@ cohort: participant_profile.schedule.cohort) end - it "executes the transfer, returns true and set no errors" do + it "executes the transfer" do + expect(form.save).to be_truthy + expect(participant_profile.reload.latest_induction_record.cohort_start_year).to eq(target_cohort_start_year) + end + + it "returns true and set no errors" do expect(form.save).to be_truthy expect(form.errors).to be_empty end + + context "when the transfer is due to payments frozen in the cohort of the participant" do + before do + source_cohort.update!(payments_frozen_at: Time.current) + allow(participant_profile).to receive(:eligible_to_change_cohort_and_continue_training?).and_return(true) + end + + it "mark the participant as transferred for that reason" do + expect(form.save).to be_truthy + expect(participant_profile).to be_cohort_changed_after_payments_frozen + end + + it "mark the participant as transferred from the original cohort" do + expect(form.save).to be_truthy + end + end end end diff --git a/spec/validators/active_participant_profile_validator_spec.rb b/spec/validators/active_participant_profile_validator_spec.rb new file mode 100644 index 0000000000..9b8c7c85c1 --- /dev/null +++ b/spec/validators/active_participant_profile_validator_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +class ActiveParticipantProfileValidatorConsumerClass + include ActiveModel::Model + attr_accessor :participant_profile + + validates :participant_profile, active_participant_profile: true +end + +RSpec.describe ActiveParticipantProfileValidator, type: :model do + subject { ActiveParticipantProfileValidatorConsumerClass.new(participant_profile:) } + + context "when the participant is not a ParticipantProfile instance" do + let(:participant_profile) { nil } + + it "is not valid" do + expect(subject).not_to be_valid + expect(subject.errors[:participant_profile]).to include(I18n.t("errors.participant_profile.not_a_participant_profile")) + end + end + + context "when the participant has not active induction status" do + let(:participant_profile) { ParticipantProfile::ECT.new(status: :withdrawn) } + + it "is not valid" do + expect(subject).to_not be_valid + expect(subject.errors[:participant_profile]).to include(I18n.t("errors.participant_profile.not_active")) + end + end + + context "when the participant has active induction and training statuses" do + let(:participant_profile) { ParticipantProfile::Mentor.new(status: :active) } + + it "is valid" do + expect(subject).to be_valid + expect(subject.errors[:participant_profile]).to be_empty + end + end +end