From cbf59f803daf946fa59ae0ae6f025ad8ea3b7d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Alem=C3=A3o?= Date: Thu, 2 Jan 2025 14:13:03 +0000 Subject: [PATCH 1/2] [CPDLP-3894] Move one offs ecf services to a higher up folder --- app/services/oneoffs/{ecf => }/add_band_c.rb | 2 +- app/services/oneoffs/{ecf => }/change_service_fees.rb | 2 +- app/services/oneoffs/{ecf => }/remove_fees_from_contracts.rb | 2 +- app/services/oneoffs/{ecf => }/update_contracts.rb | 2 +- spec/services/oneoffs/{ecf => }/add_band_c_spec.rb | 2 +- spec/services/oneoffs/{ecf => }/change_service_fees_spec.rb | 2 +- .../oneoffs/{ecf => }/remove_fees_from_contracts_spec.rb | 2 +- spec/services/oneoffs/{ecf => }/update_contracts_spec.rb | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) rename app/services/oneoffs/{ecf => }/add_band_c.rb (99%) rename app/services/oneoffs/{ecf => }/change_service_fees.rb (99%) rename app/services/oneoffs/{ecf => }/remove_fees_from_contracts.rb (98%) rename app/services/oneoffs/{ecf => }/update_contracts.rb (99%) rename spec/services/oneoffs/{ecf => }/add_band_c_spec.rb (99%) rename spec/services/oneoffs/{ecf => }/change_service_fees_spec.rb (99%) rename spec/services/oneoffs/{ecf => }/remove_fees_from_contracts_spec.rb (99%) rename spec/services/oneoffs/{ecf => }/update_contracts_spec.rb (99%) diff --git a/app/services/oneoffs/ecf/add_band_c.rb b/app/services/oneoffs/add_band_c.rb similarity index 99% rename from app/services/oneoffs/ecf/add_band_c.rb rename to app/services/oneoffs/add_band_c.rb index e9e67e1dc8..fa605a1c5f 100644 --- a/app/services/oneoffs/ecf/add_band_c.rb +++ b/app/services/oneoffs/add_band_c.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module Oneoffs::ECF +module Oneoffs class AddBandC def initialize(cohort_year:, cpd_lead_provider:, payment_date_range:, band_c_params:) @cohort_year = cohort_year diff --git a/app/services/oneoffs/ecf/change_service_fees.rb b/app/services/oneoffs/change_service_fees.rb similarity index 99% rename from app/services/oneoffs/ecf/change_service_fees.rb rename to app/services/oneoffs/change_service_fees.rb index f4ee20a2c0..17592b9d8e 100644 --- a/app/services/oneoffs/ecf/change_service_fees.rb +++ b/app/services/oneoffs/change_service_fees.rb @@ -2,7 +2,7 @@ require "has_recordable_information" -module Oneoffs::ECF +module Oneoffs class ChangeServiceFees class CallOffContractNotFoundError < StandardError; end diff --git a/app/services/oneoffs/ecf/remove_fees_from_contracts.rb b/app/services/oneoffs/remove_fees_from_contracts.rb similarity index 98% rename from app/services/oneoffs/ecf/remove_fees_from_contracts.rb rename to app/services/oneoffs/remove_fees_from_contracts.rb index 04ca854148..d23adcfd0d 100644 --- a/app/services/oneoffs/ecf/remove_fees_from_contracts.rb +++ b/app/services/oneoffs/remove_fees_from_contracts.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module Oneoffs::ECF +module Oneoffs class RemoveFeesFromContracts attr_reader :cohort_year, :from_date diff --git a/app/services/oneoffs/ecf/update_contracts.rb b/app/services/oneoffs/update_contracts.rb similarity index 99% rename from app/services/oneoffs/ecf/update_contracts.rb rename to app/services/oneoffs/update_contracts.rb index 45015ba8cd..31989b55f7 100644 --- a/app/services/oneoffs/ecf/update_contracts.rb +++ b/app/services/oneoffs/update_contracts.rb @@ -2,7 +2,7 @@ require "csv" -module Oneoffs::ECF +module Oneoffs class UpdateContracts include HasRecordableInformation diff --git a/spec/services/oneoffs/ecf/add_band_c_spec.rb b/spec/services/oneoffs/add_band_c_spec.rb similarity index 99% rename from spec/services/oneoffs/ecf/add_band_c_spec.rb rename to spec/services/oneoffs/add_band_c_spec.rb index af4b09a390..e3ed076cdb 100644 --- a/spec/services/oneoffs/ecf/add_band_c_spec.rb +++ b/spec/services/oneoffs/add_band_c_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Oneoffs::ECF::AddBandC do +RSpec.describe Oneoffs::AddBandC do let(:cohort) { create(:cohort, start_year: 2021) } let(:payment_date_range) { Date.new(2023, 10, 1)..Date.new(2023, 11, 30) } let(:band_c_params) do diff --git a/spec/services/oneoffs/ecf/change_service_fees_spec.rb b/spec/services/oneoffs/change_service_fees_spec.rb similarity index 99% rename from spec/services/oneoffs/ecf/change_service_fees_spec.rb rename to spec/services/oneoffs/change_service_fees_spec.rb index 9d06f4535c..b54bcadbf5 100644 --- a/spec/services/oneoffs/ecf/change_service_fees_spec.rb +++ b/spec/services/oneoffs/change_service_fees_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe Oneoffs::ECF::ChangeServiceFees do +describe Oneoffs::ChangeServiceFees do let(:cpd_lead_provider) { ecf_statement.cpd_lead_provider } let(:cohort) { create(:cohort, start_year: 2021) } diff --git a/spec/services/oneoffs/ecf/remove_fees_from_contracts_spec.rb b/spec/services/oneoffs/remove_fees_from_contracts_spec.rb similarity index 99% rename from spec/services/oneoffs/ecf/remove_fees_from_contracts_spec.rb rename to spec/services/oneoffs/remove_fees_from_contracts_spec.rb index b827989fcd..3db4e1f0c5 100644 --- a/spec/services/oneoffs/ecf/remove_fees_from_contracts_spec.rb +++ b/spec/services/oneoffs/remove_fees_from_contracts_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Oneoffs::ECF::RemoveFeesFromContracts do +RSpec.describe Oneoffs::RemoveFeesFromContracts do let(:cohort) { create(:cohort, start_year: 2021) } subject { described_class.new(cohort_year: 2021, from_date: "2023-10-01") } diff --git a/spec/services/oneoffs/ecf/update_contracts_spec.rb b/spec/services/oneoffs/update_contracts_spec.rb similarity index 99% rename from spec/services/oneoffs/ecf/update_contracts_spec.rb rename to spec/services/oneoffs/update_contracts_spec.rb index 89a48de114..9d63fef884 100644 --- a/spec/services/oneoffs/ecf/update_contracts_spec.rb +++ b/spec/services/oneoffs/update_contracts_spec.rb @@ -2,7 +2,7 @@ require "tempfile" -RSpec.describe Oneoffs::ECF::UpdateContracts do +RSpec.describe Oneoffs::UpdateContracts do let(:cohort) { create(:cohort, start_year: 2024) } let(:cpd_lead_provider) { create(:cpd_lead_provider, :with_lead_provider, name: "Great Provider") } let(:lead_provider) { cpd_lead_provider.lead_provider } From e02ed9f6e81b24c782e477288473087795240e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Alem=C3=A3o?= Date: Fri, 3 Jan 2025 01:31:01 +0000 Subject: [PATCH 2/2] [CPDLP-3894] Add MigrateDeclarationsBetweenStatements service --- ...migrate_declarations_between_statements.rb | 184 ++++++++++ ...te_declarations_between_statements_spec.rb | 345 ++++++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 app/services/oneoffs/migrate_declarations_between_statements.rb create mode 100644 spec/services/oneoffs/migrate_declarations_between_statements_spec.rb diff --git a/app/services/oneoffs/migrate_declarations_between_statements.rb b/app/services/oneoffs/migrate_declarations_between_statements.rb new file mode 100644 index 0000000000..966e78ca00 --- /dev/null +++ b/app/services/oneoffs/migrate_declarations_between_statements.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "has_recordable_information" + +module Oneoffs + class MigrateDeclarationsBetweenStatements + class StatementMismatchError < RuntimeError; end + class PaidStatementMigrationError < RuntimeError; end + + include HasRecordableInformation + + def initialize( + cohort:, + from_statement_name:, + to_statement_name:, + from_statement_updates: {}, + to_statement_updates: {}, + restrict_to_lead_providers: nil, + restrict_to_declaration_types: nil, + restrict_to_declaration_states: nil, + restrict_to_course_identifiers: nil, + restrict_to_created_on_or_before: nil + ) + @cohort = cohort + @from_statement_name = from_statement_name + @to_statement_name = to_statement_name + @to_statement_updates = to_statement_updates + @from_statement_updates = from_statement_updates + @restrict_to_lead_providers = restrict_to_lead_providers + @restrict_to_declaration_types = restrict_to_declaration_types + @restrict_to_declaration_states = restrict_to_declaration_states + @restrict_to_course_identifiers = restrict_to_course_identifiers + @restrict_to_created_on_or_before = restrict_to_created_on_or_before + end + + def migrate(dry_run: true) + reset_recorded_info + prevent_migrating_from_paid_statement! + warn_unless_to_statements_are_future_dated + ensure_statements_align! + record_summary_info(dry_run) + + ActiveRecord::Base.transaction do + migrate_declarations_between_statements! + update_from_statement_attributes! + update_to_statement_attributes! + + raise ActiveRecord::Rollback if dry_run + end + + recorded_info + end + + private + + attr_reader :cohort, :from_statement_name, :to_statement_name, + :to_statement_updates, :from_statement_updates, + :restrict_to_lead_providers, :restrict_to_declaration_types, + :restrict_to_declaration_states, :restrict_to_course_identifiers, + :restrict_to_created_on_or_before + + def update_from_statement_attributes! + return if from_statement_updates.blank? + + from_statements_by_provider.each_value do |statement| + statement.update!(from_statement_updates) + record_info("Statement #{statement.name} for #{statement.lead_provider.name} updated with #{from_statement_updates}") + end + end + + def update_to_statement_attributes! + return if to_statement_updates.blank? + + to_statements_by_provider.each_value do |statement| + statement.update!(to_statement_updates) + record_info("Statement #{statement.name} for #{statement.lead_provider.name} updated with #{to_statement_updates}") + end + end + + def migrate_declarations_between_statements! + each_statements_by_provider do |provider, from_statement, to_statement| + migrate_line_items!(provider, from_statement, to_statement) + end + end + + def migrate_line_items!(provider, from_statement, to_statement) + statement_line_items = filter_statement_line_items(from_statement.statement_line_items) + + record_info("Migrating #{statement_line_items.size} declarations for #{provider.name}") + statement_line_items.update!(statement_id: to_statement.id) + + make_eligible_declaration_payable_for_to_statement(to_statement, statement_line_items) + make_payable_declaration_eligible_for_to_statement(to_statement, statement_line_items) + end + + def each_statements_by_provider + from_statements_by_provider.each do |provider, from_statement| + to_statement = to_statements_by_provider[provider] + yield(provider, from_statement, to_statement) + end + end + + def make_eligible_declaration_payable_for_to_statement(to_statement, statement_line_items) + declarations = statement_line_items.map(&:participant_declaration).uniq + eligible_declarations = declarations.select(&:eligible?) + + return unless to_statement.payable? + return unless eligible_declarations.any? + + service = ParticipantDeclarations::MarkAsPayable.new(to_statement) + action = service.class.to_s.underscore.humanize.split.last + + record_info("Marking #{eligible_declarations.size} eligible declarations as #{action} for #{to_statement.name} statement") + + eligible_declarations.each { |declaration| service.call(declaration) } + end + + def make_payable_declaration_eligible_for_to_statement(to_statement, statement_line_items) + declarations = statement_line_items.map(&:participant_declaration).uniq + payable_declarations = declarations.select(&:payable?) + + return if to_statement.payable? || to_statement.paid? + return unless payable_declarations.any? + + record_info("Marking #{payable_declarations.size} payable declarations back as eligible for #{to_statement.name} statement") + + payable_declarations.each { |declaration| DeclarationState.eligible!(declaration) } + statement_line_items.select(&:payable?).map(&:eligible!) + end + + def filter_statement_line_items(statement_line_items) + scope = statement_line_items.includes(:participant_declaration) + scope = scope.where(participant_declaration: { declaration_type: restrict_to_declaration_types }) if restrict_to_declaration_types + scope = scope.where(participant_declaration: { state: restrict_to_declaration_states }) if restrict_to_declaration_states + scope = scope.where(participant_declaration: { course_identifier: restrict_to_course_identifiers }) if restrict_to_course_identifiers + scope = scope.where(participant_declaration: { created_at: ..restrict_to_created_on_or_before }) if restrict_to_created_on_or_before + + scope + end + + def record_summary_info(dry_run) + record_info("~~~ DRY RUN ~~~") if dry_run + record_info("Migrating declarations from #{from_statement_name} to #{to_statement_name} for #{provider_count} providers") + end + + def warn_unless_to_statements_are_future_dated + record_info("Warning: to statements are not future dated") if to_statements_by_provider.values.any? { |statement| statement.deadline_date.past? } + end + + def ensure_statements_align! + statements_mismatch = from_statements_by_provider.keys.sort != to_statements_by_provider.keys.sort + statements_empty = from_statements_by_provider.empty? && to_statements_by_provider.empty? + + raise StatementMismatchError, "There is a mismatch between to/from statements" if statements_mismatch + raise StatementMismatchError, "No statements were found" if statements_empty + end + + def prevent_migrating_from_paid_statement! + raise PaidStatementMigrationError, "Cannot migrate from a paid statement" if from_statements_by_provider.values.any?(&:paid?) + end + + def provider_count + from_statements_by_provider.count + end + + def from_statements_by_provider + @from_statements_by_provider ||= statements_by_provider(from_statement_name) + end + + def to_statements_by_provider + @to_statements_by_provider ||= statements_by_provider(to_statement_name) + end + + def statements_by_provider(statement_name) + lead_provider = restrict_to_lead_providers || LeadProvider.all + + Finance::Statement::ECF + .includes(:cohort, :participant_declarations, cpd_lead_provider: :lead_provider) + .where(cohort:, name: statement_name, cpd_lead_provider: { lead_provider: }) + .group_by(&:lead_provider) + .transform_values(&:first) + end + end +end diff --git a/spec/services/oneoffs/migrate_declarations_between_statements_spec.rb b/spec/services/oneoffs/migrate_declarations_between_statements_spec.rb new file mode 100644 index 0000000000..00df5ee7fb --- /dev/null +++ b/spec/services/oneoffs/migrate_declarations_between_statements_spec.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +describe Oneoffs::MigrateDeclarationsBetweenStatements, mid_cohort: true do + let(:from_statement_updates) { {} } + let(:to_statement_updates) { {} } + let(:cpd_lead_provider) { create(:cpd_lead_provider, :with_lead_provider) } + let(:lead_provider) { cpd_lead_provider.lead_provider } + let(:from_statement) { create(:ecf_statement, name: "April 2023", cpd_lead_provider:, cohort:, output_fee: true) } + let(:to_statement) { create(:ecf_statement, :next_output_fee, name: "May 2023", cpd_lead_provider:, cohort:) } + let(:from_statement_name) { from_statement.name } + let(:to_statement_name) { to_statement.name } + let(:cohort) { Cohort.current } + let(:restrict_to_lead_providers) { nil } + let(:restrict_to_declaration_types) { nil } + let(:restrict_to_declaration_states) { nil } + let(:restrict_to_course_identifiers) { nil } + let(:restrict_to_created_on_or_before) { nil } + + let(:instance) do + described_class.new( + cohort:, + from_statement_name:, + to_statement_name:, + from_statement_updates:, + to_statement_updates:, + restrict_to_lead_providers:, + restrict_to_declaration_types:, + restrict_to_declaration_states:, + restrict_to_course_identifiers:, + restrict_to_created_on_or_before:, + ) + end + + before { allow(Rails.logger).to receive(:info) } + + describe "#migrate" do + let(:dry_run) { false } + + subject(:migrate) { instance.migrate(dry_run:) } + + it { is_expected.to eq(instance.recorded_info) } + + context "when there are declarations" do + let(:declaration) { create(:ect_participant_declaration, :payable, cohort:, cpd_lead_provider:, declaration_type: :started) } + let(:from_statement) { declaration.statements.first } + let(:declaration2) { create(:mentor_participant_declaration, :eligible, cohort:, declaration_type: :"retained-1") } + let!(:from_statement2) { declaration2.statements.first.tap { |s| s.update!(name: from_statement.name) } } + let(:cpd_lead_provider2) { declaration2.cpd_lead_provider } + let(:lead_provider2) { cpd_lead_provider2.lead_provider } + let!(:to_statement2) { create(:ecf_statement, :next_output_fee, name: to_statement.name, cpd_lead_provider: cpd_lead_provider2, cohort:) } + + let(:declarations) { [declaration1, declaration2] } + + it "migrates them to the new statement" do + migrate + + expect(declaration.statement_line_items.map(&:statement)).to all(eq(to_statement)) + expect(declaration2.statement_line_items.map(&:statement)).to all(eq(to_statement2)) + end + + it "records information" do + migrate + + expect(instance).to have_recorded_info([ + "Migrating declarations from #{from_statement_name} to #{to_statement_name} for 2 providers", + "Migrating 1 declarations for #{lead_provider.name}", + "Migrating 1 declarations for #{lead_provider2.name}", + ]) + end + + context "when restrict_to_lead_providers is provided" do + let(:restrict_to_lead_providers) { [lead_provider] } + + it "migrates only the declarations for the given lead provider to the new statement" do + migrate + + expect(declaration.statement_line_items.map(&:statement)).to all(eq(to_statement)) + expect(declaration2.statement_line_items.map(&:statement)).to all(eq(from_statement2)) + end + + it "records information" do + migrate + + expect(instance).to have_recorded_info([ + "Migrating declarations from #{from_statement_name} to #{to_statement_name} for 1 providers", + "Migrating 1 declarations for #{lead_provider.name}", + ]) + end + end + + context "when restrict_to_declaration_types is provided" do + let(:restrict_to_declaration_types) { [:started] } + + it "migrates only the declarations with the given declaration type" do + migrate + + expect(declaration.statement_line_items.map(&:statement)).to all(eq(to_statement)) + expect(declaration2.statement_line_items.map(&:statement)).to all(eq(from_statement2)) + end + + it "records information" do + migrate + + expect(instance).to have_recorded_info([ + "Migrating declarations from #{from_statement_name} to #{to_statement_name} for 2 providers", + "Migrating 1 declarations for #{lead_provider.name}", + "Migrating 0 declarations for #{lead_provider2.name}", + ]) + end + + context "when restrict_to_declaration_types contains a string" do + let(:restrict_to_declaration_types) { %w[retained-1] } + + it "migrates only the declarations with the given declaration type" do + migrate + + expect(declaration2.statement_line_items.map(&:statement)).to all(eq(to_statement2)) + expect(declaration.statement_line_items.map(&:statement)).to all(eq(from_statement)) + end + end + end + + context "when restrict_to_declaration_states is provided" do + let(:restrict_to_declaration_states) { [:eligible] } + + it "migrates only the declarations with the given declaration type" do + migrate + + expect(declaration.statement_line_items.map(&:statement)).to all(eq(from_statement)) + expect(declaration2.statement_line_items.map(&:statement)).to all(eq(to_statement2)) + end + + it "records information" do + migrate + + expect(instance).to have_recorded_info([ + "Migrating declarations from #{from_statement_name} to #{to_statement_name} for 2 providers", + "Migrating 0 declarations for #{lead_provider.name}", + "Migrating 1 declarations for #{lead_provider2.name}", + ]) + end + + context "when restrict_to_declaration_types contains a string" do + let(:restrict_to_declaration_states) { %w[eligible] } + + it "migrates only the declarations with the given declaration type" do + migrate + + expect(declaration.statement_line_items.map(&:statement)).to all(eq(from_statement)) + expect(declaration2.statement_line_items.map(&:statement)).to all(eq(to_statement2)) + end + end + end + + context "when from_statement_updates are provided" do + let(:from_statement_updates) { { output_fee: false } } + + it "updates the to statements" do + migrate + + expect(from_statement.reload).to have_attributes(from_statement_updates) + expect(instance).to have_recorded_info([ + "Statement #{from_statement.name} for #{from_statement.lead_provider.name} updated with #{from_statement_updates}", + ]) + end + end + + context "when to_statement_updates are provided" do + let(:to_statement_updates) { { deadline_date: 5.days.from_now.to_date, payment_date: 2.days.from_now.to_date } } + + it "updates the to statements" do + migrate + + expect(to_statement.reload).to have_attributes(to_statement_updates) + expect(instance).to have_recorded_info([ + "Statement #{to_statement.name} for #{to_statement.lead_provider.name} updated with #{to_statement_updates}", + ]) + end + end + + context "when dry_run is true" do + let(:dry_run) { true } + + it "does not make any changes, but logs out as if it does" do + expect { migrate }.not_to change { declaration.statement_line_items.first.reload.statement } + + expect(instance).to have_recorded_info([ + "~~~ DRY RUN ~~~", + "Migrating declarations from #{from_statement_name} to #{to_statement_name} for 2 providers", + "Migrating 1 declarations for #{lead_provider.name}", + "Migrating 1 declarations for #{lead_provider2.name}", + ]) + end + end + + context "when restrict_to_course_identifiers is provided" do + let(:restrict_to_course_identifiers) { [declaration2.course_identifier] } + + it "migrates only the declarations with the given course identifier" do + migrate + + expect(declaration.statement_line_items.map(&:statement)).to all(eq(from_statement)) + expect(declaration2.statement_line_items.map(&:statement)).to all(eq(to_statement2)) + end + + it "records information" do + migrate + + expect(instance).to have_recorded_info([ + "Migrating declarations from #{from_statement_name} to #{to_statement_name} for 2 providers", + "Migrating 0 declarations for #{lead_provider.name}", + "Migrating 1 declarations for #{lead_provider2.name}", + ]) + end + end + + context "when restrict_to_created_on_or_before is provided" do + let(:restrict_to_created_on_or_before) { Date.new(2024, 12, 31) } + + before do + declaration2.update!(created_at: restrict_to_created_on_or_before) + migrate + end + + it "migrates only the declarations created on or before the given date" do + expect(declaration.statement_line_items.map(&:statement)).to all(eq(from_statement)) + expect(declaration2.statement_line_items.map(&:statement)).to all(eq(to_statement2)) + end + + it "records information" do + expect(instance).to have_recorded_info([ + "Migrating declarations from #{from_statement_name} to #{to_statement_name} for 2 providers", + "Migrating 0 declarations for #{lead_provider.name}", + "Migrating 1 declarations for #{lead_provider2.name}", + ]) + end + end + end + + context "when migrating to a payable statement" do + let(:to_statement) { create(:ecf_payable_statement, name: "May 2023", cpd_lead_provider:, cohort:) } + let(:declaration) { create(:ect_participant_declaration, :eligible, cohort:, cpd_lead_provider:) } + let(:from_statement) { declaration.statements.first } + + it "migrates eligible declarations to the new statement and makes them payable" do + migrate + + declaration.reload + + expect(declaration.statement_line_items.map(&:statement)).to all(eq(to_statement)) + expect(declaration).to be_payable + end + + it "records information" do + migrate + + expect(instance).to have_recorded_info([ + "Migrating declarations from #{from_statement_name} to #{to_statement_name} for 1 providers", + "Migrating 1 declarations for #{lead_provider.name}", + "Marking 1 eligible declarations as payable for #{to_statement_name} statement", + ]) + end + + context "when there are declarations are already payable" do + let(:declaration) { create(:ect_participant_declaration, :payable, cohort:, cpd_lead_provider:) } + let(:from_statement) { declaration.statement_line_items.first.statement } + + it "migrates them, but does not attempt to make them payable" do + migrate + + declaration.reload + + expect(declaration.statement_line_items.map(&:statement)).to all(eq(to_statement)) + expect(declaration).to be_payable + expect(instance.recorded_info).not_to include(/eligible declarations as payable/) + end + end + end + + context "when migrating to an eligible statement" do + let(:to_statement) { create(:ecf_statement, name: "May 2023", cpd_lead_provider:, cohort:) } + let(:declaration) { create(:ect_participant_declaration, :payable, cohort:, cpd_lead_provider:) } + let(:from_statement) { declaration.statements.first } + + it "migrates payable declarations to the new statement and makes them eligible" do + migrate + + declaration.reload + + expect(declaration.statement_line_items.map(&:statement)).to all(eq(to_statement)) + expect(declaration).to be_eligible + expect(to_statement.statement_line_items.map(&:state)).to eq(%w[eligible]) + end + + it "records information" do + migrate + + expect(instance).to have_recorded_info([ + "Migrating declarations from #{from_statement_name} to #{to_statement_name} for 1 providers", + "Migrating 1 declarations for #{lead_provider.name}", + "Marking 1 payable declarations back as eligible for #{to_statement_name} statement", + ]) + end + end + + context "when migrating from a paid statement" do + let(:declaration) { create(:ect_participant_declaration, :paid, cohort:, cpd_lead_provider:) } + let(:from_statement) { declaration.statements.first } + + it { expect { migrate }.to raise_error(described_class::PaidStatementMigrationError, "Cannot migrate from a paid statement") } + end + + describe "integrity checks" do + context "when there is a mismatch between the number of statements" do + let!(:mismatched_statement) { create(:ecf_statement, cohort:, name: from_statement.name, output_fee: true) } + + it { expect { migrate }.to raise_error(described_class::StatementMismatchError, "There is a mismatch between to/from statements") } + end + + context "when a to statement has a deadline date in the past" do + before do + to_statement.update!(deadline_date: 1.day.ago) + migrate + end + + it { expect(instance).to have_recorded_info(["Warning: to statements are not future dated"]) } + end + + context "when attempting to migrate between statements on different cohorts" do + let(:other_cohort) { Cohort.previous } + + before { from_statement.update!(cohort: other_cohort) } + + it { expect { migrate }.to raise_error(described_class::StatementMismatchError, "There is a mismatch between to/from statements") } + end + + context "when there are no statements found" do + let(:from_statement_name) { "Not found" } + let(:to_statement_name) { "Not found" } + + it { expect { migrate }.to raise_error(described_class::StatementMismatchError, "No statements were found") } + end + end + end +end