Compare commits

...

10 Commits

Author SHA1 Message Date
Samuel Young 614fd111e5
CLDC-4300: Update sale date staircasing date validation (#3341) 1 week ago
Samuel Young 13b2fc61c1
CLDC-3280: Remove error report upload again when viewing before decision (#3303) 1 week ago
Samuel Young e079455f65
CLDC-4332: Check that a support user can only be in certain orgs (#3305) 2 weeks ago
Samuel Young 99ea75f9e8
Revert "CLDC-4300: Update sale date staircasing date validation (#3307)" (#3339) 2 weeks ago
Nat Dean-Lewis e72707b77e
CLDC-4389: BU validation prioritisation (#3317) 2 weeks ago
Nat Dean-Lewis 8d6cd83549
CLDC-4269: Raise max hhmemb (#3332) 2 weeks ago
Nat Dean-Lewis aa79c1fdfe
CLDC-4370: Update reason for leaving renewal hint text (#3336) 2 weeks ago
Nat Dean-Lewis 4367e80545
CLDC-4352: Update building height class hint text as dropdown (#3335) 2 weeks ago
Samuel Young 1e1773605c
CLDC-4300: Update sale date staircasing date validation (#3307) 2 weeks ago
Samuel Young a592cb8a66
CLDC-4234: Allow 'R' as an input for prevten (#3326) 2 weeks ago
  1. 10
      app/controllers/users_controller.rb
  2. 2
      app/models/derived_variables/lettings_log_variables.rb
  3. 9
      app/models/form/lettings/pages/person_known.rb
  4. 2
      app/models/form/lettings/questions/hhmemb.rb
  5. 1
      app/models/form/sales/questions/building_height_class.rb
  6. 6
      app/models/forms/bulk_upload_resume/confirm.rb
  7. 6
      app/models/forms/bulk_upload_resume/fix_choice.rb
  8. 3
      app/models/lettings_log.rb
  9. 8
      app/models/log.rb
  10. 1
      app/models/sales_log.rb
  11. 17
      app/models/user.rb
  12. 4
      app/models/validations/financial_validations.rb
  13. 6
      app/models/validations/sales/sale_information_validations.rb
  14. 35
      app/models/validations/soft_validations.rb
  15. 3
      app/services/bulk_upload/lettings/year2025/row_parser.rb
  16. 3
      app/services/bulk_upload/lettings/year2026/row_parser.rb
  17. 22
      app/services/bulk_upload/sales/year2025/row_parser.rb
  18. 21
      app/services/bulk_upload/sales/year2026/row_parser.rb
  19. 6
      app/services/feature_toggle.rb
  20. 4
      app/views/bulk_upload_lettings_results/show.html.erb
  21. 8
      app/views/bulk_upload_lettings_results/summary.html.erb
  22. 2
      app/views/bulk_upload_lettings_resume/confirm.html.erb
  23. 2
      app/views/bulk_upload_lettings_resume/fix_choice.html.erb
  24. 4
      app/views/bulk_upload_sales_results/show.html.erb
  25. 8
      app/views/bulk_upload_sales_results/summary.html.erb
  26. 2
      app/views/bulk_upload_sales_resume/confirm.html.erb
  27. 2
      app/views/bulk_upload_sales_resume/fix_choice.html.erb
  28. 5
      app/views/form/guidance/_building_height_class.html.erb
  29. 4
      config/locales/en.yml
  30. 2
      config/locales/forms/2025/lettings/household_characteristics.en.yml
  31. 2
      config/locales/forms/2025/lettings/household_situation.en.yml
  32. 2
      config/locales/forms/2026/lettings/household_characteristics.en.yml
  33. 2
      config/locales/forms/2026/lettings/household_situation.en.yml
  34. 9
      config/locales/forms/2026/sales/guidance.en.yml
  35. 4
      config/locales/forms/2026/sales/property_information.en.yml
  36. 15
      lib/tasks/fix_sales_logs_with_invalid_initialpurchase_lasttransaction.rake
  37. 29
      spec/models/form/lettings/pages/person_known_spec.rb
  38. 4
      spec/models/form/sales/questions/building_height_class_spec.rb
  39. 47
      spec/models/user_spec.rb
  40. 12
      spec/models/validations/household_validations_spec.rb
  41. 69
      spec/models/validations/sales/sale_information_validations_spec.rb
  42. 36
      spec/requests/bulk_upload_lettings_results_controller_spec.rb
  43. 36
      spec/requests/bulk_upload_sales_results_controller_spec.rb
  44. 10
      spec/services/bulk_upload/sales/year2025/row_parser_spec.rb
  45. 10
      spec/services/bulk_upload/sales/year2026/row_parser_spec.rb

10
app/controllers/users_controller.rb

@ -221,8 +221,14 @@ private
@user.errors.add :phone
end
if user_params.key?(:organisation_id) && user_params[:organisation_id].blank?
@user.errors.add :organisation_id, :blank
if user_params.key?(:organisation_id)
if user_params[:organisation_id].blank?
@user.errors.add :organisation_id, :blank
elsif !@user.role_is_allowed_to_be_in_organisation?(override_organisation_id: user_params[:organisation_id].to_i) && @user.id.present?
# this will also be flagged by the validation in user.rb.
# for convenience we show the error early before they go through the change org flow (involves reassigning logs).
@user.errors.add :organisation_id, I18n.t("validations.user.support_user_in_wrong_organisation.change_organisation")
end
end
end

2
app/models/derived_variables/lettings_log_variables.rb

@ -339,7 +339,7 @@ private
def infer_only_partner!(partner_number)
return unless hhmemb
(2..hhmemb).each do |i|
(2..people_with_details).each do |i|
next if i == partner_number
if ["P", nil].include?(public_send("relat#{i}"))

9
app/models/form/lettings/pages/person_known.rb

@ -2,11 +2,18 @@ class Form::Lettings::Pages::PersonKnown < ::Form::Page
def initialize(id, hsh, subsection, person_index:)
super(id, hsh, subsection)
@id = "person_#{person_index}_known"
@depends_on = (person_index..8).map { |index| { "hhmemb" => index } }
@person_index = person_index
@depends_on = depends_on
end
def questions
@questions ||= [Form::Lettings::Questions::DetailsKnown.new(nil, nil, self, person_index: @person_index)]
end
def depends_on
[{ "hhmemb" => {
"operator" => ">=",
"operand" => @person_index,
} }]
end
end

2
app/models/form/lettings/questions/hhmemb.rb

@ -5,7 +5,7 @@ class Form::Lettings::Questions::Hhmemb < ::Form::Question
@type = "numeric"
@width = 2
@check_answers_card_number = 0
@max = 8
@max = 15
@min = 1
@step = 1
@question_number = get_question_number_from_hash(QUESTION_NUMBER_FROM_YEAR)

1
app/models/form/sales/questions/building_height_class.rb

@ -3,6 +3,7 @@ class Form::Sales::Questions::BuildingHeightClass < ::Form::Question
super
@id = "buildheightclass"
@type = "radio"
@top_guidance_partial = "building_height_class"
@answer_options = ANSWER_OPTIONS
@question_number = get_question_number_from_hash(QUESTION_NUMBER_FROM_YEAR)
end

6
app/models/forms/bulk_upload_resume/confirm.rb

@ -20,11 +20,11 @@ module Forms
send("resume_bulk_upload_#{log_type}_result_path", bulk_upload)
end
def error_report_path
def error_report_path(read_only: false)
if BulkUploadErrorSummaryTableComponent.new(bulk_upload:).errors?
send("summary_bulk_upload_#{log_type}_result_path", bulk_upload)
send("summary_bulk_upload_#{log_type}_result_path", bulk_upload, hide_upload_button: read_only ? "true" : nil)
else
send("bulk_upload_#{log_type}_result_path", bulk_upload)
send("bulk_upload_#{log_type}_result_path", bulk_upload, hide_upload_button: read_only ? "true" : nil)
end
end

6
app/models/forms/bulk_upload_resume/fix_choice.rb

@ -34,11 +34,11 @@ module Forms
end
end
def error_report_path
def error_report_path(read_only: false)
if BulkUploadErrorSummaryTableComponent.new(bulk_upload:).errors?
send("summary_bulk_upload_#{log_type}_result_path", bulk_upload)
send("summary_bulk_upload_#{log_type}_result_path", bulk_upload, hide_upload_button: read_only ? "true" : nil)
else
send("bulk_upload_#{log_type}_result_path", bulk_upload)
send("bulk_upload_#{log_type}_result_path", bulk_upload, hide_upload_button: read_only ? "true" : nil)
end
end

3
app/models/lettings_log.rb

@ -191,6 +191,7 @@ class LettingsLog < Log
NUM_OF_WEEKS_FROM_PERIOD = { 2 => 26, 3 => 13, 4 => 12, 5 => 50, 6 => 49, 7 => 48, 8 => 47, 9 => 46, 11 => 51, 1 => 52, 10 => 53 }.freeze
SUFFIX_FROM_PERIOD = { 2 => "every 2 weeks", 3 => "every 4 weeks", 4 => "every month" }.freeze
DUPLICATE_LOG_ATTRIBUTES = %w[owning_organisation_id tenancycode startdate age1_known age1 sex1 sexrab1 ecstat1 tcharge household_charge chcharge].freeze
MAX_PEOPLE_WITH_DETAILS = 8 # This is not yet used in all lettings validations etc. so check for other occurrences of this concept if updating this
RENT_TYPE = {
social_rent: 0,
affordable_rent: 1,
@ -284,7 +285,7 @@ class LettingsLog < Log
range = ALLOWED_INCOME_RANGES[ecstat1].clone
if hhmemb > 1
(2..hhmemb).each do |person_index|
(2..people_with_details).each do |person_index|
ecstat = self["ecstat#{person_index}"]
if ecstat.nil?

8
app/models/log.rb

@ -204,6 +204,14 @@ class Log < ApplicationRecord
false
end
def people_with_details
[hhmemb || max_people_with_details, max_people_with_details].min
end
def max_people_with_details
self.class::MAX_PEOPLE_WITH_DETAILS
end
def ethnic_refused?
ethnic_group == 17
end

1
app/models/sales_log.rb

@ -104,6 +104,7 @@ class SalesLog < Log
OPTIONAL_FIELDS = %w[purchid othtype buyers_organisations].freeze
DUPLICATE_LOG_ATTRIBUTES = %w[owning_organisation_id purchid saledate age1_known age1 sex1 sexrab1 ecstat1 postcode_full uprn address_line1].freeze
MAX_PEOPLE_WITH_DETAILS = 6 # This is not yet used in all sales validations etc. so check for other occurrences of this concept if updating this
def lettings?
false

17
app/models/user.rb

@ -25,6 +25,7 @@ class User < ApplicationRecord
validates :organisation_id, presence: true
validate :organisation_not_merged
validate :support_user_is_in_correct_organisation
has_paper_trail ignore: %w[last_sign_in_at
current_sign_in_at
@ -390,6 +391,12 @@ class User < ApplicationRecord
end
end
def role_is_allowed_to_be_in_organisation?(override_organisation_id: nil)
return true unless support? && FeatureToggle.support_organisation_allow_list.present?
FeatureToggle.support_organisation_allow_list.include?(override_organisation_id || organisation_id)
end
protected
# Checks whether a password is needed or not. For validations only.
@ -407,6 +414,16 @@ private
end
end
def support_user_is_in_correct_organisation
return if role_is_allowed_to_be_in_organisation?
if role_changed?
errors.add :role, I18n.t("validations.user.support_user_in_wrong_organisation.change_role")
else
errors.add :organisation_id, I18n.t("validations.user.support_user_in_wrong_organisation.change_organisation")
end
end
def send_data_protection_confirmation_reminder
return unless persisted?
return unless is_dpo?

4
app/models/validations/financial_validations.rb

@ -41,7 +41,7 @@ module Validations::FinancialValidations
:over_hard_max,
message: I18n.t("validations.lettings.financial.hhmemb.earnings_over_hard_max", earnings: format_as_currency(record.earnings), frequency:),
)
(1..record.hhmemb).each do |n|
(1..record.people_with_details).each do |n|
record.errors.add(
"ecstat#{n}",
:over_hard_max,
@ -70,7 +70,7 @@ module Validations::FinancialValidations
:under_hard_min,
message: I18n.t("validations.lettings.financial.hhmemb.earnings_under_hard_min", earnings: format_as_currency(record.earnings), frequency:),
)
(1..record.hhmemb).each do |n|
(1..record.people_with_details).each do |n|
record.errors.add(
"ecstat#{n}",
:under_hard_min,

6
app/models/validations/sales/sale_information_validations.rb

@ -42,7 +42,7 @@ module Validations::Sales::SaleInformationValidations
record.errors.add :initialpurchase, I18n.t("validations.sales.sale_information.initialpurchase.must_be_after_1980")
end
if record.saledate.present? && record.initialpurchase > record.saledate
if record.saledate.present? && ((record.initialpurchase > record.saledate) || (record.initialpurchase == record.saledate && record.form.start_year_2026_or_later?))
record.errors.add :initialpurchase, I18n.t("validations.sales.sale_information.initialpurchase.must_be_before_saledate")
record.errors.add :saledate, :skip_bu_error, message: I18n.t("validations.sales.sale_information.saledate.must_be_after_initial_purchase_date")
end
@ -55,11 +55,11 @@ module Validations::Sales::SaleInformationValidations
record.errors.add :lasttransaction, I18n.t("validations.sales.sale_information.lasttransaction.must_be_after_1980")
end
if record.saledate.present? && record.lasttransaction > record.saledate
if record.saledate.present? && ((record.lasttransaction > record.saledate) || (record.lasttransaction == record.saledate && record.form.start_year_2026_or_later?))
record.errors.add :lasttransaction, I18n.t("validations.sales.sale_information.lasttransaction.must_be_before_saledate")
record.errors.add :saledate, :skip_bu_error, message: I18n.t("validations.sales.sale_information.saledate.must_be_after_last_transaction_date")
end
if record.initialpurchase.present? && record.lasttransaction < record.initialpurchase
if record.initialpurchase.present? && ((record.lasttransaction < record.initialpurchase) || (record.lasttransaction == record.initialpurchase && record.form.start_year_2026_or_later?))
record.errors.add :initialpurchase, I18n.t("validations.sales.sale_information.initialpurchase.must_be_before_last_transaction")
record.errors.add :lasttransaction, I18n.t("validations.sales.sale_information.lasttransaction.must_be_after_initial_purchase")
end

35
app/models/validations/soft_validations.rb

@ -208,8 +208,7 @@ module Validations::SoftValidations
def multiple_partners?
return unless hhmemb
max_person_with_details = sales? ? [hhmemb, 6].min : [hhmemb, 8].min
(2..max_person_with_details).many? { |n| public_send("relat#{n}") == "P" }
(2..people_with_details).many? { |n| public_send("relat#{n}") == "P" }
end
def at_least_one_working_situation_is_sickness_and_household_sickness_is_no?
@ -219,22 +218,18 @@ module Validations::SoftValidations
private
def all_tenants_age_and_gender_information_completed?
return false if hhmemb.present? && hhmemb > 8
return false if hhmemb.present? && hhmemb > max_people_with_details
return false unless all_tenants_gender_information_completed?
person_count = hhmemb || 8
(1..person_count).all? do |n|
(1..people_with_details).all? do |n|
public_send("age#{n}").present? && public_send("age#{n}_known").present? && public_send("age#{n}_known").zero?
end
end
def all_tenants_gender_information_completed?
return false if hhmemb.present? && hhmemb > 8
person_count = hhmemb || 8
return false if hhmemb.present? && hhmemb > max_people_with_details
(1..person_count).all? do |n|
(1..people_with_details).all? do |n|
tenant_gender_information_completed?(n)
end
end
@ -258,27 +253,21 @@ private
end
def any_non_male_in_expected_pregnancy_age_range(min, max)
person_count = hhmemb || 8
(1..person_count).any? do |n|
(1..people_with_details).any? do |n|
person_in_expected_pregnancy_age_range(n, min, max) && person_is_non_male(n)
end
end
def non_males_in_the_household?
person_count = hhmemb || 8
(1..person_count).any? do |n|
(1..people_with_details).any? do |n|
person_is_non_male(n)
end
end
def all_male_tenants_in_the_household?
return false if hhmemb.present? && hhmemb > 8
return false if hhmemb.present? && hhmemb > max_people_with_details
person_count = hhmemb || 8
(1..person_count).all? do |n|
(1..people_with_details).all? do |n|
person_is_male(n)
end
end
@ -344,11 +333,7 @@ private
end
def at_least_one_person_working_situation_is_illness?
return if hhmemb.present? && hhmemb > 8
person_count = hhmemb || 8
(1..person_count).any? { |n| public_send("ecstat#{n}") == 8 }
(1..people_with_details).any? { |n| public_send("ecstat#{n}") == 8 }
end
def no_one_in_household_with_illness?

3
app/services/bulk_upload/lettings/year2025/row_parser.rb

@ -461,7 +461,6 @@ class BulkUpload::Lettings::Year2025::RowParser
validate :validate_uprn_exists_if_any_key_address_fields_are_blank, on: :after_log, unless: -> { supported_housing? }
validate :validate_address_fields, on: :after_log, unless: -> { supported_housing? }
validate :validate_incomplete_soft_validations, on: :after_log
validate :validate_nationality, on: :after_log
validate :validate_reasonpref_reason_values, on: :after_log
validate :validate_prevten_value_when_renewal, on: :after_log
@ -506,6 +505,8 @@ class BulkUpload::Lettings::Year2025::RowParser
end
end
validate_incomplete_soft_validations
add_errors_for_invalid_fields
@valid = errors.blank?

3
app/services/bulk_upload/lettings/year2026/row_parser.rb

@ -496,7 +496,6 @@ class BulkUpload::Lettings::Year2026::RowParser
validate :validate_uprn_exists_if_any_key_address_fields_are_blank, on: :after_log
validate :validate_address_fields, on: :after_log
validate :validate_incomplete_soft_validations, on: :after_log
validate :validate_nationality, on: :after_log
validate :validate_reasonpref_reason_values, on: :after_log
validate :validate_prevten_value_when_renewal, on: :after_log
@ -541,6 +540,8 @@ class BulkUpload::Lettings::Year2026::RowParser
end
end
validate_incomplete_soft_validations
add_errors_for_invalid_fields
@valid = errors.blank?

22
app/services/bulk_upload/sales/year2025/row_parser.rb

@ -154,6 +154,7 @@ class BulkUpload::Sales::Year2025::RowParser
:field_52, # Gender identity of person 5
:field_56, # Gender identity of person 6
:field_58, # What was buyer 1’s previous tenure?
:field_64, # What was buyer 2’s previous tenure?
:field_75, # What is the total amount the buyers had in savings before they paid any deposit for the property?
@ -227,7 +228,7 @@ class BulkUpload::Sales::Year2025::RowParser
attribute :field_56, :string
attribute :field_57, :integer
attribute :field_58, :integer
attribute :field_58, :string
attribute :field_59, :integer
attribute :field_60, :string
attribute :field_61, :string
@ -438,8 +439,6 @@ class BulkUpload::Sales::Year2025::RowParser
validate :validate_assigned_to_when_support, on: :after_log
validate :validate_managing_org_related, on: :after_log
validate :validate_relevant_collection_window, on: :after_log
validate :validate_incomplete_soft_validations, on: :after_log
validate :validate_uprn_exists_if_any_key_address_fields_are_blank, on: :after_log
validate :validate_address_fields, on: :after_log
validate :validate_if_log_already_exists, on: :after_log, if: -> { FeatureToggle.bulk_upload_duplicate_log_check_enabled? }
@ -503,6 +502,8 @@ class BulkUpload::Sales::Year2025::RowParser
end
end
validate_incomplete_soft_validations
add_errors_for_invalid_fields
errors.blank?
@ -564,6 +565,15 @@ private
end
end
def prevten
case field_58
when "R"
0
else
field_58
end
end
def prevtenbuy2
case field_64
when "R"
@ -910,7 +920,7 @@ private
attributes["savings"] = field_75.to_i if attributes["savingsnk"]&.zero? && field_75&.match(/\A\d+\z/)
attributes["prevown"] = field_76
attributes["prevten"] = field_58
attributes["prevten"] = prevten
attributes["prevloc"] = field_62
attributes["previous_la_known"] = previous_la_known
attributes["ppcodenk"] = previous_postcode_known
@ -1291,7 +1301,7 @@ private
def infer_soctenant_from_prevten_and_prevtenbuy2
return unless shared_ownership?
if [1, 2].include?(field_58) || [1, 2].include?(field_64.to_i)
if [1, 2].include?(field_58.to_i) || [1, 2].include?(field_64.to_i)
1
else
2
@ -1509,7 +1519,7 @@ private
next if log.form.questions.none? { |q| q.id == interruption_screen_question_id && q.page.routed_to?(log, nil) }
field_mapping_for_errors[interruption_screen_question_id.to_sym]&.each do |field|
if errors.none? { |e| e.options[:category] == :soft_validation && field_mapping_for_errors[interruption_screen_question_id.to_sym].include?(e.attribute) }
if errors.none? { |e| field_mapping_for_errors[interruption_screen_question_id.to_sym].include?(e.attribute) }
error_message = [display_title_text(question.page.title_text, log), display_informative_text(question.page.informative_text, log)].reject(&:empty?).join(" ")
errors.add(field, message: error_message, category: :soft_validation)
end

21
app/services/bulk_upload/sales/year2026/row_parser.rb

@ -169,6 +169,7 @@ class BulkUpload::Sales::Year2026::RowParser
:field_61, # Person 5's sex, as registered at birth
:field_67, # Person 6's sex, as registered at birth
:field_71, # What was buyer 1’s previous tenure?
:field_77, # What was buyer 2’s previous tenure?
:field_88, # What is the total amount the buyers had in savings before they paid any deposit for the property?
@ -263,7 +264,7 @@ class BulkUpload::Sales::Year2026::RowParser
attribute :field_69, :string
attribute :field_70, :integer
attribute :field_71, :integer
attribute :field_71, :string
attribute :field_72, :integer
attribute :field_73, :string
attribute :field_74, :string
@ -492,7 +493,6 @@ class BulkUpload::Sales::Year2026::RowParser
validate :validate_assigned_to_when_support, on: :after_log
validate :validate_managing_org_related, on: :after_log
validate :validate_relevant_collection_window, on: :after_log
validate :validate_incomplete_soft_validations, on: :after_log
validate :validate_uprn_exists_if_any_key_address_fields_are_blank, on: :after_log
validate :validate_address_fields, on: :after_log
@ -560,6 +560,8 @@ class BulkUpload::Sales::Year2026::RowParser
end
end
validate_incomplete_soft_validations
add_errors_for_invalid_fields
errors.blank?
@ -621,6 +623,15 @@ private
end
end
def prevten
case field_71
when "R"
0
else
field_71
end
end
def prevtenbuy2
case field_77
when "R"
@ -1003,7 +1014,7 @@ private
attributes["savings"] = field_88.to_i if attributes["savingsnk"]&.zero? && field_88&.match(/\A\d+\z/)
attributes["prevown"] = field_89
attributes["prevten"] = field_71
attributes["prevten"] = prevten
attributes["prevloc"] = field_75
attributes["previous_la_known"] = previous_la_known
attributes["ppcodenk"] = previous_postcode_known
@ -1424,7 +1435,7 @@ private
def infer_soctenant_from_prevten_and_prevtenbuy2
return unless shared_ownership?
if [1, 2].include?(field_71) || [1, 2].include?(field_77.to_i)
if [1, 2].include?(field_71.to_i) || [1, 2].include?(field_77.to_i)
1
else
2
@ -1662,7 +1673,7 @@ private
next if log.form.questions.none? { |q| q.id == interruption_screen_question_id && q.page.routed_to?(log, nil) }
field_mapping_for_errors[interruption_screen_question_id.to_sym]&.each do |field|
if errors.none? { |e| e.options[:category] == :soft_validation && field_mapping_for_errors[interruption_screen_question_id.to_sym].include?(e.attribute) }
if errors.none? { |e| field_mapping_for_errors[interruption_screen_question_id.to_sym].include?(e.attribute) }
error_message = [display_title_text(question.page.title_text, log), display_informative_text(question.page.informative_text, log)].reject(&:empty?).join(" ")
errors.add(field, message: error_message, category: :soft_validation)
end

6
app/services/feature_toggle.rb

@ -34,4 +34,10 @@ class FeatureToggle
def self.sales_export_enabled?
Time.zone.now >= Time.zone.local(2025, 4, 1) || (Rails.env.review? || Rails.env.staging?)
end
# IDs of organisations a user must be in to be allowed the support role
# if nil this feature will be disabled
def self.support_organisation_allow_list
[1] if Rails.env.production?
end
end

4
app/views/bulk_upload_lettings_results/show.html.erb

@ -33,4 +33,6 @@
</div>
</div>
<%= govuk_button_link_to "Upload your file again", start_bulk_upload_lettings_logs_path(organisation_id: @bulk_upload.organisation_id) %>
<% if params[:hide_upload_button] != "true" %>
<%= govuk_button_link_to "Upload your file again", start_bulk_upload_lettings_logs_path(organisation_id: @bulk_upload.organisation_id) %>
<% end %>

8
app/views/bulk_upload_lettings_results/summary.html.erb

@ -1,3 +1,7 @@
<% content_for :before_content do %>
<%= govuk_back_link(href: :back) %>
<% end %>
<%= render partial: "bulk_upload_shared/moved_user_banner" %>
<div class="govuk-grid-row">
@ -34,4 +38,6 @@
<% end %>
</div>
<%= govuk_button_link_to "Upload your file again", start_bulk_upload_lettings_logs_path(organisation_id: @bulk_upload.organisation_id) %>
<% if params[:hide_upload_button] != "true" %>
<%= govuk_button_link_to "Upload your file again", start_bulk_upload_lettings_logs_path(organisation_id: @bulk_upload.organisation_id) %>
<% end %>

2
app/views/bulk_upload_lettings_resume/confirm.html.erb

@ -9,7 +9,7 @@
<p class="govuk-body">
<%= logs_and_errors_warning(@bulk_upload) %>
<%= govuk_link_to "View the error report", @form.error_report_path %>
<%= govuk_link_to "View the error report", @form.error_report_path(read_only: true) %>
</p>
<% if unique_answers_to_be_cleared(@bulk_upload).present? %>

2
app/views/bulk_upload_lettings_resume/fix_choice.html.erb

@ -19,7 +19,7 @@
</div>
<div class="govuk-body">
<%= govuk_link_to "View the error report", @form.error_report_path %>
<%= govuk_link_to "View the error report", @form.error_report_path(read_only: true) %>
</div>
<%= govuk_details(summary_text: "How to choose between fixing errors on the CORE site or in the CSV") do %>

4
app/views/bulk_upload_sales_results/show.html.erb

@ -33,4 +33,6 @@
</div>
</div>
<%= govuk_button_link_to "Upload your file again", start_bulk_upload_sales_logs_path(organisation_id: @bulk_upload.organisation_id) %>
<% if params[:hide_upload_button] != "true" %>
<%= govuk_button_link_to "Upload your file again", start_bulk_upload_sales_logs_path(organisation_id: @bulk_upload.organisation_id) %>
<% end %>

8
app/views/bulk_upload_sales_results/summary.html.erb

@ -1,3 +1,7 @@
<% content_for :before_content do %>
<%= govuk_back_link(href: :back) %>
<% end %>
<%= render partial: "bulk_upload_shared/moved_user_banner" %>
<div class="govuk-grid-row">
@ -34,4 +38,6 @@
<% end %>
</div>
<%= govuk_button_link_to "Upload your file again", start_bulk_upload_sales_logs_path(organisation_id: @bulk_upload.organisation_id) %>
<% if params[:hide_upload_button] != "true" %>
<%= govuk_button_link_to "Upload your file again", start_bulk_upload_sales_logs_path(organisation_id: @bulk_upload.organisation_id) %>
<% end %>

2
app/views/bulk_upload_sales_resume/confirm.html.erb

@ -9,7 +9,7 @@
<p class="govuk-body">
<%= logs_and_errors_warning(@bulk_upload) %>
<%= govuk_link_to "View the error report", @form.error_report_path %>
<%= govuk_link_to "View the error report", @form.error_report_path(read_only: true) %>
</p>
<% if unique_answers_to_be_cleared(@bulk_upload).present? %>

2
app/views/bulk_upload_sales_resume/fix_choice.html.erb

@ -19,7 +19,7 @@
</div>
<div class="govuk-body">
<%= govuk_link_to "View the error report", @form.error_report_path %>
<%= govuk_link_to "View the error report", @form.error_report_path(read_only: true) %>
</div>
<%= govuk_details(summary_text: "How to choose between fixing errors on the CORE site or in the CSV") do %>

5
app/views/form/guidance/_building_height_class.html.erb

@ -0,0 +1,5 @@
<div class="govuk-body">
<%= govuk_details(summary_text: I18n.t("forms.#{@log.form.start_date.year}.sales.guidance.building_height_class.title")) do %>
<%= I18n.t("forms.#{@log.form.start_date.year}.sales.guidance.building_height_class.content").html_safe %>
<% end %>
</div>

4
config/locales/en.yml

@ -260,6 +260,10 @@ en:
blank: "Enter an email address."
role:
invalid: "Role must be data accessor, data provider or data coordinator."
user:
support_user_in_wrong_organisation:
change_role: "You cannot create a support account type for a user in this organisation. Support accounts should only be created for MHCLG and contractor staff as they are administrator level accounts with access to all organisations' data. Any support accounts for housing organisations would be a data protection breach."
change_organisation: "You cannot move a user with a support account to a non-MHCLG organisation. If you need to move the user, change their role type to data coordinator or data provider."
setup:
saledate:

2
config/locales/forms/2025/lettings/household_characteristics.en.yml

@ -7,7 +7,7 @@ en:
page_header: ""
check_answer_label: "Number of household members"
check_answer_prompt: "Enter total number of household members"
hint_text: "You can provide details for a maximum of 8 people."
hint_text: "You can answer up to 15 people. You will be asked to add details for a maximum of 8 people in the next questions."
question_text: "How many people live in the household for this letting?"
age1:

2
config/locales/forms/2025/lettings/household_situation.en.yml

@ -23,7 +23,7 @@ en:
reason:
check_answer_label: "Reason for leaving last settled home"
check_answer_prompt: ""
hint_text: "You told us this letting is a renewal. We have removed some options because of this."
hint_text: "The tenant’s ‘last settled home’ is their last long-standing home. For tenants who were in temporary accommodation, sleeping rough or otherwise homeless, their last settled home is where they were living previously.<br><br>You told us this letting is a renewal. We have removed some options because of this."
question_text: "What is the tenant’s main reason for the household leaving their last settled home?"
reasonother:
check_answer_label: ""

2
config/locales/forms/2026/lettings/household_characteristics.en.yml

@ -7,7 +7,7 @@ en:
page_header: ""
check_answer_label: "Number of household members"
check_answer_prompt: "Enter total number of household members"
hint_text: "You can provide details for a maximum of 8 people."
hint_text: "You can answer up to 15 people. You will be asked to add details for a maximum of 8 people in the next questions."
question_text: "How many people live in the household for this letting?"
age1:

2
config/locales/forms/2026/lettings/household_situation.en.yml

@ -23,7 +23,7 @@ en:
reason:
check_answer_label: "Reason for leaving last settled home"
check_answer_prompt: ""
hint_text: "You told us this letting is a renewal. We have removed some options because of this."
hint_text: "The tenant’s ‘last settled home’ is their last long-standing home. For tenants who were in temporary accommodation, sleeping rough or otherwise homeless, their last settled home is where they were living immediately before that period.<br><br>You told us this letting is a renewal. We have removed some options because of this."
question_text: "What is the tenant’s main reason for the household leaving their last settled home?"
reasonother:
check_answer_label: ""

9
config/locales/forms/2026/sales/guidance.en.yml

@ -55,3 +55,12 @@ en:
title: "What is a UPRN?"
content: "<p>The Unique Property Reference Number (UPRN) is a unique number system created by Ordnance Survey and used by housing providers and various industries across the UK. An example is 0010457355.</p>
<p>The UPRN may not be the same as the property reference assigned by your organisation.</p>"
building_height_class:
title: "What do these classifications mean?"
content: "<p>High-rise residential buildings are those containing 2 or more residential units and either:</p>
<ul class=\"govuk-list govuk-list--bullet\">
<li>have 7 or more stories</li>
<li>are at least 18 metres in height</li>
</ul>
<p>If unsure, answer based on the number of storeys.</p>"

4
config/locales/forms/2026/sales/property_information.en.yml

@ -60,10 +60,10 @@ en:
question_text: "What type of unit is the property?"
buildheightclass:
page_header: ""
page_header: "Building height classification"
check_answer_label: "Building height classification"
check_answer_prompt: ""
hint_text: "High-rise residential buildings are those containing 2 or more residential units and either have 7 or more storeys or are at least 18 metres in height. If unsure, answer based on the number of storeys."
hint_text: ""
question_text: "What is the building height classification?"
builtype:

15
lib/tasks/fix_sales_logs_with_invalid_initialpurchase_lasttransaction.rake

@ -0,0 +1,15 @@
desc "We tightened the validation between initial purchase date in 2026, last transaction date and sale date so the two can no longer be equal. To avoid invalid logs we clear initialpurchase if it equals saledate and if initialpurchase = lasttransaction we clear both"
task fix_sales_logs_with_invalid_initialpurchase_lasttransaction: :environment do
initial_purchase_equal_lasttransaction_logs = SalesLog.filter_by_year_or_later(2026).where("initialpurchase = lasttransaction")
lasttransaction_equal_saledate_logs = SalesLog.filter_by_year_or_later(2026).where("lasttransaction = saledate")
# this one must happen first since this will always result in a log that passes date validations
puts "Updating #{initial_purchase_equal_lasttransaction_logs.count} logs where initialpurchase = lasttransaction, #{initial_purchase_equal_lasttransaction_logs.map(&:id)}"
initial_purchase_equal_lasttransaction_logs.update!(initialpurchase: nil, lasttransaction: nil)
# this one could fail if lasttransaction == saledate == initialpurchase, but the above case will have already reset these logs
puts "Updating #{lasttransaction_equal_saledate_logs.count} logs where lasttransaction = saledate, #{lasttransaction_equal_saledate_logs.map(&:id)}"
lasttransaction_equal_saledate_logs.update!(lasttransaction: nil)
puts "Done"
end

29
spec/models/form/lettings/pages/person_known_spec.rb

@ -26,15 +26,12 @@ RSpec.describe Form::Lettings::Pages::PersonKnown, type: :model do
it "has correct depends_on" do
expect(page.depends_on).to eq(
[
{ "hhmemb" => 2 },
{ "hhmemb" => 3 },
{ "hhmemb" => 4 },
{ "hhmemb" => 5 },
{ "hhmemb" => 6 },
{ "hhmemb" => 7 },
{ "hhmemb" => 8 },
],
[{
"hhmemb" => {
"operator" => ">=",
"operand" => 2,
},
}],
)
end
end
@ -52,14 +49,12 @@ RSpec.describe Form::Lettings::Pages::PersonKnown, type: :model do
it "has correct depends_on" do
expect(page.depends_on).to eq(
[
{ "hhmemb" => 3 },
{ "hhmemb" => 4 },
{ "hhmemb" => 5 },
{ "hhmemb" => 6 },
{ "hhmemb" => 7 },
{ "hhmemb" => 8 },
],
[{
"hhmemb" => {
"operator" => ">=",
"operand" => 3,
},
}],
)
end
end

4
spec/models/form/sales/questions/building_height_class_spec.rb

@ -36,4 +36,8 @@ RSpec.describe Form::Sales::Questions::BuildingHeightClass, type: :model do
it "has the correct question_number" do
expect(question.question_number).to eq(17)
end
it "has correct guidance partial" do
expect(question.top_guidance_partial).to eq("building_height_class")
end
end

47
spec/models/user_spec.rb

@ -540,6 +540,53 @@ RSpec.describe User, type: :model do
.to raise_error(ActiveRecord::RecordInvalid, error_message)
end
end
describe "#support_user_is_in_correct_organisation" do
let(:organisation) { create(:organisation) }
context "when the user is not a support user" do
let(:user) { build(:user, :data_coordinator, organisation:) }
it "is valid regardless of the allow list" do
allow(FeatureToggle).to receive(:support_organisation_allow_list).and_return([999])
expect(user).to be_valid
end
end
context "when the user is a support user" do
let(:user) { build(:user, :support, organisation:) }
context "and the allow list is nil" do
before do
allow(FeatureToggle).to receive(:support_organisation_allow_list).and_return(nil)
end
it "is valid" do
expect(user).to be_valid
end
end
context "and the organisation is in the allow list" do
before do
allow(FeatureToggle).to receive(:support_organisation_allow_list).and_return([organisation.id])
end
it "is valid" do
expect(user).to be_valid
end
end
context "and the organisation is not in the allow list" do
before do
allow(FeatureToggle).to receive(:support_organisation_allow_list).and_return([organisation.id + 1])
end
it "is not valid" do
expect(user).not_to be_valid
end
end
end
end
end
describe "delete" do

12
spec/models/validations/household_validations_spec.rb

@ -252,18 +252,18 @@ RSpec.describe Validations::HouseholdValidations do
record.hhmemb = 0
household_validator.validate_numeric_min_max(record)
expect(record.errors["hhmemb"])
.to include(match I18n.t("validations.shared.numeric.within_range", field: "Number of household members", min: 1, max: 8))
.to include(match I18n.t("validations.shared.numeric.within_range", field: "Number of household members", min: 1, max: 15))
end
it "validates that the number of household members cannot be more than 8" do
record.hhmemb = 9
it "validates that the number of household members cannot be more than 15" do
record.hhmemb = 16
household_validator.validate_numeric_min_max(record)
expect(record.errors["hhmemb"])
.to include(match I18n.t("validations.shared.numeric.within_range", field: "Number of household members", min: 1, max: 8))
.to include(match I18n.t("validations.shared.numeric.within_range", field: "Number of household members", min: 1, max: 15))
end
it "expects that the number of other household members is between the min and max" do
record.hhmemb = 5
it "expects that the number of household members is between the min and max" do
record.hhmemb = 11
household_validator.validate_numeric_min_max(record)
expect(record.errors["hhmemb"]).to be_empty
end

69
spec/models/validations/sales/sale_information_validations_spec.rb

@ -252,12 +252,27 @@ RSpec.describe Validations::Sales::SaleInformationValidations do
end
context "when initial purchase date == saledate" do
let(:record) { build(:sales_log, initialpurchase: current_collection_start_date, saledate: current_collection_start_date) }
let(:record) { build(:sales_log, initialpurchase: collection_start_date_for_year(start_year), saledate: collection_start_date_for_year(start_year)) }
it "does not add an error" do
sale_information_validator.validate_staircasing_initial_purchase_date(record)
context "and 2025", metadata: { year: 25 } do
let(:start_year) { 2025 }
expect(record.errors[:initialpurchase]).not_to be_present
it "does not add an error" do
sale_information_validator.validate_staircasing_initial_purchase_date(record)
expect(record.errors[:lasttransaction]).not_to be_present
end
end
context "and 2026", metadata: { year: 26 } do
let(:start_year) { 2026 }
it "adds error" do
sale_information_validator.validate_staircasing_initial_purchase_date(record)
expect(record.errors[:initialpurchase]).to eq([I18n.t("validations.sales.sale_information.initialpurchase.must_be_before_saledate")])
expect(record.errors[:saledate]).to eq([I18n.t("validations.sales.sale_information.saledate.must_be_after_initial_purchase_date")])
end
end
end
end
@ -315,12 +330,27 @@ RSpec.describe Validations::Sales::SaleInformationValidations do
end
context "when last transaction date == saledate" do
let(:record) { build(:sales_log, lasttransaction: current_collection_start_date, saledate: current_collection_start_date) }
let(:record) { build(:sales_log, lasttransaction: collection_start_date_for_year(start_year), saledate: collection_start_date_for_year(start_year)) }
it "does not add an error" do
sale_information_validator.validate_staircasing_last_transaction_date(record)
context "and 2025", metadata: { year: 25 } do
let(:start_year) { 2025 }
expect(record.errors[:lasttransaction]).not_to be_present
it "does not add an error" do
sale_information_validator.validate_staircasing_last_transaction_date(record)
expect(record.errors[:lasttransaction]).not_to be_present
end
end
context "and 2026", metadata: { year: 26 } do
let(:start_year) { 2026 }
it "adds error" do
sale_information_validator.validate_staircasing_last_transaction_date(record)
expect(record.errors[:lasttransaction]).to eq([I18n.t("validations.sales.sale_information.lasttransaction.must_be_before_saledate")])
expect(record.errors[:saledate]).to eq([I18n.t("validations.sales.sale_information.saledate.must_be_after_last_transaction_date")])
end
end
end
@ -346,12 +376,27 @@ RSpec.describe Validations::Sales::SaleInformationValidations do
end
context "when last transaction date == initial purchase date" do
let(:record) { build(:sales_log, lasttransaction: current_collection_start_date, initialpurchase: current_collection_start_date) }
let(:record) { build(:sales_log, lasttransaction: collection_start_date_for_year(start_year), initialpurchase: collection_start_date_for_year(start_year), saledate: collection_start_date_for_year(start_year) + 1.day) }
it "does not add an error" do
sale_information_validator.validate_staircasing_last_transaction_date(record)
context "and 2025", metadata: { year: 25 } do
let(:start_year) { 2025 }
expect(record.errors[:lasttransaction]).not_to be_present
it "does not add an error" do
sale_information_validator.validate_staircasing_last_transaction_date(record)
expect(record.errors[:lasttransaction]).not_to be_present
end
end
context "and 2026", metadata: { year: 26 } do
let(:start_year) { 2026 }
it "adds error" do
sale_information_validator.validate_staircasing_last_transaction_date(record)
expect(record.errors[:lasttransaction]).to eq([I18n.t("validations.sales.sale_information.lasttransaction.must_be_after_initial_purchase")])
expect(record.errors[:initialpurchase]).to eq([I18n.t("validations.sales.sale_information.initialpurchase.must_be_before_last_transaction")])
end
end
end
end

36
spec/requests/bulk_upload_lettings_results_controller_spec.rb

@ -82,6 +82,24 @@ RSpec.describe BulkUploadLettingsResultsController, type: :request do
expect(response.body).to include("You moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end
end
context "and user has upload button shown" do
it "displays a link to reupload file" do
get "/lettings-logs/bulk-upload-results/#{bulk_upload.id}/summary"
expect(response.body).to include("Upload your file again")
expect(response.body).to include("/lettings-logs/bulk-upload-logs/start")
end
end
context "and user has upload button hidden" do
it "does not display a link to reupload file" do
get "/lettings-logs/bulk-upload-results/#{bulk_upload.id}/summary?hide_upload_button=true"
expect(response.body).not_to include("Upload your file again")
expect(response.body).not_to include("/lettings-logs/bulk-upload-logs/start")
end
end
end
end
@ -152,5 +170,23 @@ RSpec.describe BulkUploadLettingsResultsController, type: :request do
expect(response.body).to include("You moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end
end
context "and user has upload button shown" do
it "displays a link to reupload file" do
get "/lettings-logs/bulk-upload-results/#{bulk_upload.id}"
expect(response.body).to include("Upload your file again")
expect(response.body).to include("/lettings-logs/bulk-upload-logs/start")
end
end
context "and user has upload button hidden" do
it "does not display a link to reupload file" do
get "/lettings-logs/bulk-upload-results/#{bulk_upload.id}?hide_upload_button=true"
expect(response.body).not_to include("Upload your file again")
expect(response.body).not_to include("/lettings-logs/bulk-upload-logs/start")
end
end
end
end

36
spec/requests/bulk_upload_sales_results_controller_spec.rb

@ -44,6 +44,24 @@ RSpec.describe BulkUploadSalesResultsController, type: :request do
expect(response.body).to include("You moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end
end
context "and user has upload button shown" do
it "displays a link to reupload file" do
get "/sales-logs/bulk-upload-results/#{bulk_upload.id}/summary"
expect(response.body).to include("Upload your file again")
expect(response.body).to include("/sales-logs/bulk-upload-logs/start")
end
end
context "and user has upload button hidden" do
it "does not display a link to reupload file" do
get "/sales-logs/bulk-upload-results/#{bulk_upload.id}/summary?hide_upload_button=true"
expect(response.body).not_to include("Upload your file again")
expect(response.body).not_to include("/sales-logs/bulk-upload-logs/start")
end
end
end
end
@ -127,5 +145,23 @@ RSpec.describe BulkUploadSalesResultsController, type: :request do
expect(response.body).to include("You moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end
end
context "and user has upload button shown" do
it "displays a link to reupload file" do
get "/sales-logs/bulk-upload-results/#{bulk_upload.id}"
expect(response.body).to include("Upload your file again")
expect(response.body).to include("/sales-logs/bulk-upload-logs/start")
end
end
context "and user has upload button hidden" do
it "does not display a link to reupload file" do
get "/sales-logs/bulk-upload-results/#{bulk_upload.id}?hide_upload_button=true"
expect(response.body).not_to include("Upload your file again")
expect(response.body).not_to include("/sales-logs/bulk-upload-logs/start")
end
end
end
end

10
spec/services/bulk_upload/sales/year2025/row_parser_spec.rb

@ -295,7 +295,7 @@ RSpec.describe BulkUpload::Sales::Year2025::RowParser do
context "and case insensitive fields are set to lowercase" do
let(:case_insensitive_fields) { %w[field_29 field_36 field_44 field_48 field_52 field_56] }
let(:case_insensitive_integer_fields_with_r_option) { %w[field_28 field_35 field_43 field_47 field_51 field_55 field_64 field_75 field_70 field_72] }
let(:case_insensitive_integer_fields_with_r_option) { %w[field_28 field_35 field_43 field_47 field_51 field_55 field_58 field_64 field_75 field_70 field_72] }
let(:attributes) do
valid_attributes
.merge(case_insensitive_fields.each_with_object({}) { |field, h| h[field.to_sym] = valid_attributes[field.to_sym]&.downcase })
@ -1778,6 +1778,14 @@ RSpec.describe BulkUpload::Sales::Year2025::RowParser do
end
end
describe "#prevten" do
let(:attributes) { setup_section_params.merge({ field_58: "R" }) }
it "is correctly set" do
expect(parser.log.prevten).to be(0)
end
end
describe "#prevtenbuy2" do
let(:attributes) { setup_section_params.merge({ field_64: "R" }) }

10
spec/services/bulk_upload/sales/year2026/row_parser_spec.rb

@ -301,7 +301,7 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
context "and case insensitive fields are set to lowercase" do
let(:case_insensitive_fields) { %w[field_30 field_39 field_49 field_55 field_61 field_67] }
let(:case_insensitive_integer_fields_with_r_option) { %w[field_29 field_38 field_48 field_54 field_60 field_66 field_77 field_88 field_83 field_85 field_103 field_107 field_125 field_126 field_133 field_136] }
let(:case_insensitive_integer_fields_with_r_option) { %w[field_29 field_38 field_48 field_54 field_60 field_66 field_71 field_77 field_88 field_83 field_85 field_103 field_107 field_125 field_126 field_133 field_136] }
let(:attributes) do
valid_attributes
.merge(case_insensitive_fields.each_with_object({}) { |field, h| h[field.to_sym] = valid_attributes[field.to_sym]&.downcase })
@ -1840,6 +1840,14 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
end
end
describe "#prevten" do
let(:attributes) { setup_section_params.merge({ field_71: "R" }) }
it "is correctly set" do
expect(parser.log.prevten).to be(0)
end
end
describe "#prevtenbuy2" do
let(:attributes) { setup_section_params.merge({ field_77: "R" }) }

Loading…
Cancel
Save