Repository: DFE-Digital/early-careers-framework
Pull request #5695: Remap withdrawl reason when `programme_type_changes_2025` feature is enabled
Author: login: ethax-ross
Created: 2025-04-04T14:28:42Z | Merged: 2025-04-11T08:26:03Z

[Jira-4186](https://dfedigital.atlassian.net.mcas.ms/browse/CPDLP-4186)

### Context

We want to remap the withdrawl reason `school-left-fip` to `switched-to-school-led` when the `programme_type_changes_2025` feature flag is enabled. This is part of a wider renaming of programme types.

### Changes proposed in this pull request

- Map withdrawl reason to new value in v3 ParticipantSerializer

As part of the programme type changes we want to map the `switched-to-school-led` withdrawl reason to `school-left-fit` when the `programme_type_changes_2025` feature flag is enabled.

Update the v3 `ParticipantSerializer` to map the withdrawl reason. The v1/2 serializers don't appear to expose the withdrawl reason.

- Update WithdrawParticipant with program type changes

When the `programme_type_changes_2025` feature is enabled we want to support sending the new `switched-to-school-led` withdrawl reason in place of `school-left-fip`.

Avoids changing the `WITHDRAW_REASONS` constant to prevent unintended changes
elsewhere.

- Update ChangeTrainingStatusForm to support new programme types

When the `programme_type_changes_2025` feature is enabled we want to display and accept the new withdrawl reason types in the change training status form.

- Update AssuranceReport::CsvSerializer with programme type changes

As with other places, we want to remap the withdrawl reason when the `programme_type_changes_2025` feature is enabled.

Confusingly, the `AssuranceReport::Query` will only surface withdrawl reasons in the `Training Status Reason` column, even if the current state of the participant is not withdrawn.

### Guidance for review

I thought about extracting the `WITHDRAWN_REASONS` into a method so we could do the remapping there instead of both in the form and csv serializer, but I figured since the feature is temporary its better to keep it consistent with `DEFERRAL_REASONS` in a constant for the short-term.



Diff:
diff --git a/app/forms/finance/ecf/change_training_status_form.rb b/app/forms/finance/ecf/change_training_status_form.rb
index 928da5d06d..656b84b060 100644
--- a/app/forms/finance/ecf/change_training_status_form.rb
+++ b/app/forms/finance/ecf/change_training_status_form.rb
@@ -6,11 +6,6 @@ class ChangeTrainingStatusForm
       include ActiveModel::Model
       include ActiveModel::Attributes
 
-      REASON_OPTIONS = {
-        "deferred" => ParticipantProfile::DEFERRAL_REASONS,
-        "withdrawn" => ParticipantProfile::ECF::WITHDRAW_REASONS,
-      }.freeze
-
       attribute :participant_profile
       attribute :training_status
       attribute :reason
@@ -24,7 +19,10 @@ def training_status_options
       end
 
       def reason_options
-        REASON_OPTIONS.except(current_training_status)
+        {
+          "deferred" => ParticipantProfile::DEFERRAL_REASONS,
+          "withdrawn" => ParticipantProfile::ECF::WITHDRAW_REASONS.map { |reason| ProgrammeTypeMappings.withdrawal_reason(reason:) },
+        }.except(current_training_status)
       end
 
       def current_training_status
diff --git a/app/serializers/api/v3/ecf/participant_serializer.rb b/app/serializers/api/v3/ecf/participant_serializer.rb
index 124e5c6a6e..8654e2e9b8 100644
--- a/app/serializers/api/v3/ecf/participant_serializer.rb
+++ b/app/serializers/api/v3/ecf/participant_serializer.rb
@@ -41,7 +41,7 @@ def withdrawal(profile:, cpd_lead_provider:, latest_induction_record:)
                 .find { |pps| pps.state == ParticipantProfileState.states[:withdrawn] && pps.cpd_lead_provider_id == cpd_lead_provider.id }
               if latest_participant_profile_state.present?
                 {
-                  reason: latest_participant_profile_state.reason,
+                  reason: ProgrammeTypeMappings.withdrawal_reason(reason: latest_participant_profile_state.reason),
                   date: latest_participant_profile_state.created_at.rfc3339,
                 }
               end
diff --git a/app/serializers/finance/ecf/assurance_report/csv_serializer.rb b/app/serializers/finance/ecf/assurance_report/csv_serializer.rb
index f79d87d06b..fd749c6aa0 100644
--- a/app/serializers/finance/ecf/assurance_report/csv_serializer.rb
+++ b/app/serializers/finance/ecf/assurance_report/csv_serializer.rb
@@ -75,7 +75,7 @@ def to_row(record)
             record.school_urn,
             record.school_name,
             record.training_status,
-            record.training_status_reason,
+            training_status_reason(record),
             record.declaration_id,
             record.declaration_status,
             record.declaration_type,
@@ -87,6 +87,10 @@ def to_row(record)
           ]
         end
 
+        def training_status_reason(record)
+          ProgrammeTypeMappings.withdrawal_reason(reason: record.training_status_reason)
+        end
+
         def lead_provider
           statement.lead_provider
         end
diff --git a/app/services/withdraw_participant.rb b/app/services/withdraw_participant.rb
index 3cc927b7b2..49aedecb9c 100644
--- a/app/services/withdraw_participant.rb
+++ b/app/services/withdraw_participant.rb
@@ -19,7 +19,7 @@ class WithdrawParticipant
   validates :reason,
             presence: { message: I18n.t(:missing_reason) },
             inclusion: {
-              in: ->(klass) { klass.participant_profile.class::WITHDRAW_REASONS },
+              in: ->(klass) { klass.participant_profile.class::WITHDRAW_REASONS.map { |reason| ProgrammeTypeMappings.withdrawal_reason(reason:) } },
               message: I18n.t(:invalid_reason),
             }, if: ->(klass) { klass.participant_profile.present? }
   validate :not_already_withdrawn
diff --git a/spec/forms/finance/ecf/change_training_status_form_spec.rb b/spec/forms/finance/ecf/change_training_status_form_spec.rb
index 2a14deba6f..8f0495669a 100644
--- a/spec/forms/finance/ecf/change_training_status_form_spec.rb
+++ b/spec/forms/finance/ecf/change_training_status_form_spec.rb
@@ -1,64 +1,119 @@
 # frozen_string_literal: true
 
-RSpec.describe Finance::ECF::ChangeTrainingStatusForm, type: :model do
+RSpec.shared_context "changing training status form" do
   subject(:form) { described_class.new(params) }
 
-  let(:cpd_lead_provider)   { create(:cpd_lead_provider, :with_lead_provider) }
+  let(:cpd_lead_provider) { create(:cpd_lead_provider, :with_lead_provider) }
   let(:induction_programme) { create(:induction_programme, :fip) }
-  let!(:induction_record)   { Induction::Enrol.call(participant_profile:, induction_programme:) }
-  let(:params)              { { participant_profile:, training_status: "deferred", reason: "bereavement", induction_record: } }
-
-  describe "EarlyCareerTeacher" do
-    let!(:participant_declaration) { create(:ect_participant_declaration, participant_profile:, cpd_lead_provider:) }
-    let(:user) { create(:participant_identity, :secondary).user }
-    let(:participant_profile) { create(:ect, user:, lead_provider: cpd_lead_provider.lead_provider) }
+  let!(:induction_record) { Induction::Enrol.call(participant_profile:, induction_programme:) }
+  let(:params) { { participant_profile:, training_status:, reason: "bereavement", induction_record: } }
+  let(:training_status) { "deferred" }
 
+  describe "validations" do
     it { is_expected.to validate_inclusion_of(:training_status).in_array(ParticipantProfile.training_statuses.values) }
     it { is_expected.to validate_inclusion_of(:reason).in_array(ParticipantProfile::DEFERRAL_REASONS) }
 
-    describe ".save" do
-      context "valid params" do
-        it "should change training status" do
-          expect(form.save).to be true
-          expect(participant_profile.reload).to be_training_status_deferred
-        end
+    context "when the training_status is 'withdrawn'" do
+      let(:training_status) { "withdrawn" }
+
+      it { is_expected.to validate_inclusion_of(:reason).in_array(ParticipantProfile::ECF::WITHDRAW_REASONS) }
+
+      context "when the programme type mappings are enabled" do
+        before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { true } }
+
+        it { is_expected.to allow_value("switched-to-school-led").for(:reason) }
+        it { is_expected.not_to allow_value("school-left-fip").for(:reason) }
       end
 
-      context "invalid params" do
-        let(:params) { { participant_profile:, training_status: nil, reason: nil } }
+      context "when the programme type mappings are disabled" do
+        before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { false } }
 
-        it "should not change training status" do
-          expect(form.save).to be false
-          expect(participant_profile.reload).to be_training_status_active
-        end
+        it { is_expected.to allow_value("school-left-fip").for(:reason) }
+        it { is_expected.not_to allow_value("switched-to-school-led").for(:reason) }
       end
     end
   end
 
-  describe "Mentor" do
-    let!(:participant_declaration) { create(:mentor_participant_declaration, participant_profile:, cpd_lead_provider:) }
-    let(:user) { create(:participant_identity, :secondary).user }
-    let(:participant_profile) { create(:mentor, user:, lead_provider: cpd_lead_provider.lead_provider) }
+  describe "#reason_options" do
+    context "when the current training status is 'deferred'" do
+      before { induction_record.training_status = :deferred }
 
-    it { is_expected.to validate_inclusion_of(:training_status).in_array(ParticipantProfile.training_statuses.values) }
-    it { is_expected.to validate_inclusion_of(:reason).in_array(ParticipantProfile::DEFERRAL_REASONS) }
+      it "returns the correct reason options" do
+        expect(form.reason_options).to eq(
+          "withdrawn" => ParticipantProfile::ECF::WITHDRAW_REASONS,
+        )
+      end
+    end
+
+    context "when the current training status is 'withdrawn'" do
+      before { induction_record.training_status = :withdrawn }
 
-    describe ".save" do
-      context "valid params" do
-        it "should change training status" do
-          expect(form.save).to be true
-          expect(participant_profile.reload.training_status).to eql("deferred")
-        end
+      it "returns the correct reason options" do
+        expect(form.reason_options).to eq(
+          "deferred" => ParticipantProfile::DEFERRAL_REASONS,
+        )
       end
+    end
 
-      context "invalid params" do
-        let(:params) { { participant_profile:, training_status: nil, reason: nil } }
+    context "when the current training status is not 'withdrawn' or 'deferred'" do
+      before { induction_record.training_status = :active }
 
-        it "should not change training status" do
-          expect(form.save).to be false
-          expect(participant_profile.reload.training_status).to eql("active")
-        end
+      it "returns the correct reason options" do
+        expect(form.reason_options).to eq(
+          "withdrawn" => ParticipantProfile::ECF::WITHDRAW_REASONS,
+          "deferred" => ParticipantProfile::DEFERRAL_REASONS,
+        )
       end
     end
+
+    context "when the programme type mappings are enabled" do
+      before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { true } }
+
+      it { expect(form.reason_options["withdrawn"]).to include("switched-to-school-led") }
+      it { expect(form.reason_options["withdrawn"]).not_to include("school-left-fip") }
+    end
+
+    context "when the programme type mappings are disabled" do
+      before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { false } }
+
+      it { expect(form.reason_options["withdrawn"]).not_to include("switched-to-school-led") }
+      it { expect(form.reason_options["withdrawn"]).to include("school-left-fip") }
+    end
+  end
+
+  describe "#save" do
+    context "valid params" do
+      it "changes training status" do
+        expect(form.save).to be true
+        expect(participant_profile.reload).to be_training_status_deferred
+      end
+    end
+
+    context "invalid params" do
+      let(:params) { { participant_profile:, training_status: nil, reason: nil } }
+
+      it "does not change training status" do
+        expect(form.save).to be false
+        expect(participant_profile.reload).to be_training_status_active
+      end
+    end
+  end
+end
+
+RSpec.describe Finance::ECF::ChangeTrainingStatusForm, type: :model do
+  describe "EarlyCareerTeacher" do
+    let!(:participant_declaration) { create(:ect_participant_declaration, participant_profile:, cpd_lead_provider:) }
+    let(:user) { create(:participant_identity, :secondary).user }
+    let(:participant_profile) { create(:ect, user:, lead_provider: cpd_lead_provider.lead_provider) }
+
+    include_context "changing training status form"
+  end
+
+  describe "Mentor" do
+    let!(:participant_declaration) { create(:mentor_participant_declaration, participant_profile:, cpd_lead_provider:) }
+    let(:user) { create(:participant_identity, :secondary).user }
+    let(:participant_profile) { create(:mentor, user:, lead_provider: cpd_lead_provider.lead_provider) }
+
+    include_context "changing training status form"
   end
 end
diff --git a/spec/serializers/api/v3/ecf/participant_serializer_spec.rb b/spec/serializers/api/v3/ecf/participant_serializer_spec.rb
index a921ddfc86..07d14e1e4b 100644
--- a/spec/serializers/api/v3/ecf/participant_serializer_spec.rb
+++ b/spec/serializers/api/v3/ecf/participant_serializer_spec.rb
@@ -115,15 +115,37 @@ module ECF
             end
 
             context "when the profile is withdrawn" do
+              let(:reason) { "other" }
+
               before do
                 ect_profile.induction_records.latest.update!(training_status: "withdrawn")
-                ect_profile.participant_profile_states.last.update!(state: "withdrawn", reason: "other")
+                ect_profile.participant_profile_states.last.update!(state: "withdrawn", reason:)
               end
 
               it "includes a withdrawal object" do
                 expect(ecf_enrolments[0][:withdrawal][:reason]).to eq("other")
                 expect(ecf_enrolments[0][:withdrawal][:date]).to eql(ect_profile.participant_profile_state.created_at.rfc3339)
               end
+
+              context "when the withdrawal reason is 'school-left-fip'" do
+                let(:reason) { "school-left-fip" }
+
+                context "when the programme type mappings are enabled" do
+                  before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { true } }
+
+                  it "replaces `school-left-fip` with `switched-to-school-led`" do
+                    expect(ecf_enrolments[0][:withdrawal][:reason]).to eq("switched-to-school-led")
+                  end
+                end
+
+                context "when the programme type mappings are disabled" do
+                  before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { false } }
+
+                  it "does not replace `school-left-fip` with `switched-to-school-led`" do
+                    expect(ecf_enrolments[0][:withdrawal][:reason]).to eq("school-left-fip")
+                  end
+                end
+              end
             end
 
             context "when the profile is deferred" do
diff --git a/spec/serializers/finance/ecf/assurance_report/csv_serializer_spec.rb b/spec/serializers/finance/ecf/assurance_report/csv_serializer_spec.rb
index 4924979c2f..8e5f30c87d 100644
--- a/spec/serializers/finance/ecf/assurance_report/csv_serializer_spec.rb
+++ b/spec/serializers/finance/ecf/assurance_report/csv_serializer_spec.rb
@@ -62,6 +62,38 @@
         })
       end
 
+      describe "training status reason" do
+        let(:reason) { "other" }
+        let(:declaration) { create(:ect_participant_declaration, :payable) }
+        let(:parsed_training_status_reason) { parsed_csv.first["Training Status Reason"] }
+
+        before do
+          declaration.participant_profile.participant_profile_states.last.update!(state: :withdrawn, reason:)
+        end
+
+        it { expect(parsed_training_status_reason).to eq("other") }
+
+        context "when the reason is `school-left-fip`" do
+          let(:reason) { "school-left-fip" }
+
+          context "when the programme type mappings are enabled" do
+            before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { true } }
+
+            it "replaces `school-left-fip` with `switched-to-school-led`" do
+              expect(parsed_training_status_reason).to eq("switched-to-school-led")
+            end
+          end
+
+          context "when the programme type mappings are disabled" do
+            before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { false } }
+
+            it "does not replace `school-left-fip` with `switched-to-school-led`" do
+              expect(parsed_training_status_reason).to eq("school-left-fip")
+            end
+          end
+        end
+      end
+
       describe "uplift payable" do
         let(:declaration) { create(:ect_participant_declaration, :payable, profile_traits: profile_traits + %i[eligible_for_funding]) }
         let(:parsed_csv_uplift_payable) { parsed_csv.first["Uplift Payable"] }
diff --git a/spec/services/withdraw_participant_spec.rb b/spec/services/withdraw_participant_spec.rb
index b5a10225ec..376c3133dd 100644
--- a/spec/services/withdraw_participant_spec.rb
+++ b/spec/services/withdraw_participant_spec.rb
@@ -23,6 +23,38 @@
     end
   end
 
+  context "when the programme type mappings are enabled" do
+    before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { true } }
+
+    context "when the reason is `school-left-fip`" do
+      let(:reason) { "school-left-fip" }
+
+      it { is_expected.to be_invalid }
+    end
+
+    context "when the reason is `switched-to-school-led`" do
+      let(:reason) { "switched-to-school-led" }
+
+      it { is_expected.to be_valid }
+    end
+  end
+
+  context "when the programme type mappings are disabled" do
+    before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { false } }
+
+    context "when the reason is `school-left-fip`" do
+      let(:reason) { "school-left-fip" }
+
+      it { is_expected.to be_valid }
+    end
+
+    context "when the reason is `switched-to-school-led`" do
+      let(:reason) { "switched-to-school-led" }
+
+      it { is_expected.to be_invalid }
+    end
+  end
+
   context "when the course identifier is missing" do
     let(:course_identifier) {}
 
@@ -84,6 +116,7 @@
     expect(latest_participant_profile_state).to have_attributes(
       participant_profile_id: participant_profile.id,
       state: "withdrawn",
+      reason:,
     )
   end
 


Reviews:
user: login: leandroalemao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:48:16Z
user: login: leandroalemao
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:49:30Z
user: login: leandroalemao
body: Just a few very minor comments... tks
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:50:01Z
user: login: ethax-ross
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T15:05:46Z
user: login: cwrw
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T07:40:03Z
user: login: mooktakim
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T08:30:34Z
user: login: cwrw
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T21:37:31Z

Review comments:
diff_hunk: @@ -115,15 +115,37 @@ module ECF
             end
 
             context "when the profile is withdrawn" do
+              let(:reason) { "other" }
+
               before do
                 ect_profile.induction_records.latest.update!(training_status: "withdrawn")
-                ect_profile.participant_profile_states.last.update!(state: "withdrawn", reason: "other")
+                ect_profile.participant_profile_states.last.update!(state: "withdrawn", reason:)
               end
 
               it "includes a withdrawal object" do
                 expect(ecf_enrolments[0][:withdrawal][:reason]).to eq("other")
                 expect(ecf_enrolments[0][:withdrawal][:date]).to eql(ect_profile.participant_profile_state.created_at.rfc3339)
               end
+
+              context "when the withdrawl reason is 'school-left-fip'" do
path: spec/serializers/api/v3/ecf/participant_serializer_spec.rb
user: login: leandroalemao
body: `withdrawal`
created_at: 2025-04-07T14:48:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,64 +1,119 @@
 # frozen_string_literal: true
 
-RSpec.describe Finance::ECF::ChangeTrainingStatusForm, type: :model do
+RSpec.shared_context "changing training status form" do
   subject(:form) { described_class.new(params) }
 
-  let(:cpd_lead_provider)   { create(:cpd_lead_provider, :with_lead_provider) }
+  let(:cpd_lead_provider) { create(:cpd_lead_provider, :with_lead_provider) }
   let(:induction_programme) { create(:induction_programme, :fip) }
-  let!(:induction_record)   { Induction::Enrol.call(participant_profile:, induction_programme:) }
-  let(:params)              { { participant_profile:, training_status: "deferred", reason: "bereavement", induction_record: } }
-
-  describe "EarlyCareerTeacher" do
-    let!(:participant_declaration) { create(:ect_participant_declaration, participant_profile:, cpd_lead_provider:) }
-    let(:user) { create(:participant_identity, :secondary).user }
-    let(:participant_profile) { create(:ect, user:, lead_provider: cpd_lead_provider.lead_provider) }
+  let!(:induction_record) { Induction::Enrol.call(participant_profile:, induction_programme:) }
+  let(:params) { { participant_profile:, training_status:, reason: "bereavement", induction_record: } }
+  let(:training_status) { "deferred" }
 
+  describe "validations" do
     it { is_expected.to validate_inclusion_of(:training_status).in_array(ParticipantProfile.training_statuses.values) }
     it { is_expected.to validate_inclusion_of(:reason).in_array(ParticipantProfile::DEFERRAL_REASONS) }
 
-    describe ".save" do
-      context "valid params" do
-        it "should change training status" do
-          expect(form.save).to be true
-          expect(participant_profile.reload).to be_training_status_deferred
-        end
+    context "when the training_status is 'withdrawn'" do
+      let(:training_status) { "withdrawn" }
+
+      it { is_expected.to validate_inclusion_of(:reason).in_array(ParticipantProfile::ECF::WITHDRAW_REASONS) }
+
+      context "when the programme type mappings are enabled" do
+        before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { true } }
+
+        it { is_expected.to allow_value("switched-to-school-led").for(:reason) }
+        it { is_expected.not_to allow_value("school-left-fip").for(:reason) }
       end
 
-      context "invalid params" do
-        let(:params) { { participant_profile:, training_status: nil, reason: nil } }
+      context "when the programme type mappings are disabled" do
+        before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { false } }
 
-        it "should not change training status" do
-          expect(form.save).to be false
-          expect(participant_profile.reload).to be_training_status_active
-        end
+        it { is_expected.to allow_value("school-left-fip").for(:reason) }
+        it { is_expected.not_to allow_value("switched-to-school-led").for(:reason) }
       end
     end
   end
 
-  describe "Mentor" do
-    let!(:participant_declaration) { create(:mentor_participant_declaration, participant_profile:, cpd_lead_provider:) }
-    let(:user) { create(:participant_identity, :secondary).user }
-    let(:participant_profile) { create(:mentor, user:, lead_provider: cpd_lead_provider.lead_provider) }
+  describe "#reason_options" do
+    context "when the current training status is 'deferred'" do
+      before { induction_record.training_status = :deferred }
 
-    it { is_expected.to validate_inclusion_of(:training_status).in_array(ParticipantProfile.training_statuses.values) }
-    it { is_expected.to validate_inclusion_of(:reason).in_array(ParticipantProfile::DEFERRAL_REASONS) }
+      it "returns the correct reason options" do
+        expect(form.reason_options).to eq(
+          "withdrawn" => ParticipantProfile::ECF::WITHDRAW_REASONS,
+        )
+      end
+    end
+
+    context "when the current training status is 'withdrawn'" do
+      before { induction_record.training_status = :withdrawn }
 
-    describe ".save" do
-      context "valid params" do
-        it "should change training status" do
-          expect(form.save).to be true
-          expect(participant_profile.reload.training_status).to eql("deferred")
-        end
+      it "returns the correct reason options" do
+        expect(form.reason_options).to eq(
+          "deferred" => ParticipantProfile::DEFERRAL_REASONS,
+        )
       end
+    end
 
-      context "invalid params" do
-        let(:params) { { participant_profile:, training_status: nil, reason: nil } }
+    context "when the current training status is not 'withdrawn' or 'deferred'" do
+      before { induction_record.training_status = :active }
 
-        it "should not change training status" do
-          expect(form.save).to be false
-          expect(participant_profile.reload.training_status).to eql("active")
-        end
+      it "returns the correct reason options" do
+        expect(form.reason_options).to eq(
+          "withdrawn" => ParticipantProfile::ECF::WITHDRAW_REASONS,
+          "deferred" => ParticipantProfile::DEFERRAL_REASONS,
+        )
       end
     end
+
+    context "when the programme type mappings are enabled" do
+      before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { true } }
+
+      it { expect(form.reason_options["withdrawn"]).to include("switched-to-school-led") }
+      it { expect(form.reason_options["withdrawn"]).not_to include("school-left-fip") }
+    end
+
+    context "when the programme type mappings are disabled" do
+      before { allow(ProgrammeTypeMappings).to receive(:mappings_enabled?) { false } }
+
+      it { expect(form.reason_options["withdrawn"]).not_to include("switched-to-school-led") }
+      it { expect(form.reason_options["withdrawn"]).to include("school-left-fip") }
+    end
+  end
+
+  describe "#save" do
+    context "valid params" do
+      it "should change training status" do
path: spec/forms/finance/ecf/change_training_status_form_spec.rb
user: login: leandroalemao
body: it "changes training status"
created_at: 2025-04-07T14:49:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -115,15 +115,37 @@ module ECF
             end
 
             context "when the profile is withdrawn" do
+              let(:reason) { "other" }
+
               before do
                 ect_profile.induction_records.latest.update!(training_status: "withdrawn")
-                ect_profile.participant_profile_states.last.update!(state: "withdrawn", reason: "other")
+                ect_profile.participant_profile_states.last.update!(state: "withdrawn", reason:)
               end
 
               it "includes a withdrawal object" do
                 expect(ecf_enrolments[0][:withdrawal][:reason]).to eq("other")
                 expect(ecf_enrolments[0][:withdrawal][:date]).to eql(ect_profile.participant_profile_state.created_at.rfc3339)
               end
+
+              context "when the withdrawl reason is 'school-left-fip'" do
path: spec/serializers/api/v3/ecf/participant_serializer_spec.rb
user: login: ethax-ross
body: I don't know why I can never spell withdrawal 🤦 
created_at: 2025-04-07T15:05:45Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-07T06:06:10Z
body: Review app deployed to https://cpd-ecf-review-5695-web.test.teacherservices.cloud
<!-- Sticky Pull Request Comment -->
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-10T08:24:21Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DFE-Digital_early-careers-framework&pullRequest=5695) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DFE-Digital_early-careers-framework&pullRequest=5695&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DFE-Digital_early-careers-framework&pullRequest=5695&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DFE-Digital_early-careers-framework&pullRequest=5695&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DFE-Digital_early-careers-framework&pullRequest=5695&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DFE-Digital_early-careers-framework&pullRequest=5695&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=DFE-Digital_early-careers-framework&pullRequest=5695)


author_association: NONE