Browse Source

Merge branch 'main' into CLDC-4169-remove-vrtb-discounted-ownership-type-option

pull/3202/head
Nat Dean-Lewis 3 months ago
parent
commit
8094acb004
  1. 19
      app/helpers/bulk_upload/sales_log_to_csv.rb
  2. 17
      app/models/form/sales/pages/building_height_class.rb
  3. 4
      app/models/form/sales/pages/service_charge.rb
  4. 13
      app/models/form/sales/pages/service_charge_staircasing.rb
  5. 17
      app/models/form/sales/questions/building_height_class.rb
  6. 15
      app/models/form/sales/questions/has_service_charge.rb
  7. 16
      app/models/form/sales/questions/service_charge.rb
  8. 106
      app/models/form/sales/subsections/household_characteristics.rb
  9. 1
      app/models/form/sales/subsections/property_information.rb
  10. 1
      app/models/form/sales/subsections/shared_ownership_staircasing_transaction.rb
  11. 2
      app/models/validations/financial_validations.rb
  12. 4
      app/models/validations/shared_validations.rb
  13. 29
      app/services/bulk_upload/lettings/year2025/row_parser.rb
  14. 29
      app/services/bulk_upload/lettings/year2026/row_parser.rb
  15. 31
      app/services/bulk_upload/sales/year2025/row_parser.rb
  16. 4
      app/services/bulk_upload/sales/year2026/csv_parser.rb
  17. 115
      app/services/bulk_upload/sales/year2026/row_parser.rb
  18. 11
      app/services/csv/sales_log_csv_service.rb
  19. 15
      app/services/exports/sales_log_export_constants.rb
  20. 4
      app/services/exports/sales_log_export_service.rb
  21. 2
      app/views/form/page.html.erb
  22. 56
      config/locales/forms/2026/lettings/household_characteristics.en.yml
  23. 49
      config/locales/forms/2026/sales/household_characteristics.en.yml
  24. 7
      config/locales/forms/2026/sales/property_information.en.yml
  25. 5
      db/migrate/20260219093257_add_buildheightclass_to_sales_logs.rb
  26. 25
      db/migrate/20260225135309_add_composite_indexes_for_logs_organisation_lookup.rb
  27. 13
      db/schema.rb
  28. 8
      spec/factories/sales_log.rb
  29. 154
      spec/fixtures/exports/sales_log_25_26.xml
  30. 155
      spec/fixtures/exports/sales_log_26_27.xml
  31. 24
      spec/fixtures/files/2026_27_sales_bulk_upload.csv
  32. 3
      spec/fixtures/files/sales_logs_csv_export_codes_23.csv
  33. 2
      spec/fixtures/files/sales_logs_csv_export_codes_24.csv
  34. 2
      spec/fixtures/files/sales_logs_csv_export_codes_25.csv
  35. 3
      spec/fixtures/files/sales_logs_csv_export_codes_26.csv
  36. 3
      spec/fixtures/files/sales_logs_csv_export_labels_23.csv
  37. 2
      spec/fixtures/files/sales_logs_csv_export_labels_24.csv
  38. 2
      spec/fixtures/files/sales_logs_csv_export_labels_25.csv
  39. 3
      spec/fixtures/files/sales_logs_csv_export_labels_26.csv
  40. 3
      spec/fixtures/files/sales_logs_csv_export_non_support_codes_26.csv
  41. 2
      spec/fixtures/files/sales_logs_csv_export_non_support_labels_24.csv
  42. 2
      spec/fixtures/files/sales_logs_csv_export_non_support_labels_25.csv
  43. 3
      spec/fixtures/files/sales_logs_csv_export_non_support_labels_26.csv
  44. 1
      spec/fixtures/variable_definitions/sales_download_26_27.csv
  45. 2
      spec/lib/tasks/log_variable_definitions_spec.rb
  46. 36
      spec/models/form/sales/pages/building_height_class_spec.rb
  47. 29
      spec/models/form/sales/pages/service_charge_staircasing_spec.rb
  48. 39
      spec/models/form/sales/questions/building_height_class_spec.rb
  49. 48
      spec/models/form/sales/questions/has_service_charge_spec.rb
  50. 47
      spec/models/form/sales/questions/service_charge_spec.rb
  51. 7
      spec/models/form/sales/subsections/household_characteristics_spec.rb
  52. 53
      spec/models/form/sales/subsections/property_information_spec.rb
  53. 85
      spec/models/form/sales/subsections/shared_ownership_staircasing_transaction_spec.rb
  54. 14
      spec/services/bulk_upload/lettings/year2025/row_parser_spec.rb
  55. 14
      spec/services/bulk_upload/lettings/year2026/row_parser_spec.rb
  56. 14
      spec/services/bulk_upload/sales/year2025/row_parser_spec.rb
  57. 27
      spec/services/bulk_upload/sales/year2026/row_parser_spec.rb
  58. 129
      spec/services/csv/sales_log_csv_service_spec.rb
  59. 66
      spec/services/exports/sales_log_export_service_spec.rb
  60. 6
      yarn.lock

19
app/helpers/bulk_upload/sales_log_to_csv.rb

@ -565,14 +565,14 @@ class BulkUpload::SalesLogToCsv
log.wchair,
log.age1,
log.sex1,
log.sexrab1,
log.ethnic, # 30
log.nationality_all_group,
log.ecstat1,
log.buy1livein,
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat2],
log.age2,
log.sex2,
log.sexrab2,
log.ethnic_group2,
log.nationality_all_buyer2_group,
log.ecstat2,
@ -581,19 +581,19 @@ class BulkUpload::SalesLogToCsv
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat3],
log.age3,
log.sex3,
log.sexrab3,
log.ecstat3,
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat4],
log.age4,
log.sex4,
log.sexrab4,
log.ecstat4,
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat5], # 50
log.age5,
log.sex5,
log.sexrab5,
log.ecstat5,
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat6],
log.age6,
log.sex6,
log.sexrab6,
log.ecstat6,
log.prevten,
@ -665,12 +665,7 @@ class BulkUpload::SalesLogToCsv
log.extrabor,
log.deposit, # 120
log.mscharge,
log.sexrab1,
log.sexrab2,
log.sexrab3,
log.sexrab4,
log.sexrab5,
log.sexrab6, # 127
log.buildheightclass, # 122
]
end

17
app/models/form/sales/pages/building_height_class.rb

@ -0,0 +1,17 @@
class Form::Sales::Pages::BuildingHeightClass < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "building_height_class"
@depends_on = [
{ "proptype" => 1 },
{ "proptype" => 2 },
{ "proptype" => 9 },
]
end
def questions
@questions ||= [
Form::Sales::Questions::BuildingHeightClass.new(nil, nil, self),
]
end
end

4
app/models/form/sales/pages/service_charge.rb

@ -6,8 +6,8 @@ class Form::Sales::Pages::ServiceCharge < ::Form::Page
def questions
@questions ||= [
Form::Sales::Questions::HasServiceCharge.new(nil, nil, self),
Form::Sales::Questions::ServiceCharge.new(nil, nil, self),
Form::Sales::Questions::HasServiceCharge.new(nil, nil, self, staircasing: false),
Form::Sales::Questions::ServiceCharge.new(nil, nil, self, staircasing: false),
]
end
end

13
app/models/form/sales/pages/service_charge_staircasing.rb

@ -0,0 +1,13 @@
class Form::Sales::Pages::ServiceChargeStaircasing < ::Form::Page
def initialize(id, hsh, subsection)
super
@copy_key = "sales.sale_information.servicecharges"
end
def questions
@questions ||= [
Form::Sales::Questions::HasServiceCharge.new(nil, nil, self, staircasing: true),
Form::Sales::Questions::ServiceCharge.new(nil, nil, self, staircasing: true),
]
end
end

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

@ -0,0 +1,17 @@
class Form::Sales::Questions::BuildingHeightClass < ::Form::Question
def initialize(id, hsh, page)
super
@id = "buildheightclass"
@type = "radio"
@answer_options = ANSWER_OPTIONS
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
end
ANSWER_OPTIONS = {
"1" => { "value" => "High-rise" },
"2" => { "value" => "Low-rise" },
"3" => { "value" => "Don't know" },
}.freeze
QUESTION_NUMBER_FROM_YEAR = { 2026 => 17 }.freeze
end

15
app/models/form/sales/questions/has_service_charge.rb

@ -1,6 +1,6 @@
class Form::Sales::Questions::HasServiceCharge < ::Form::Question
def initialize(id, hsh, subsection)
super
def initialize(id, hsh, subsection, staircasing:)
super(id, hsh, subsection)
@id = "has_mscharge"
@type = "radio"
@answer_options = ANSWER_OPTIONS
@ -15,7 +15,8 @@ class Form::Sales::Questions::HasServiceCharge < ::Form::Question
],
}
@copy_key = "sales.sale_information.servicecharges.has_servicecharge"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@staircasing = staircasing
@question_number = question_number_from_year[form.start_date.year] || question_number_from_year[question_number_from_year.keys.max]
end
ANSWER_OPTIONS = {
@ -23,5 +24,11 @@ class Form::Sales::Questions::HasServiceCharge < ::Form::Question
"0" => { "value" => "No" },
}.freeze
QUESTION_NUMBER_FROM_YEAR = { 2025 => 88 }.freeze
def question_number_from_year
if @staircasing
{ 2026 => 0 }.freeze
else
{ 2025 => 88, 2026 => 0 }.freeze
end
end
end

16
app/models/form/sales/questions/service_charge.rb

@ -1,16 +1,24 @@
class Form::Sales::Questions::ServiceCharge < ::Form::Question
def initialize(id, hsh, subsection)
super
def initialize(id, hsh, subsection, staircasing:)
super(id, hsh, subsection)
@id = "mscharge"
@type = "numeric"
@min = 1
@max = 9999.99
@step = 0.01
@width = 5
@prefix = "£"
@copy_key = "sales.sale_information.servicecharges.servicecharge"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@staircasing = staircasing
@question_number = question_number_from_year[form.start_date.year] || question_number_from_year[question_number_from_year.keys.max]
@strip_commas = true
end
QUESTION_NUMBER_FROM_YEAR = { 2025 => 88 }.freeze
def question_number_from_year
if @staircasing
{ 2026 => 0 }.freeze
else
{ 2025 => 88, 2026 => 0 }.freeze
end
end
end

106
app/models/form/sales/subsections/household_characteristics.rb

@ -25,7 +25,7 @@ class Form::Sales::Subsections::HouseholdCharacteristics < ::Form::Subsection
Form::Sales::Pages::OldPersonsSharedOwnershipValueCheck.new("age_1_old_persons_shared_ownership_joint_purchase_value_check", nil, self, joint_purchase: true),
Form::Sales::Pages::OldPersonsSharedOwnershipValueCheck.new("age_1_old_persons_shared_ownership_value_check", nil, self, joint_purchase: false),
(Form::Sales::Pages::SexRegisteredAtBirth1.new(nil, nil, self) if form.start_year_2026_or_later?),
Form::Sales::Pages::GenderIdentity1.new(nil, nil, self),
(Form::Sales::Pages::GenderIdentity1.new(nil, nil, self) unless form.start_year_2026_or_later?),
Form::Sales::Pages::Buyer1EthnicGroup.new(nil, nil, self),
Form::Sales::Pages::Buyer1EthnicBackgroundBlack.new(nil, nil, self),
Form::Sales::Pages::Buyer1EthnicBackgroundAsian.new(nil, nil, self),
@ -48,7 +48,7 @@ class Form::Sales::Subsections::HouseholdCharacteristics < ::Form::Subsection
(Form::Sales::Pages::NotRetiredValueCheck.new("age_2_buyer_not_retired_value_check", nil, self, person_index: 2) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("buyer_2_age_student_not_child_value_check", nil, self, person_index: 2) unless form.start_year_2025_or_later?),
(Form::Sales::Pages::SexRegisteredAtBirth2.new(nil, nil, self) if form.start_year_2026_or_later?),
Form::Sales::Pages::GenderIdentity2.new(nil, nil, self),
(Form::Sales::Pages::GenderIdentity2.new(nil, nil, self) unless form.start_year_2026_or_later?),
buyer_2_ethnicity_nationality_pages,
Form::Sales::Pages::Buyer2WorkingSituation.new(nil, nil, self),
Form::Sales::Pages::RetirementValueCheck.new("working_situation_2_retirement_value_check_joint_purchase", nil, self, person_index: 2),
@ -59,89 +59,31 @@ class Form::Sales::Subsections::HouseholdCharacteristics < ::Form::Subsection
Form::Sales::Pages::BuyerLiveInValueCheck.new("buyer_2_live_in_property_value_check", nil, self, person_index: 2),
Form::Sales::Pages::NumberOfOthersInProperty.new("number_of_others_in_property", nil, self, joint_purchase: false),
Form::Sales::Pages::NumberOfOthersInProperty.new("number_of_others_in_property_joint_purchase", nil, self, joint_purchase: true),
Form::Sales::Pages::PersonKnown.new("person_2_known", nil, self, person_index: 2),
(form.start_year_2025_or_later? ? Form::Sales::Pages::PersonRelationshipToBuyer1YesNo.new("person_2_relationship_to_buyer_1", nil, self, person_index: 2) : Form::Sales::Pages::PersonRelationshipToBuyer1.new("person_2_relationship_to_buyer_1", nil, self, person_index: 2)),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("relationship_2_partner_under_16_value_check", nil, self, person_index: 2) if form.start_year_2024_or_later?),
(Form::Sales::Pages::MultiplePartnersValueCheck.new("relationship_2_multiple_partners_value_check", nil, self, person_index: 2) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("relationship_2_student_not_child_value_check", nil, self, person_index: 2) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonAge.new("person_2_age", nil, self, person_index: 2),
Form::Sales::Pages::RetirementValueCheck.new("age_2_retirement_value_check", nil, self, person_index: 2),
(Form::Sales::Pages::NotRetiredValueCheck.new("age_2_not_retired_value_check", nil, self, person_index: 2) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("age_2_student_not_child_value_check", nil, self, person_index: 2) unless form.start_year_2025_or_later?),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("age_2_partner_under_16_value_check", nil, self, person_index: 2) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonSexRegisteredAtBirth.new("person_2_sex_registered_at_birth", nil, self, person_index: 2) if form.start_year_2026_or_later?),
Form::Sales::Pages::PersonGenderIdentity.new("person_2_gender_identity", nil, self, person_index: 2),
Form::Sales::Pages::PersonWorkingSituation.new("person_2_working_situation", nil, self, person_index: 2),
Form::Sales::Pages::RetirementValueCheck.new("working_situation_2_retirement_value_check", nil, self, person_index: 2),
(Form::Sales::Pages::NotRetiredValueCheck.new("working_situation_2_not_retired_value_check", nil, self, person_index: 2) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("working_situation_2_student_not_child_value_check", nil, self, person_index: 2) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonKnown.new("person_3_known", nil, self, person_index: 3),
(form.start_year_2025_or_later? ? Form::Sales::Pages::PersonRelationshipToBuyer1YesNo.new("person_3_relationship_to_buyer_1", nil, self, person_index: 3) : Form::Sales::Pages::PersonRelationshipToBuyer1.new("person_3_relationship_to_buyer_1", nil, self, person_index: 3)),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("relationship_3_partner_under_16_value_check", nil, self, person_index: 3) if form.start_year_2024_or_later?),
(Form::Sales::Pages::MultiplePartnersValueCheck.new("relationship_3_multiple_partners_value_check", nil, self, person_index: 3) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("relationship_3_student_not_child_value_check", nil, self, person_index: 3) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonAge.new("person_3_age", nil, self, person_index: 3),
Form::Sales::Pages::RetirementValueCheck.new("age_3_retirement_value_check", nil, self, person_index: 3),
(Form::Sales::Pages::NotRetiredValueCheck.new("age_3_not_retired_value_check", nil, self, person_index: 3) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("age_3_student_not_child_value_check", nil, self, person_index: 3) unless form.start_year_2025_or_later?),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("age_3_partner_under_16_value_check", nil, self, person_index: 3) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonSexRegisteredAtBirth.new("person_3_sex_registered_at_birth", nil, self, person_index: 3) if form.start_year_2026_or_later?),
Form::Sales::Pages::PersonGenderIdentity.new("person_3_gender_identity", nil, self, person_index: 3),
Form::Sales::Pages::PersonWorkingSituation.new("person_3_working_situation", nil, self, person_index: 3),
Form::Sales::Pages::RetirementValueCheck.new("working_situation_3_retirement_value_check", nil, self, person_index: 3),
(Form::Sales::Pages::NotRetiredValueCheck.new("working_situation_3_not_retired_value_check", nil, self, person_index: 3) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("working_situation_3_student_not_child_value_check", nil, self, person_index: 3) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonKnown.new("person_4_known", nil, self, person_index: 4),
(form.start_year_2025_or_later? ? Form::Sales::Pages::PersonRelationshipToBuyer1YesNo.new("person_4_relationship_to_buyer_1", nil, self, person_index: 4) : Form::Sales::Pages::PersonRelationshipToBuyer1.new("person_4_relationship_to_buyer_1", nil, self, person_index: 4)),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("relationship_4_partner_under_16_value_check", nil, self, person_index: 4) if form.start_year_2024_or_later?),
(Form::Sales::Pages::MultiplePartnersValueCheck.new("relationship_4_multiple_partners_value_check", nil, self, person_index: 4) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("relationship_4_student_not_child_value_check", nil, self, person_index: 4) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonAge.new("person_4_age", nil, self, person_index: 4),
Form::Sales::Pages::RetirementValueCheck.new("age_4_retirement_value_check", nil, self, person_index: 4),
(Form::Sales::Pages::NotRetiredValueCheck.new("age_4_not_retired_value_check", nil, self, person_index: 4) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("age_4_student_not_child_value_check", nil, self, person_index: 4) unless form.start_year_2025_or_later?),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("age_4_partner_under_16_value_check", nil, self, person_index: 4) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonSexRegisteredAtBirth.new("person_4_sex_registered_at_birth", nil, self, person_index: 4) if form.start_year_2026_or_later?),
Form::Sales::Pages::PersonGenderIdentity.new("person_4_gender_identity", nil, self, person_index: 4),
Form::Sales::Pages::PersonWorkingSituation.new("person_4_working_situation", nil, self, person_index: 4),
Form::Sales::Pages::RetirementValueCheck.new("working_situation_4_retirement_value_check", nil, self, person_index: 4),
(Form::Sales::Pages::NotRetiredValueCheck.new("working_situation_4_not_retired_value_check", nil, self, person_index: 4) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("working_situation_4_student_not_child_value_check", nil, self, person_index: 4) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonKnown.new("person_5_known", nil, self, person_index: 5),
(form.start_year_2025_or_later? ? Form::Sales::Pages::PersonRelationshipToBuyer1YesNo.new("person_5_relationship_to_buyer_1", nil, self, person_index: 5) : Form::Sales::Pages::PersonRelationshipToBuyer1.new("person_5_relationship_to_buyer_1", nil, self, person_index: 5)),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("relationship_5_partner_under_16_value_check", nil, self, person_index: 5) if form.start_year_2024_or_later?),
(Form::Sales::Pages::MultiplePartnersValueCheck.new("relationship_5_multiple_partners_value_check", nil, self, person_index: 5) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("relationship_5_student_not_child_value_check", nil, self, person_index: 5) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonAge.new("person_5_age", nil, self, person_index: 5),
Form::Sales::Pages::RetirementValueCheck.new("age_5_retirement_value_check", nil, self, person_index: 5),
(Form::Sales::Pages::NotRetiredValueCheck.new("age_5_not_retired_value_check", nil, self, person_index: 5) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("age_5_student_not_child_value_check", nil, self, person_index: 5) unless form.start_year_2025_or_later?),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("age_5_partner_under_16_value_check", nil, self, person_index: 5) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonSexRegisteredAtBirth.new("person_5_sex_registered_at_birth", nil, self, person_index: 5) if form.start_year_2026_or_later?),
Form::Sales::Pages::PersonGenderIdentity.new("person_5_gender_identity", nil, self, person_index: 5),
Form::Sales::Pages::PersonWorkingSituation.new("person_5_working_situation", nil, self, person_index: 5),
Form::Sales::Pages::RetirementValueCheck.new("working_situation_5_retirement_value_check", nil, self, person_index: 5),
(Form::Sales::Pages::NotRetiredValueCheck.new("working_situation_5_not_retired_value_check", nil, self, person_index: 5) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("working_situation_5_student_not_child_value_check", nil, self, person_index: 5) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonKnown.new("person_6_known", nil, self, person_index: 6),
(form.start_year_2025_or_later? ? Form::Sales::Pages::PersonRelationshipToBuyer1YesNo.new("person_6_relationship_to_buyer_1", nil, self, person_index: 6) : Form::Sales::Pages::PersonRelationshipToBuyer1.new("person_6_relationship_to_buyer_1", nil, self, person_index: 6)),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("relationship_6_partner_under_16_value_check", nil, self, person_index: 6) if form.start_year_2024_or_later?),
(Form::Sales::Pages::MultiplePartnersValueCheck.new("relationship_6_multiple_partners_value_check", nil, self, person_index: 6) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("relationship_6_student_not_child_value_check", nil, self, person_index: 6) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonAge.new("person_6_age", nil, self, person_index: 6),
Form::Sales::Pages::RetirementValueCheck.new("age_6_retirement_value_check", nil, self, person_index: 6),
(Form::Sales::Pages::NotRetiredValueCheck.new("age_6_not_retired_value_check", nil, self, person_index: 6) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("age_6_student_not_child_value_check", nil, self, person_index: 6) unless form.start_year_2025_or_later?),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("age_6_partner_under_16_value_check", nil, self, person_index: 6) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonSexRegisteredAtBirth.new("person_6_sex_registered_at_birth", nil, self, person_index: 6) if form.start_year_2026_or_later?),
Form::Sales::Pages::PersonGenderIdentity.new("person_6_gender_identity", nil, self, person_index: 6),
Form::Sales::Pages::PersonWorkingSituation.new("person_6_working_situation", nil, self, person_index: 6),
Form::Sales::Pages::RetirementValueCheck.new("working_situation_6_retirement_value_check", nil, self, person_index: 6),
(Form::Sales::Pages::NotRetiredValueCheck.new("working_situation_6_not_retired_value_check", nil, self, person_index: 6) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("working_situation_6_student_not_child_value_check", nil, self, person_index: 6) unless form.start_year_2025_or_later?),
(2..6).flat_map { |index| person_pages(index) },
].flatten.compact
end
def person_pages(person_index)
[
Form::Sales::Pages::PersonKnown.new("person_#{person_index}_known", nil, self, person_index:),
(form.start_year_2025_or_later? ? Form::Sales::Pages::PersonRelationshipToBuyer1YesNo.new("person_#{person_index}_relationship_to_buyer_1", nil, self, person_index:) : Form::Sales::Pages::PersonRelationshipToBuyer1.new("person_#{person_index}_relationship_to_buyer_1", nil, self, person_index:)),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("relationship_#{person_index}_partner_under_16_value_check", nil, self, person_index:) if form.start_year_2024_or_later?),
(Form::Sales::Pages::MultiplePartnersValueCheck.new("relationship_#{person_index}_multiple_partners_value_check", nil, self, person_index:) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("relationship_#{person_index}_student_not_child_value_check", nil, self, person_index:) unless form.start_year_2025_or_later?),
Form::Sales::Pages::PersonAge.new("person_#{person_index}_age", nil, self, person_index:),
Form::Sales::Pages::RetirementValueCheck.new("age_#{person_index}_retirement_value_check", nil, self, person_index:),
(Form::Sales::Pages::NotRetiredValueCheck.new("age_#{person_index}_not_retired_value_check", nil, self, person_index:) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("age_#{person_index}_student_not_child_value_check", nil, self, person_index:) unless form.start_year_2025_or_later?),
(Form::Sales::Pages::PartnerUnder16ValueCheck.new("age_#{person_index}_partner_under_16_value_check", nil, self, person_index:) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonGenderIdentity.new("person_#{person_index}_gender_identity", nil, self, person_index:) unless form.start_year_2026_or_later?),
(Form::Sales::Pages::PersonSexRegisteredAtBirth.new("person_#{person_index}_sex_registered_at_birth", nil, self, person_index:) if form.start_year_2026_or_later?),
Form::Sales::Pages::PersonWorkingSituation.new("person_#{person_index}_working_situation", nil, self, person_index:),
Form::Sales::Pages::RetirementValueCheck.new("working_situation_#{person_index}_retirement_value_check", nil, self, person_index:),
(Form::Sales::Pages::NotRetiredValueCheck.new("working_situation_#{person_index}_not_retired_value_check", nil, self, person_index:) if form.start_year_2024_or_later?),
(Form::Sales::Pages::PersonStudentNotChildValueCheck.new("working_situation_#{person_index}_student_not_child_value_check", nil, self, person_index:) unless form.start_year_2025_or_later?),
]
end
def buyer_2_ethnicity_nationality_pages
if form.start_date.year >= 2023
[

1
app/models/form/sales/subsections/property_information.rb

@ -10,6 +10,7 @@ class Form::Sales::Subsections::PropertyInformation < ::Form::Subsection
@pages ||= [
(uprn_questions if form.start_date.year >= 2024),
(Form::Sales::Pages::PropertyUnitType.new(nil, nil, self) if form.start_year_2025_or_later?),
(Form::Sales::Pages::BuildingHeightClass.new(nil, nil, self) if form.start_year_2026_or_later?),
Form::Sales::Pages::PropertyNumberOfBedrooms.new(nil, nil, self),
Form::Sales::Pages::AboutPriceValueCheck.new("about_price_bedrooms_value_check", nil, self),
(Form::Sales::Pages::PropertyUnitType.new(nil, nil, self) unless form.start_year_2025_or_later?),

1
app/models/form/sales/subsections/shared_ownership_staircasing_transaction.rb

@ -25,6 +25,7 @@ class Form::Sales::Subsections::SharedOwnershipStaircasingTransaction < ::Form::
Form::Sales::Pages::Mortgageused.new("staircase_mortgage_used_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::MonthlyRentStaircasingOwned.new(nil, nil, self),
Form::Sales::Pages::MonthlyRentStaircasing.new(nil, nil, self),
(Form::Sales::Pages::ServiceChargeStaircasing.new("service_charge_staircasing", nil, self) if form.start_year_2026_or_later?),
Form::Sales::Pages::MonthlyChargesValueCheck.new("monthly_charges_shared_ownership_value_check", nil, self),
].compact
end

2
app/models/validations/financial_validations.rb

@ -228,7 +228,7 @@ private
end
if record.weekly_value(record["brent"]) > rent_range.hard_max
record.errors.add :brent, :over_hard_max, message: I18n.t("validations.lettings.financial.brent.above_hard_max")
record.errors.add :brent, :above_hard_max, message: I18n.t("validations.lettings.financial.brent.above_hard_max")
record.errors.add :beds, I18n.t("validations.lettings.financial.beds.rent_above_hard_max")
record.errors.add :uprn, I18n.t("validations.lettings.financial.uprn.rent_above_hard_max")
record.errors.add :la, I18n.t("validations.lettings.financial.la.rent_above_hard_max")

4
app/models/validations/shared_validations.rb

@ -105,8 +105,8 @@ private
def add_range_error(record, question)
field = question.check_answer_label || question.id
min = [question.prefix, number_with_delimiter(question.min, delimiter: ","), question.suffix].join("") if question.min
max = [question.prefix, number_with_delimiter(question.max, delimiter: ","), question.suffix].join("") if question.max
min = [question.prefix, number_with_delimiter(question.min, delimiter: ","), question.suffix_label(record)].join("") if question.min
max = [question.prefix, number_with_delimiter(question.max, delimiter: ","), question.suffix_label(record)].join("") if question.max
if min && max
record.errors.add question.id.to_sym, :outside_the_range, message: I18n.t("validations.shared.numeric.within_range", field:, min:, max:)

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

@ -148,6 +148,26 @@ class BulkUpload::Lettings::Year2025::RowParser
ERROR_BASE_KEY = "validations.lettings.2025.bulk_upload".freeze
CASE_INSENSITIVE_FIELDS = [
:field_42, # What is the lead tenant’s age?
:field_48, # What is person 2’s age?
:field_52, # What is person 3’s age?
:field_56, # What is person 4’s age?
:field_60, # What is person 5’s age?
:field_64, # What is person 6’s age?
:field_68, # What is person 7’s age?
:field_72, # What is person 8’s age?
:field_43, # Which of these best describes the lead tenant’s gender identity?
:field_49, # Which of these best describes person 2’s gender identity?
:field_53, # Which of these best describes person 3’s gender identity?
:field_57, # Which of these best describes person 4’s gender identity?
:field_61, # Which of these best describes person 5’s gender identity?
:field_65, # Which of these best describes person 6’s gender identity?
:field_69, # Which of these best describes person 7’s gender identity?
:field_73, # Which of these best describes person 8’s gender identity?
].freeze
attribute :bulk_upload
attribute :block_log_creation, :boolean, default: -> { false }
@ -459,6 +479,8 @@ class BulkUpload::Lettings::Year2025::RowParser
return @valid = true if blank_row?
normalise_case_insensitive_fields
super(:before_log)
@before_errors = errors.dup
@ -560,6 +582,13 @@ class BulkUpload::Lettings::Year2025::RowParser
private
def normalise_case_insensitive_fields
CASE_INSENSITIVE_FIELDS.each do |field|
value = send(field)
send("#{field}=", value.upcase) if value.present?
end
end
def validate_valid_radio_option
log.attributes.each_key do |question_id|
question = log.form.get_question(question_id, log)

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

@ -166,6 +166,26 @@ class BulkUpload::Lettings::Year2026::RowParser
ERROR_BASE_KEY = "validations.lettings.2026.bulk_upload".freeze
CASE_INSENSITIVE_FIELDS = [
:field_41, # What is the lead tenant's age?
:field_48, # What is person 2's age?
:field_54, # What is person 3's age?
:field_60, # What is person 4's age?
:field_66, # What is person 5's age?
:field_72, # What is person 6's age?
:field_78, # What is person 7's age?
:field_84, # What is person 8's age?
:field_42, # What is the lead tenant's sex?
:field_50, # What is person 2's sex?
:field_56, # What is person 3's sex?
:field_62, # What is person 4's sex?
:field_68, # What is person 5's sex?
:field_74, # What is person 6's sex?
:field_80, # What is person 7's sex?
:field_86, # What is person 8's sex?
].freeze
attribute :bulk_upload
attribute :block_log_creation, :boolean, default: -> { false }
@ -494,6 +514,8 @@ class BulkUpload::Lettings::Year2026::RowParser
return @valid = true if blank_row?
normalise_case_insensitive_fields
super(:before_log)
@before_errors = errors.dup
@ -600,6 +622,13 @@ class BulkUpload::Lettings::Year2026::RowParser
private
def normalise_case_insensitive_fields
CASE_INSENSITIVE_FIELDS.each do |field|
value = send(field)
send("#{field}=", value.upcase) if value.present?
end
end
def validate_valid_radio_option
log.attributes.each_key do |question_id|
question = log.form.get_question(question_id, log)

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

@ -139,6 +139,28 @@ class BulkUpload::Sales::Year2025::RowParser
ERROR_BASE_KEY = "validations.sales.2025.bulk_upload".freeze
CASE_INSENSITIVE_FIELDS = [
:field_28, # Age of buyer 1
:field_35, # Age of person 2
:field_43, # Age of person 3
:field_47, # Age of person 4
:field_51, # Age of person 5
:field_55, # Age of person 6
:field_29, # Gender identity of buyer 1
:field_36, # Gender identity of person 2
:field_44, # Gender identity of person 3
:field_48, # Gender identity of person 4
:field_52, # Gender identity of person 5
:field_56, # Gender identity of person 6
: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?
:field_70, # What is buyer 1’s gross annual income?
:field_72, # What is buyer 2’s gross annual income?
].freeze
attribute :bulk_upload
attribute :block_log_creation, :boolean, default: -> { false }
@ -454,6 +476,8 @@ class BulkUpload::Sales::Year2025::RowParser
return true if blank_row?
normalise_case_insensitive_fields
super(:before_log)
@before_errors = errors.dup
@ -525,6 +549,13 @@ class BulkUpload::Sales::Year2025::RowParser
private
def normalise_case_insensitive_fields
CASE_INSENSITIVE_FIELDS.each do |field|
value = send(field)
send("#{field}=", value.upcase) if value.present?
end
end
def prevtenbuy2
case field_64
when "R"

4
app/services/bulk_upload/sales/year2026/csv_parser.rb

@ -4,7 +4,7 @@ class BulkUpload::Sales::Year2026::CsvParser
include CollectionTimeHelper
# TODO: CLDC-4162: Update when 2026 format is known
FIELDS = 127
FIELDS = 122
FORM_YEAR = 2026
attr_reader :path
@ -27,7 +27,7 @@ class BulkUpload::Sales::Year2026::CsvParser
def cols
# TODO: CLDC-4162: Update when 2026 format is known
@cols ||= ("A".."DR").to_a
@cols ||= ("A".."DP").to_a
end
def row_parsers

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

@ -35,14 +35,14 @@ class BulkUpload::Sales::Year2026::RowParser
field_27: "Is the property built or adapted to wheelchair user standards?",
field_28: "Age of buyer 1",
field_29: "Gender identity of buyer 1",
field_29: "Buyer 1's sex, as registered at birth",
field_30: "What is buyer 1’s ethnic group?",
field_31: "What is buyer 1’s nationality?",
field_32: "Working situation of buyer 1",
field_33: "Will buyer 1 live in the property?",
field_34: "Is buyer 2 or person 2 the partner of buyer 1?",
field_35: "Age of person 2",
field_36: "Gender identity of person 2",
field_36: "Buyer/Person 2's sex, as registered at birth",
field_37: "Which of the following best describes buyer 2’s ethnic background?",
field_38: "What is buyer 2’s nationality?",
field_39: "What is buyer 2 or person 2’s working situation?",
@ -51,19 +51,19 @@ class BulkUpload::Sales::Year2026::RowParser
field_42: "Is person 3 the partner of buyer 1?",
field_43: "Age of person 3",
field_44: "Gender identity of person 3",
field_44: "Person 3's sex, as registered at birth",
field_45: "Working situation of person 3",
field_46: "Is person 4 the partner of buyer 1?",
field_47: "Age of person 4",
field_48: "Gender identity of person 4",
field_48: "Person 4's sex, as registered at birth",
field_49: "Working situation of person 4",
field_50: "Is person 5 the partner of buyer 1?",
field_51: "Age of person 5",
field_52: "Gender identity of person 5",
field_52: "Person 5's sex, as registered at birth",
field_53: "Working situation of person 5",
field_54: "Is person 6 the partner of buyer 1?",
field_55: "Age of person 6",
field_56: "Gender identity of person 6",
field_56: "Person 6's sex, as registered at birth",
field_57: "Working situation of person 6",
field_58: "What was buyer 1’s previous tenure?",
@ -136,16 +136,33 @@ class BulkUpload::Sales::Year2026::RowParser
field_120: "How much was the cash deposit paid on the property?",
field_121: "What are the total monthly leasehold charges for the property?",
field_122: "Buyer 1's sex, as registered at birth",
field_123: "Buyer/Person 2's sex, as registered at birth",
field_124: "Person 3's sex, as registered at birth",
field_125: "Person 4's sex, as registered at birth",
field_126: "Person 5's sex, as registered at birth",
field_127: "Person 6's sex, as registered at birth",
field_122: "What is the building height classification?",
}.freeze
ERROR_BASE_KEY = "validations.sales.2026.bulk_upload".freeze
CASE_INSENSITIVE_FIELDS = [
:field_28, # Age of buyer 1
:field_35, # Age of person 2
:field_43, # Age of person 3
:field_47, # Age of person 4
:field_51, # Age of person 5
:field_55, # Age of person 6
:field_29, # Buyer 1's sex, as registered at birth
:field_36, # Buyer/Person 2's sex, as registered at birth
:field_44, # Person 3's sex, as registered at birth
:field_48, # Person 4's sex, as registered at birth
:field_52, # Person 5's sex, as registered at birth
:field_56, # Person 6's sex, as registered at birth
: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?
:field_70, # What is buyer 1’s gross annual income?
:field_72, # What is buyer 2’s gross annual income?
].freeze
attribute :bulk_upload
attribute :block_log_creation, :boolean, default: -> { false }
@ -281,13 +298,7 @@ class BulkUpload::Sales::Year2026::RowParser
attribute :field_119, :integer
attribute :field_120, :decimal
attribute :field_121, :decimal
attribute :field_122, :string
attribute :field_123, :string
attribute :field_124, :string
attribute :field_125, :string
attribute :field_126, :string
attribute :field_127, :string
attribute :field_122, :integer
validates :field_1,
presence: {
@ -468,6 +479,8 @@ class BulkUpload::Sales::Year2026::RowParser
return true if blank_row?
normalise_case_insensitive_fields
super(:before_log)
@before_errors = errors.dup
@ -526,8 +539,7 @@ class BulkUpload::Sales::Year2026::RowParser
"field_21", # postcode
"field_22", # postcode
"field_28", # age1
"field_29", # sex1
"field_122", # sexrab1
"field_29", # sexrab1
"field_32", # ecstat1
)
end
@ -540,6 +552,13 @@ class BulkUpload::Sales::Year2026::RowParser
private
def normalise_case_insensitive_fields
CASE_INSENSITIVE_FIELDS.each do |field|
value = send(field)
send("#{field}=", value.upcase) if value.present?
end
end
def prevtenbuy2
case field_64
when "R"
@ -676,13 +695,6 @@ private
age5: %i[field_51],
age6_known: %i[field_55],
age6: %i[field_55],
sex1: %i[field_29],
sex2: %i[field_36],
sex3: %i[field_44],
sex4: %i[field_48],
sex5: %i[field_52],
sex6: %i[field_56],
relat2: %i[field_34],
relat3: %i[field_42],
relat4: %i[field_46],
@ -795,12 +807,13 @@ private
lasttransaction: %i[field_104 field_105 field_106],
initialpurchase: %i[field_100 field_101 field_102],
sexrab1: %i[field_122],
sexrab2: %i[field_123],
sexrab3: %i[field_124],
sexrab4: %i[field_125],
sexrab5: %i[field_126],
sexrab6: %i[field_127],
sexrab1: %i[field_29],
sexrab2: %i[field_36],
sexrab3: %i[field_44],
sexrab4: %i[field_48],
sexrab5: %i[field_52],
sexrab6: %i[field_56],
buildheightclass: %i[field_122],
}
end
@ -829,19 +842,13 @@ private
attributes["age6_known"] = age6_known?
attributes["age6"] = field_55 if attributes["age6_known"]&.zero? && field_55&.match(/\A\d{1,3}\z|\AR\z/)
attributes["sex1"] = field_29
attributes["sex2"] = field_36
attributes["sex3"] = field_44
attributes["sex4"] = field_48
attributes["sex5"] = field_52
attributes["sex6"] = field_56
attributes["sexrab1"] = field_122
attributes["sexrab2"] = field_123
attributes["sexrab3"] = field_124
attributes["sexrab4"] = field_125
attributes["sexrab5"] = field_126
attributes["sexrab6"] = field_127
attributes["sexrab1"] = field_29
attributes["sexrab2"] = field_36
attributes["sexrab3"] = field_44
attributes["sexrab4"] = field_48
attributes["sexrab5"] = field_52
attributes["sexrab6"] = field_56
attributes["buildheightclass"] = field_122
attributes["relat2"] = relationship_from_is_partner(field_34)
attributes["relat3"] = relationship_from_is_partner(field_42)
@ -1042,23 +1049,23 @@ private
end
def person_2_present?
field_35.present? || field_36.present? || field_34.present? || field_123.present?
field_35.present? || field_36.present? || field_34.present?
end
def person_3_present?
field_43.present? || field_44.present? || field_42.present? || field_124.present?
field_43.present? || field_44.present? || field_42.present?
end
def person_4_present?
field_47.present? || field_48.present? || field_46.present? || field_125.present?
field_47.present? || field_48.present? || field_46.present?
end
def person_5_present?
field_51.present? || field_52.present? || field_50.present? || field_126.present?
field_51.present? || field_52.present? || field_50.present?
end
def person_6_present?
field_55.present? || field_56.present? || field_54.present? || field_127.present?
field_55.present? || field_56.present? || field_54.present?
end
def relationship_from_is_partner(is_partner)
@ -1281,7 +1288,6 @@ private
saledate
age1
sexrab1
sex1
ecstat1
owning_organisation
postcode_full
@ -1458,8 +1464,7 @@ private
errors.add(:field_21, error_message) # Postcode
errors.add(:field_22, error_message) # Postcode
errors.add(:field_28, error_message) # Buyer 1 age
errors.add(:field_29, error_message) # Buyer 1 gender
errors.add(:field_122, error_message) # Buyer 1 sex registered at birth
errors.add(:field_29, error_message) # Buyer 1 sex registered at birth
errors.add(:field_32, error_message) # Buyer 1 working situation
errors.add(:field_7, error_message) # Purchaser code
end

11
app/services/csv/sales_log_csv_service.rb

@ -249,7 +249,7 @@ module Csv
return @attributes unless @user.support?
mappings = SUPPORT_ATTRIBUTE_NAME_MAPPINGS
mappings = mappings.merge(SUPPORT_ATTRIBUTE_NAME_MAPPINGS_2025) if @year == 2025
mappings = mappings.merge(SUPPORT_ATTRIBUTE_NAME_MAPPINGS_2025) if @year >= 2025
@attributes.map do |attribute|
mappings[attribute] || attribute.upcase
@ -297,11 +297,10 @@ module Csv
end
def attribute_mappings
mappings = case @year
when 2024
ATTRIBUTE_MAPPINGS.merge(ATTRIBUTE_MAPPINGS_2024)
when 2025
mappings = if @year >= 2025
ATTRIBUTE_MAPPINGS.merge(ATTRIBUTE_MAPPINGS_2024).merge(ATTRIBUTE_MAPPINGS_2025)
elsif @year == 2024
ATTRIBUTE_MAPPINGS.merge(ATTRIBUTE_MAPPINGS_2024)
else
ATTRIBUTE_MAPPINGS
end
@ -348,6 +347,8 @@ module Csv
%w[id status duplicate_set_id created_at updated_at collection_start_year creation_method bulk_upload_id is_dpo]
when 2025
%w[id status duplicate_set_id created_at created_by_id updated_at updated_by_id creation_method bulk_upload_id]
when 2026
%w[id status duplicate_set_id created_at created_by_id updated_at updated_by_id creation_method bulk_upload_id]
else
%w[id status duplicate_set_id created_at updated_at collection_start_year creation_method bulk_upload_id is_dpo]
end

15
app/services/exports/sales_log_export_constants.rb

@ -139,14 +139,25 @@ module Exports::SalesLogExportConstants
(1..6).each do |index|
ALL_YEAR_EXPORT_FIELDS << "AGE#{index}"
ALL_YEAR_EXPORT_FIELDS << "ECSTAT#{index}"
ALL_YEAR_EXPORT_FIELDS << "SEX#{index}"
end
(2..6).each do |index|
ALL_YEAR_EXPORT_FIELDS << "RELAT#{index}"
end
YEAR_2026_EXPORT_FIELDS = Set[]
YEAR_2024_EXPORT_FIELDS = Set[]
(1..6).each do |index|
YEAR_2024_EXPORT_FIELDS << "SEX#{index}"
end
YEAR_2025_EXPORT_FIELDS = Set[]
(1..6).each do |index|
YEAR_2025_EXPORT_FIELDS << "SEX#{index}"
end
YEAR_2026_EXPORT_FIELDS = Set["BUILDHEIGHTCLASS"]
(1..6).each do |index|
YEAR_2026_EXPORT_FIELDS << "SEXRAB#{index}"

4
app/services/exports/sales_log_export_service.rb

@ -159,6 +159,10 @@ module Exports
included_fields.merge(ALL_YEAR_EXPORT_FIELDS)
year_fields = case sales_log.collection_start_year
when 2024
YEAR_2024_EXPORT_FIELDS
when 2025
YEAR_2025_EXPORT_FIELDS
when 2026
YEAR_2026_EXPORT_FIELDS
else

2
app/views/form/page.html.erb

@ -76,6 +76,8 @@
<% end %>
<% if @pages_with_errors_count > 1 %>
<%# Hidden form submit ensures pressing Enter triggers "Save and continue" as the user would expect rather than "See all related answers"%>
<%= f.submit submit_button_text(@page, request.query_parameters["referrer"]), class: "govuk-visually-hidden", tabindex: -1, "aria-hidden": true %>
<div class="govuk-button-group">
<%= f.submit "See all related answers", name: "check_errors", class: "govuk-body govuk-link submit-button-link" %>
</div>

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

@ -44,13 +44,6 @@ en:
hint_text: ""
question_text: "Enter gender identity"
sex1:
page_header: ""
check_answer_label: "Lead tenant’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes the lead tenant’s gender identity?"
ethnic_group:
page_header: ""
check_answer_label: "Lead tenant’s ethnic group"
@ -158,13 +151,6 @@ en:
hint_text: ""
question_text: "Enter gender identity"
sex2:
page_header: ""
check_answer_label: "Person 2’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 2’s gender identity?"
ecstat2:
page_header: ""
check_answer_label: "Person 2’s working situation"
@ -220,13 +206,6 @@ en:
hint_text: ""
question_text: "Enter gender identity"
sex3:
page_header: ""
check_answer_label: "Person 3’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 3’s gender identity?"
ecstat3:
page_header: ""
check_answer_label: "Person 3’s working situation"
@ -282,13 +261,6 @@ en:
hint_text: ""
question_text: "Enter gender identity"
sex4:
page_header: ""
check_answer_label: "Person 4’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 4’s gender identity?"
ecstat4:
page_header: ""
check_answer_label: "Person 4’s working situation"
@ -344,13 +316,6 @@ en:
hint_text: ""
question_text: "Enter gender identity"
sex5:
page_header: ""
check_answer_label: "Person 5’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 5’s gender identity?"
ecstat5:
page_header: ""
check_answer_label: "Person 5’s working situation"
@ -406,13 +371,6 @@ en:
hint_text: ""
question_text: "Enter gender identity"
sex6:
page_header: ""
check_answer_label: "Person 6’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 6’s gender identity?"
ecstat6:
page_header: ""
check_answer_label: "Person 6’s working situation"
@ -468,13 +426,6 @@ en:
hint_text: ""
question_text: "Enter gender identity"
sex7:
page_header: ""
check_answer_label: "Person 7’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 7’s gender identity?"
ecstat7:
page_header: ""
check_answer_label: "Person 7’s working situation"
@ -530,13 +481,6 @@ en:
hint_text: ""
question_text: "Enter gender identity"
sex8:
page_header: ""
check_answer_label: "Person 8’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 8’s gender identity?"
ecstat8:
page_header: ""
check_answer_label: "Person 8’s working situation"

49
config/locales/forms/2026/sales/household_characteristics.en.yml

@ -23,13 +23,6 @@ en:
hint_text: "This is the sex that was registered at birth. The next question will ask about the buyer's gender identity."
question_text: "What was buyer 1's sex at birth?"
sex1:
page_header: ""
check_answer_label: "Buyer 1’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes buyer 1’s gender identity?"
ethnic_group:
page_header: ""
check_answer_label: "Buyer 1’s ethnic group"
@ -151,20 +144,6 @@ en:
hint_text: "This is the sex that was registered at birth. The next question will ask about the person's gender identity."
question_text: "What was person 2's sex at birth?"
sex2:
buyer:
page_header: ""
check_answer_label: "Buyer 2’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes buyer 2’s gender identity?"
person:
page_header: ""
check_answer_label: "Person 2’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 2’s gender identity?"
ethnic_group2:
page_header: ""
check_answer_label: "Buyer 2’s ethnic group"
@ -294,13 +273,6 @@ en:
hint_text: "This is the sex that was registered at birth. The next question will ask about the person's gender identity."
question_text: "What was person 3's sex at birth?"
sex3:
page_header: ""
check_answer_label: "Person 3’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 3’s gender identity?"
ecstat3:
page_header: ""
check_answer_label: "Person 3’s working situation"
@ -342,13 +314,6 @@ en:
hint_text: "This is the sex that was registered at birth. The next question will ask about the person's gender identity."
question_text: "What was person 4's sex at birth?"
sex4:
page_header: ""
check_answer_label: "Person 4’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 4’s gender identity?"
ecstat4:
page_header: ""
check_answer_label: "Person 4’s working situation"
@ -390,13 +355,6 @@ en:
hint_text: "This is the sex that was registered at birth. The next question will ask about the person's gender identity."
question_text: "What was person 5's sex at birth?"
sex5:
page_header: ""
check_answer_label: "Person 5’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 5’s gender identity?"
ecstat5:
page_header: ""
check_answer_label: "Person 5’s working situation"
@ -438,13 +396,6 @@ en:
hint_text: "This is the sex that was registered at birth. The next question will ask about the person's gender identity."
question_text: "What was person 6's sex at birth?"
sex6:
page_header: ""
check_answer_label: "Person 6’s gender identity"
check_answer_prompt: ""
hint_text: "This should be however they personally choose to identify from the options below. This may or may not be the same as their biological sex or the sex they were assigned at birth."
question_text: "Which of these best describes person 6’s gender identity?"
ecstat6:
page_header: ""
check_answer_label: "Person 6’s working situation"

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

@ -59,6 +59,13 @@ en:
hint_text: ""
question_text: "What type of unit is the property?"
buildheightclass:
page_header: ""
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."
question_text: "What is the building height classification?"
builtype:
page_header: ""
check_answer_label: "Type of building"

5
db/migrate/20260219093257_add_buildheightclass_to_sales_logs.rb

@ -0,0 +1,5 @@
class AddBuildheightclassToSalesLogs < ActiveRecord::Migration[7.2]
def change
add_column :sales_logs, :buildheightclass, :integer
end
end

25
db/migrate/20260225135309_add_composite_indexes_for_logs_organisation_lookup.rb

@ -0,0 +1,25 @@
class AddCompositeIndexesForLogsOrganisationLookup < ActiveRecord::Migration[7.2]
disable_ddl_transaction!
def change
add_index :lettings_logs, %i[owning_organisation_id id],
order: { id: :desc },
name: "index_lettings_logs_on_owning_org_and_id_desc",
algorithm: :concurrently
add_index :lettings_logs, %i[managing_organisation_id id],
order: { id: :desc },
name: "index_lettings_logs_on_managing_org_and_id_desc",
algorithm: :concurrently
add_index :sales_logs, %i[owning_organisation_id id],
order: { id: :desc },
name: "index_sales_logs_on_owning_org_and_id_desc",
algorithm: :concurrently
add_index :sales_logs, %i[managing_organisation_id id],
order: { id: :desc },
name: "index_sales_logs_on_managing_org_and_id_desc",
algorithm: :concurrently
end
end

13
db/schema.rb

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_02_12_091506) do
ActiveRecord::Schema[7.2].define(version: 2026_02_25_162121) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -376,6 +376,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_12_091506) do
t.boolean "manual_address_entry_selected", default: false
t.integer "referral_type"
t.integer "working_situation_illness_check"
t.integer "referral_register"
t.integer "referral_noms"
t.integer "referral_org"
t.string "sexrab1"
t.string "sexrab2"
t.string "sexrab3"
@ -400,16 +403,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_12_091506) do
t.string "gender_description6"
t.string "gender_description7"
t.string "gender_description8"
t.integer "referral_register"
t.integer "referral_noms"
t.integer "referral_org"
t.integer "tenancyother_value_check"
t.index ["assigned_to_id"], name: "index_lettings_logs_on_assigned_to_id"
t.index ["bulk_upload_id"], name: "index_lettings_logs_on_bulk_upload_id"
t.index ["created_by_id"], name: "index_lettings_logs_on_created_by_id"
t.index ["location_id"], name: "index_lettings_logs_on_location_id"
t.index ["managing_organisation_id", "id"], name: "index_lettings_logs_on_managing_org_and_id_desc", order: { id: :desc }
t.index ["managing_organisation_id"], name: "index_lettings_logs_on_managing_organisation_id"
t.index ["old_id"], name: "index_lettings_logs_on_old_id", unique: true
t.index ["owning_organisation_id", "id"], name: "index_lettings_logs_on_owning_org_and_id_desc", order: { id: :desc }
t.index ["owning_organisation_id"], name: "index_lettings_logs_on_owning_organisation_id"
t.index ["scheme_id"], name: "index_lettings_logs_on_scheme_id"
t.index ["updated_by_id"], name: "index_lettings_logs_on_updated_by_id"
@ -822,11 +824,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_12_091506) do
t.string "sexrab4"
t.string "sexrab5"
t.string "sexrab6"
t.integer "buildheightclass"
t.index ["assigned_to_id"], name: "index_sales_logs_on_assigned_to_id"
t.index ["bulk_upload_id"], name: "index_sales_logs_on_bulk_upload_id"
t.index ["created_by_id"], name: "index_sales_logs_on_created_by_id"
t.index ["managing_organisation_id", "id"], name: "index_sales_logs_on_managing_org_and_id_desc", order: { id: :desc }
t.index ["managing_organisation_id"], name: "index_sales_logs_on_managing_organisation_id"
t.index ["old_id"], name: "index_sales_logs_on_old_id", unique: true
t.index ["owning_organisation_id", "id"], name: "index_sales_logs_on_owning_org_and_id_desc", order: { id: :desc }
t.index ["owning_organisation_id"], name: "index_sales_logs_on_owning_organisation_id"
t.index ["updated_by_id"], name: "index_sales_logs_on_updated_by_id"
end

8
spec/factories/sales_log.rb

@ -90,6 +90,7 @@ FactoryBot.define do
buy1livein { 1 }
relat2 { "P" }
proptype { 1 }
buildheightclass { 2 }
age2_known { 0 }
age2 { Faker::Number.within(range: 25..45) }
builtype { 1 }
@ -293,12 +294,13 @@ FactoryBot.define do
buy1livein { 1 }
relat2 { "P" }
proptype { 1 }
buildheightclass { 2 }
age2_known { 0 }
age2 { 33 }
builtype { 1 }
ethnic { 3 }
ethnic_group { 17 }
sexrab2 { "X" }
sexrab2 { "R" }
sex2 { "X" }
buy2livein { "1" }
ecstat1 { "1" }
@ -333,11 +335,11 @@ FactoryBot.define do
prevshared { 2 }
sexrab3 { "F" }
sex3 { "F" }
sexrab4 { "X" }
sexrab4 { "R" }
sex4 { "X" }
sexrab5 { "M" }
sex5 { "M" }
sexrab6 { "X" }
sexrab6 { "R" }
sex6 { "X" }
mortgage { 20_000 }
ecstat3 { 9 }

154
spec/fixtures/exports/sales_log_25_26.xml vendored

@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<forms>
<form>
<ID>{id}</ID>
<STATUS>1</STATUS>
<PURCHID>123</PURCHID>
<TYPE>8</TYPE>
<JOINTMORE>1</JOINTMORE>
<BEDS>2</BEDS>
<AGE1>27</AGE1>
<SEX1>F</SEX1>
<ETHNIC>17</ETHNIC>
<BUILTYPE>1</BUILTYPE>
<PROPTYPE>1</PROPTYPE>
<AGE2>33</AGE2>
<RELAT2>P</RELAT2>
<SEX2>X</SEX2>
<NOINT>2</NOINT>
<ECSTAT2>1</ECSTAT2>
<PRIVACYNOTICE>1</PRIVACYNOTICE>
<ECSTAT1>1</ECSTAT1>
<WHEEL>1</WHEEL>
<HHOLDCOUNT>4</HHOLDCOUNT>
<AGE3>14</AGE3>
<LA>E09000033</LA>
<INCOME1>10000</INCOME1>
<AGE4>18</AGE4>
<AGE5>40</AGE5>
<AGE6>40</AGE6>
<INC1MORT>1</INC1MORT>
<INCOME2>10000</INCOME2>
<SAVINGSNK>1</SAVINGSNK>
<SAVINGS/>
<PREVOWN>1</PREVOWN>
<SEX3>F</SEX3>
<MORTGAGE>20000.0</MORTGAGE>
<INC2MORT>1</INC2MORT>
<ECSTAT3>9</ECSTAT3>
<ECSTAT4>3</ECSTAT4>
<ECSTAT5>2</ECSTAT5>
<ECSTAT6>1</ECSTAT6>
<RELAT3>X</RELAT3>
<RELAT4>X</RELAT4>
<RELAT5>R</RELAT5>
<RELAT6>R</RELAT6>
<HB>4</HB>
<SEX4>X</SEX4>
<SEX5>M</SEX5>
<SEX6>X</SEX6>
<FROMBEDS/>
<STAIRCASE/>
<STAIRBOUGHT/>
<STAIROWNED/>
<MRENT/>
<RESALE/>
<DEPOSIT>80000.0</DEPOSIT>
<CASHDIS/>
<DISABLED>1</DISABLED>
<VALUE>110000.0</VALUE>
<EQUITY/>
<DISCOUNT/>
<GRANT>10000.0</GRANT>
<PPCODENK>0</PPCODENK>
<PPOSTC1>SW1A</PPOSTC1>
<PPOSTC2>1AA</PPOSTC2>
<PREVLOC>E09000033</PREVLOC>
<HHREGRES>7</HHREGRES>
<HHREGRESSTILL/>
<PROPLEN/>
<MSCHARGE>100.0</MSCHARGE>
<PREVTEN>1</PREVTEN>
<MORTGAGEUSED>1</MORTGAGEUSED>
<WCHAIR>1</WCHAIR>
<ARMEDFORCESSPOUSE>5</ARMEDFORCESSPOUSE>
<HODAY/>
<HOMONTH/>
<HOYEAR/>
<FROMPROP/>
<SOCPREVTEN/>
<EXTRABOR>1</EXTRABOR>
<HHTYPE>6</HHTYPE>
<VALUE_VALUE_CHECK/>
<PREVSHARED>2</PREVSHARED>
<BUY2LIVING>3</BUY2LIVING>
<UPRN/>
<COUNTY/>
<ADDRESS_SEARCH_VALUE_CHECK/>
<FIRSTSTAIR/>
<NUMSTAIR/>
<MRENTPRESTAIRCASING/>
<DAY>1</DAY>
<MONTH>4</MONTH>
<YEAR>2025</YEAR>
<CREATEDDATE>2025-04-01T00:00:00+01:00</CREATEDDATE>
<CREATEDBY>{created_by_email}</CREATEDBY>
<CREATEDBYID>{created_by_id}</CREATEDBYID>
<USERNAME>{assigned_to_email}</USERNAME>
<USERNAMEID>{assigned_to_id}</USERNAMEID>
<UPLOADDATE>2025-04-01T00:00:00+01:00</UPLOADDATE>
<AMENDEDBY/>
<AMENDEDBYID/>
<OWNINGORGID>{owning_org_id}</OWNINGORGID>
<OWNINGORGNAME>{owning_org_name}</OWNINGORGNAME>
<MANINGORGID>{managing_org_id}</MANINGORGID>
<MANINGORGNAME>{managing_org_name}</MANINGORGNAME>
<CREATIONMETHOD>1</CREATIONMETHOD>
<BULKUPLOADID/>
<COLLECTIONYEAR>2025</COLLECTIONYEAR>
<OWNERSHIP>2</OWNERSHIP>
<JOINT>1</JOINT>
<ETHNICGROUP1>17</ETHNICGROUP1>
<ETHNICGROUP2>17</ETHNICGROUP2>
<PREVIOUSLAKNOWN>1</PREVIOUSLAKNOWN>
<HASMSCHARGE>1</HASMSCHARGE>
<HASSERVICECHARGES/>
<SERVICECHARGES/>
<INC1NK>0</INC1NK>
<INC2NK>0</INC2NK>
<POSTCODE>SW1A 1AA</POSTCODE>
<ISLAINFERRED>true</ISLAINFERRED>
<MORTLEN1>10</MORTLEN1>
<ETHNIC2/>
<PREVTEN2/>
<ADDRESS1>Address line 1</ADDRESS1>
<ADDRESS2/>
<TOWNCITY>City</TOWNCITY>
<LANAME>Westminster</LANAME>
<ADDRESS1INPUT>Address line 1</ADDRESS1INPUT>
<POSTCODEINPUT>SW1A 1AA</POSTCODEINPUT>
<UPRNSELECTED/>
<BULKADDRESS1/>
<BULKADDRESS2/>
<BULKTOWNCITY/>
<BULKCOUNTY/>
<BULKPOSTCODE/>
<BULKLA/>
<NATIONALITYALL1>826</NATIONALITYALL1>
<NATIONALITYALL2>826</NATIONALITYALL2>
<PREVLOCNAME>Westminster</PREVLOCNAME>
<LIVEINBUYER1>1</LIVEINBUYER1>
<LIVEINBUYER2>1</LIVEINBUYER2>
<HASESTATEFEE/>
<ESTATEFEE/>
<STAIRLASTDAY/>
<STAIRLASTMONTH/>
<STAIRLASTYEAR/>
<STAIRINITIALDAY/>
<STAIRINITIALMONTH/>
<STAIRINITIALYEAR/>
<MSCHARGE_VALUE_CHECK/>
<DUPLICATESET/>
<STAIRCASETOSALE/>
</form>
</forms>

155
spec/fixtures/exports/sales_log_26_27.xml vendored

@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8"?>
<forms>
<form>
<ID>{id}</ID>
<STATUS>1</STATUS>
<PURCHID>123</PURCHID>
<TYPE>8</TYPE>
<JOINTMORE>1</JOINTMORE>
<BEDS>2</BEDS>
<AGE1>27</AGE1>
<SEXRAB1>F</SEXRAB1>
<ETHNIC>17</ETHNIC>
<BUILTYPE>1</BUILTYPE>
<PROPTYPE>1</PROPTYPE>
<AGE2>33</AGE2>
<RELAT2>P</RELAT2>
<SEXRAB2>R</SEXRAB2>
<NOINT>2</NOINT>
<ECSTAT2>1</ECSTAT2>
<PRIVACYNOTICE>1</PRIVACYNOTICE>
<ECSTAT1>1</ECSTAT1>
<WHEEL>1</WHEEL>
<HHOLDCOUNT>4</HHOLDCOUNT>
<AGE3>14</AGE3>
<LA>E09000033</LA>
<INCOME1>10000</INCOME1>
<AGE4>18</AGE4>
<AGE5>40</AGE5>
<AGE6>40</AGE6>
<INC1MORT>1</INC1MORT>
<INCOME2>10000</INCOME2>
<SAVINGSNK>1</SAVINGSNK>
<SAVINGS/>
<PREVOWN>1</PREVOWN>
<SEXRAB3>F</SEXRAB3>
<MORTGAGE>20000.0</MORTGAGE>
<INC2MORT>1</INC2MORT>
<ECSTAT3>9</ECSTAT3>
<ECSTAT4>3</ECSTAT4>
<ECSTAT5>2</ECSTAT5>
<ECSTAT6>1</ECSTAT6>
<RELAT3>X</RELAT3>
<RELAT4>X</RELAT4>
<RELAT5>R</RELAT5>
<RELAT6>R</RELAT6>
<HB>4</HB>
<SEXRAB4>R</SEXRAB4>
<SEXRAB5>M</SEXRAB5>
<SEXRAB6>R</SEXRAB6>
<FROMBEDS/>
<STAIRCASE/>
<STAIRBOUGHT/>
<STAIROWNED/>
<MRENT/>
<RESALE/>
<DEPOSIT>80000.0</DEPOSIT>
<CASHDIS/>
<DISABLED>1</DISABLED>
<VALUE>110000.0</VALUE>
<EQUITY/>
<DISCOUNT/>
<GRANT>10000.0</GRANT>
<PPCODENK>0</PPCODENK>
<PPOSTC1>SW1A</PPOSTC1>
<PPOSTC2>1AA</PPOSTC2>
<PREVLOC>E09000033</PREVLOC>
<HHREGRES>7</HHREGRES>
<HHREGRESSTILL/>
<PROPLEN/>
<MSCHARGE>100.0</MSCHARGE>
<PREVTEN>1</PREVTEN>
<MORTGAGEUSED>1</MORTGAGEUSED>
<WCHAIR>1</WCHAIR>
<ARMEDFORCESSPOUSE>5</ARMEDFORCESSPOUSE>
<HODAY/>
<HOMONTH/>
<HOYEAR/>
<FROMPROP/>
<SOCPREVTEN/>
<EXTRABOR>1</EXTRABOR>
<HHTYPE>6</HHTYPE>
<VALUE_VALUE_CHECK/>
<PREVSHARED>2</PREVSHARED>
<BUY2LIVING>3</BUY2LIVING>
<UPRN/>
<COUNTY/>
<ADDRESS_SEARCH_VALUE_CHECK/>
<FIRSTSTAIR/>
<NUMSTAIR/>
<MRENTPRESTAIRCASING/>
<BUILDHEIGHTCLASS>2</BUILDHEIGHTCLASS>
<DAY>1</DAY>
<MONTH>4</MONTH>
<YEAR>2026</YEAR>
<CREATEDDATE>2026-04-01T00:00:00+01:00</CREATEDDATE>
<CREATEDBY>{created_by_email}</CREATEDBY>
<CREATEDBYID>{created_by_id}</CREATEDBYID>
<USERNAME>{assigned_to_email}</USERNAME>
<USERNAMEID>{assigned_to_id}</USERNAMEID>
<UPLOADDATE>2026-04-01T00:00:00+01:00</UPLOADDATE>
<AMENDEDBY/>
<AMENDEDBYID/>
<OWNINGORGID>{owning_org_id}</OWNINGORGID>
<OWNINGORGNAME>{owning_org_name}</OWNINGORGNAME>
<MANINGORGID>{managing_org_id}</MANINGORGID>
<MANINGORGNAME>{managing_org_name}</MANINGORGNAME>
<CREATIONMETHOD>1</CREATIONMETHOD>
<BULKUPLOADID/>
<COLLECTIONYEAR>2026</COLLECTIONYEAR>
<OWNERSHIP>2</OWNERSHIP>
<JOINT>1</JOINT>
<ETHNICGROUP1>17</ETHNICGROUP1>
<ETHNICGROUP2>17</ETHNICGROUP2>
<PREVIOUSLAKNOWN>1</PREVIOUSLAKNOWN>
<HASMSCHARGE>1</HASMSCHARGE>
<HASSERVICECHARGES/>
<SERVICECHARGES/>
<INC1NK>0</INC1NK>
<INC2NK>0</INC2NK>
<POSTCODE>SW1A 1AA</POSTCODE>
<ISLAINFERRED>true</ISLAINFERRED>
<MORTLEN1>10</MORTLEN1>
<ETHNIC2/>
<PREVTEN2/>
<ADDRESS1>Address line 1</ADDRESS1>
<ADDRESS2/>
<TOWNCITY>City</TOWNCITY>
<LANAME>Westminster</LANAME>
<ADDRESS1INPUT>Address line 1</ADDRESS1INPUT>
<POSTCODEINPUT>SW1A 1AA</POSTCODEINPUT>
<UPRNSELECTED/>
<BULKADDRESS1/>
<BULKADDRESS2/>
<BULKTOWNCITY/>
<BULKCOUNTY/>
<BULKPOSTCODE/>
<BULKLA/>
<NATIONALITYALL1>826</NATIONALITYALL1>
<NATIONALITYALL2>826</NATIONALITYALL2>
<PREVLOCNAME>Westminster</PREVLOCNAME>
<LIVEINBUYER1>1</LIVEINBUYER1>
<LIVEINBUYER2>1</LIVEINBUYER2>
<HASESTATEFEE/>
<ESTATEFEE/>
<STAIRLASTDAY/>
<STAIRLASTMONTH/>
<STAIRLASTYEAR/>
<STAIRINITIALDAY/>
<STAIRINITIALMONTH/>
<STAIRINITIALYEAR/>
<MSCHARGE_VALUE_CHECK/>
<DUPLICATESET/>
<STAIRCASETOSALE/>
</form>
</forms>

24
spec/fixtures/files/2026_27_sales_bulk_upload.csv vendored

File diff suppressed because one or more lines are too long

3
spec/fixtures/files/sales_logs_csv_export_codes_23.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/sales_logs_csv_export_codes_24.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/sales_logs_csv_export_codes_25.csv vendored

File diff suppressed because one or more lines are too long

3
spec/fixtures/files/sales_logs_csv_export_codes_26.csv vendored

File diff suppressed because one or more lines are too long

3
spec/fixtures/files/sales_logs_csv_export_labels_23.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/sales_logs_csv_export_labels_24.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/sales_logs_csv_export_labels_25.csv vendored

File diff suppressed because one or more lines are too long

3
spec/fixtures/files/sales_logs_csv_export_labels_26.csv vendored

File diff suppressed because one or more lines are too long

3
spec/fixtures/files/sales_logs_csv_export_non_support_codes_26.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/sales_logs_csv_export_non_support_labels_24.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/sales_logs_csv_export_non_support_labels_25.csv vendored

File diff suppressed because one or more lines are too long

3
spec/fixtures/files/sales_logs_csv_export_non_support_labels_26.csv vendored

File diff suppressed because one or more lines are too long

1
spec/fixtures/variable_definitions/sales_download_26_27.csv vendored

@ -4,3 +4,4 @@ sexrab3,What was person 3's sex at birth?
sexrab4,What was person 4's sex at birth?
sexrab5,What was person 5's sex at birth?
sexrab6,What was person 6's sex at birth?
buildheightclass, What is the building height classification?

1 sexrab1 What was buyer 1's sex at birth?
4 sexrab4 What was person 4's sex at birth?
5 sexrab5 What was person 5's sex at birth?
6 sexrab6 What was person 6's sex at birth?
7 buildheightclass What is the building height classification?

2
spec/lib/tasks/log_variable_definitions_spec.rb

@ -6,7 +6,7 @@ RSpec.describe "log_variable_definitions" do
subject(:task) { Rake::Task["data_import:add_variable_definitions"] }
let(:path) { "spec/fixtures/variable_definitions" }
let(:total_variable_definitions_count) { 450 }
let(:total_variable_definitions_count) { 451 }
before do
Rake.application.rake_require("tasks/log_variable_definitions")

36
spec/models/form/sales/pages/building_height_class_spec.rb

@ -0,0 +1,36 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::BuildingHeightClass, type: :model do
include CollectionTimeHelper
subject(:page) { described_class.new(page_id, page_definition, subsection) }
let(:page_id) { nil }
let(:page_definition) { nil }
let(:subsection) { instance_double(Form::Subsection, form: instance_double(Form, start_date: current_collection_start_date)) }
let(:sales_log) { FactoryBot.create(:sales_log, :completed) }
it "has correct subsection" do
expect(page.subsection).to eq(subsection)
end
it "has correct questions" do
expect(page.questions.map(&:id)).to eq(%w[buildheightclass])
end
it "has the correct id" do
expect(page.id).to eq("building_height_class")
end
it "has the correct description" do
expect(page.description).to be_nil
end
it "has the correct depends_on" do
expect(page.depends_on).to eq([
{ "proptype" => 1 },
{ "proptype" => 2 },
{ "proptype" => 9 },
])
end
end

29
spec/models/form/sales/pages/service_charge_staircasing_spec.rb

@ -0,0 +1,29 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::ServiceChargeStaircasing, type: :model do
subject(:page) { described_class.new(page_id, page_definition, subsection) }
let(:page_id) { nil }
let(:page_definition) { nil }
let(:subsection) { instance_double(Form::Subsection, form: instance_double(Form, start_date: Time.zone.local(2026, 4, 1))) }
it "has correct subsection" do
expect(page.subsection).to eq(subsection)
end
it "has correct questions" do
expect(page.questions.map(&:id)).to eq(%w[has_mscharge mscharge])
end
it "has the correct id" do
expect(page.id).to be_nil
end
it "has the correct description" do
expect(page.description).to be_nil
end
it "has correct depends_on" do
expect(page.depends_on).to be_nil
end
end

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

@ -0,0 +1,39 @@
require "rails_helper"
RSpec.describe Form::Sales::Questions::BuildingHeightClass, type: :model do
include CollectionTimeHelper
subject(:question) { described_class.new(question_id, question_definition, page) }
let(:question_id) { nil }
let(:question_definition) { nil }
let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, form: instance_double(Form, start_date: current_collection_start_date))) }
it "has correct page" do
expect(question.page).to eq(page)
end
it "has the correct id" do
expect(question.id).to eq("buildheightclass")
end
it "has the correct type" do
expect(question.type).to eq("radio")
end
it "is not marked as derived" do
expect(question.derived?(nil)).to be false
end
it "has the correct answer_options" do
expect(question.answer_options).to eq({
"1" => { "value" => "High-rise" },
"2" => { "value" => "Low-rise" },
"3" => { "value" => "Don't know" },
})
end
it "has the correct question_number" do
expect(question.question_number).to eq(17)
end
end

48
spec/models/form/sales/questions/has_service_charge_spec.rb

@ -1,12 +1,14 @@
require "rails_helper"
RSpec.describe Form::Sales::Questions::HasServiceCharge, type: :model do
subject(:question) { described_class.new(question_id, question_definition, page) }
subject(:question) { described_class.new(question_id, question_definition, page, staircasing:) }
let(:form) { instance_double(Form, start_date: Time.zone.local(2025, 4, 4)) }
let(:question_id) { nil }
let(:question_definition) { nil }
let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, id: "shared_ownership", form:)) }
let(:subsection) { instance_double(Form::Subsection, form: instance_double(Form, start_date:)) }
let(:page) { instance_double(Form::Page, subsection:) }
let(:start_date) { Time.utc(2025, 5, 1) }
let(:staircasing) { false }
it "has correct page" do
expect(question.page).to eq(page)
@ -46,4 +48,44 @@ RSpec.describe Form::Sales::Questions::HasServiceCharge, type: :model do
],
})
end
context "with 2025/26 form" do
let(:start_date) { Time.utc(2025, 4, 1) }
before do
allow(subsection.form).to receive(:start_year_2025_or_later?).and_return(true)
end
context "when not staircasing" do
let(:staircasing) { false }
it "has the correct question number" do
expect(question.question_number).to eq(88)
end
end
end
context "with 2026/27 form" do
let(:start_date) { Time.utc(2026, 4, 1) }
before do
allow(subsection.form).to receive(:start_year_2026_or_later?).and_return(true)
end
context "when staircasing" do
let(:staircasing) { true }
it "has the correct question number" do
expect(question.question_number).to eq(0)
end
end
context "when not staircasing" do
let(:staircasing) { false }
it "has the correct question number" do
expect(question.question_number).to eq(0)
end
end
end
end

47
spec/models/form/sales/questions/service_charge_spec.rb

@ -1,11 +1,14 @@
require "rails_helper"
RSpec.describe Form::Sales::Questions::ServiceCharge, type: :model do
subject(:question) { described_class.new(question_id, question_definition, page) }
subject(:question) { described_class.new(question_id, question_definition, page, staircasing:) }
let(:question_id) { nil }
let(:question_definition) { nil }
let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, form: instance_double(Form, start_date: Time.zone.local(2023, 4, 1)))) }
let(:subsection) { instance_double(Form::Subsection, form: instance_double(Form, start_date:)) }
let(:page) { instance_double(Form::Page, subsection:) }
let(:start_date) { Time.utc(2023, 4, 1) }
let(:staircasing) { false }
it "has correct page" do
expect(question.page).to eq(page)
@ -34,4 +37,44 @@ RSpec.describe Form::Sales::Questions::ServiceCharge, type: :model do
it "has the correct prefix" do
expect(question.prefix).to eq("£")
end
context "with 2025/26 form" do
let(:start_date) { Time.utc(2025, 4, 1) }
before do
allow(subsection.form).to receive(:start_year_2025_or_later?).and_return(true)
end
context "when not staircasing" do
let(:staircasing) { false }
it "has the correct question number" do
expect(question.question_number).to eq(88)
end
end
end
context "with 2026/27 form" do
let(:start_date) { Time.utc(2026, 4, 1) }
before do
allow(subsection.form).to receive(:start_year_2026_or_later?).and_return(true)
end
context "when staircasing" do
let(:staircasing) { true }
it "has the correct question number" do
expect(question.question_number).to eq(0)
end
end
context "when not staircasing" do
let(:staircasing) { false }
it "has the correct question number" do
expect(question.question_number).to eq(0)
end
end
end
end

7
spec/models/form/sales/subsections/household_characteristics_spec.rb

@ -407,7 +407,6 @@ RSpec.describe Form::Sales::Subsections::HouseholdCharacteristics, type: :model
age_1_old_persons_shared_ownership_joint_purchase_value_check
age_1_old_persons_shared_ownership_value_check
buyer_1_sex_registered_at_birth
buyer_1_gender_identity
buyer_1_ethnic_group
buyer_1_ethnic_background_black
buyer_1_ethnic_background_asian
@ -428,7 +427,6 @@ RSpec.describe Form::Sales::Subsections::HouseholdCharacteristics, type: :model
age_2_buyer_retirement_value_check
age_2_buyer_not_retired_value_check
buyer_2_sex_registered_at_birth
buyer_2_gender_identity
buyer_2_ethnic_group
buyer_2_ethnic_background_black
buyer_2_ethnic_background_asian
@ -453,7 +451,6 @@ RSpec.describe Form::Sales::Subsections::HouseholdCharacteristics, type: :model
age_2_not_retired_value_check
age_2_partner_under_16_value_check
person_2_sex_registered_at_birth
person_2_gender_identity
person_2_working_situation
working_situation_2_retirement_value_check
working_situation_2_not_retired_value_check
@ -466,7 +463,6 @@ RSpec.describe Form::Sales::Subsections::HouseholdCharacteristics, type: :model
age_3_not_retired_value_check
age_3_partner_under_16_value_check
person_3_sex_registered_at_birth
person_3_gender_identity
person_3_working_situation
working_situation_3_retirement_value_check
working_situation_3_not_retired_value_check
@ -479,7 +475,6 @@ RSpec.describe Form::Sales::Subsections::HouseholdCharacteristics, type: :model
age_4_not_retired_value_check
age_4_partner_under_16_value_check
person_4_sex_registered_at_birth
person_4_gender_identity
person_4_working_situation
working_situation_4_retirement_value_check
working_situation_4_not_retired_value_check
@ -492,7 +487,6 @@ RSpec.describe Form::Sales::Subsections::HouseholdCharacteristics, type: :model
age_5_not_retired_value_check
age_5_partner_under_16_value_check
person_5_sex_registered_at_birth
person_5_gender_identity
person_5_working_situation
working_situation_5_retirement_value_check
working_situation_5_not_retired_value_check
@ -505,7 +499,6 @@ RSpec.describe Form::Sales::Subsections::HouseholdCharacteristics, type: :model
age_6_not_retired_value_check
age_6_partner_under_16_value_check
person_6_sex_registered_at_birth
person_6_gender_identity
person_6_working_situation
working_situation_6_retirement_value_check
working_situation_6_not_retired_value_check

53
spec/models/form/sales/subsections/property_information_spec.rb

@ -1,6 +1,8 @@
require "rails_helper"
RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do
include CollectionTimeHelper
subject(:property_information) { described_class.new(nil, nil, section) }
let(:section) { instance_double(Form::Sales::Sections::PropertyInformation) }
@ -11,14 +13,16 @@ RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do
describe "pages" do
let(:section) { instance_double(Form::Sales::Sections::Household, form:) }
let(:form) { instance_double(Form, start_date:) }
before do
allow(form).to receive_messages(start_year_2024_or_later?: false, start_year_2025_or_later?: false)
end
let(:start_year_2024_or_later?) { true }
let(:start_year_2025_or_later?) { true }
let(:start_year_2026_or_later?) { true }
let(:form) { instance_double(Form, start_date:, start_year_2024_or_later?: start_year_2024_or_later?, start_year_2025_or_later?: start_year_2025_or_later?, start_year_2026_or_later?: start_year_2026_or_later?) }
context "when 2023" do
let(:start_date) { Time.utc(2023, 2, 8) }
let(:start_date) { collection_start_date_for_year(2023) }
let(:start_year_2024_or_later?) { false }
let(:start_year_2025_or_later?) { false }
let(:start_year_2026_or_later?) { false }
it "has correct pages" do
expect(property_information.pages.map(&:id)).to eq(
@ -44,11 +48,9 @@ RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do
end
context "when 2024" do
let(:start_date) { Time.utc(2024, 2, 8) }
before do
allow(form).to receive_messages(start_year_2024_or_later?: true, start_year_2025_or_later?: false)
end
let(:start_date) { collection_start_date_for_year(2024) }
let(:start_year_2025_or_later?) { false }
let(:start_year_2026_or_later?) { false }
it "has correct pages" do
expect(property_information.pages.map(&:id)).to eq(
@ -73,11 +75,33 @@ RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do
end
context "when 2025" do
let(:start_date) { Time.utc(2025, 2, 8) }
let(:start_date) { collection_start_date_for_year(2025) }
let(:start_year_2026_or_later?) { false }
before do
allow(form).to receive_messages(start_year_2024_or_later?: true, start_year_2025_or_later?: true)
it "has correct pages" do
expect(property_information.pages.map(&:id)).to eq(
%w[
address_search
address
property_local_authority
local_authority_buyer_1_income_max_value_check
local_authority_buyer_2_income_max_value_check
local_authority_combined_income_max_value_check
about_price_la_value_check
property_unit_type
property_number_of_bedrooms
about_price_bedrooms_value_check
monthly_charges_property_type_value_check
percentage_discount_proptype_value_check
property_building_type
property_wheelchair_accessible
],
)
end
end
context "when 2026" do
let(:start_date) { collection_start_date_for_year(2026) }
it "has correct pages" do
expect(property_information.pages.map(&:id)).to eq(
@ -90,6 +114,7 @@ RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do
local_authority_combined_income_max_value_check
about_price_la_value_check
property_unit_type
building_height_class
property_number_of_bedrooms
about_price_bedrooms_value_check
monthly_charges_property_type_value_check

85
spec/models/form/sales/subsections/shared_ownership_staircasing_transaction_spec.rb

@ -0,0 +1,85 @@
require "rails_helper"
RSpec.describe Form::Sales::Subsections::SharedOwnershipStaircasingTransaction, type: :model do
subject(:shared_ownership_staircasing_transaction) { described_class.new(nil, nil, section) }
let(:form) { instance_double(Form, start_year_2026_or_later?: false) }
let(:section) { instance_double(Form::Sales::Sections::SaleInformation, form:) }
it "has correct section" do
expect(shared_ownership_staircasing_transaction.section).to eq(section)
end
it "has the correct depends_on" do
expect(shared_ownership_staircasing_transaction.depends_on).to eq([{ "ownershipsch" => 1, "setup_completed?" => true, "staircase" => 1 }])
end
it "has the correct id" do
expect(shared_ownership_staircasing_transaction.id).to eq("shared_ownership_staircasing_transaction")
end
it "has the correct label" do
expect(shared_ownership_staircasing_transaction.label).to eq("Shared ownership - staircasing transaction")
end
it "has the correct copy key" do
expect(shared_ownership_staircasing_transaction.copy_key).to eq("sale_information")
end
context "when the start year is 2025" do
let(:form) { instance_double(Form, start_year_2025_or_later?: true, start_year_2026_or_later?: false, start_date: Time.utc(2025, 4, 1)) }
it "has correct pages" do
expect(shared_ownership_staircasing_transaction.pages.map(&:id)).to eq(
%w[
about_staircasing_joint_purchase
about_staircasing_not_joint_purchase
staircase_sale
staircase_bought_value_check
staircase_owned_value_check_joint_purchase
staircase_owned_value_check_not_joint_purchase
staircase_first_time
staircase_previous
staircase_initial_date
value_shared_ownership_staircase
about_price_shared_ownership_value_check_staircasing
staircase_equity
shared_ownership_equity_value_check_staircasing
staircase_mortgage_used_shared_ownership
monthly_rent_staircasing_owned
monthly_rent_staircasing
monthly_charges_shared_ownership_value_check
],
)
end
end
context "when the start year is 2026" do
let(:form) { instance_double(Form, start_year_2025_or_later?: true, start_year_2026_or_later?: true, start_date: Time.utc(2026, 4, 1)) }
it "has correct pages" do
expect(shared_ownership_staircasing_transaction.pages.map(&:id)).to eq(
%w[
about_staircasing_joint_purchase
about_staircasing_not_joint_purchase
staircase_sale
staircase_bought_value_check
staircase_owned_value_check_joint_purchase
staircase_owned_value_check_not_joint_purchase
staircase_first_time
staircase_previous
staircase_initial_date
value_shared_ownership_staircase
about_price_shared_ownership_value_check_staircasing
staircase_equity
shared_ownership_equity_value_check_staircasing
staircase_mortgage_used_shared_ownership
monthly_rent_staircasing_owned
monthly_rent_staircasing
service_charge_staircasing
monthly_charges_shared_ownership_value_check
],
)
end
end
end

14
spec/services/bulk_upload/lettings/year2025/row_parser_spec.rb

@ -541,6 +541,20 @@ RSpec.describe BulkUpload::Lettings::Year2025::RowParser do
end
end
end
context "and case insensitive fields are set to lowercase" do
let(:case_insensitive_fields) { %w[field_43 field_49 field_53 field_57 field_61 field_65 field_69 field_73] }
let(:case_insensitive_integer_fields_with_r_option) { %w[field_42 field_48 field_52 field_56 field_60 field_64 field_68 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 })
.merge(case_insensitive_integer_fields_with_r_option.each_with_object({}) { |field, h| h[field.to_sym] = "r" })
end
it "is still valid" do
expect(parser).to be_valid
end
end
end
context "when valid row with valid decimal (integer) field_11" do

14
spec/services/bulk_upload/lettings/year2026/row_parser_spec.rb

@ -434,6 +434,20 @@ RSpec.describe BulkUpload::Lettings::Year2026::RowParser do
end
end
end
context "and case insensitive fields are set to lowercase" do
let(:case_insensitive_fields) { %w[field_42 field_50 field_56 field_62 field_68 field_74 field_80 field_86] }
let(:case_insensitive_integer_fields_with_r_option) { %w[field_41 field_48 field_54 field_60 field_66 field_72 field_78 field_84] }
let(:attributes) do
valid_attributes
.merge(case_insensitive_fields.each_with_object({}) { |field, h| h[field.to_sym] = valid_attributes[field.to_sym]&.downcase })
.merge(case_insensitive_integer_fields_with_r_option.each_with_object({}) { |field, h| h[field.to_sym] = "r" })
end
it "is still valid" do
expect(parser).to be_valid
end
end
end
context "when valid row with valid decimal (integer) field_11" do

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

@ -292,6 +292,20 @@ RSpec.describe BulkUpload::Sales::Year2025::RowParser do
expect(questions.map(&:id).size).to eq(0)
expect(questions.map(&:id)).to eql([])
end
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(:attributes) do
valid_attributes
.merge(case_insensitive_fields.each_with_object({}) { |field, h| h[field.to_sym] = valid_attributes[field.to_sym]&.downcase })
.merge(case_insensitive_integer_fields_with_r_option.each_with_object({}) { |field, h| h[field.to_sym] = "r" })
end
it "is still valid" do
expect(parser).to be_valid
end
end
end
describe "#validate_nulls" do

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

@ -112,12 +112,7 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
field_105: "07",
field_106: "2023",
field_110: "900",
field_122: "F",
field_123: "F",
field_124: "M",
field_125: "M",
field_126: "R",
field_127: "R",
field_122: "1",
}
end
@ -298,6 +293,20 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
expect(questions.map(&:id).size).to eq(0)
expect(questions.map(&:id)).to eql([])
end
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(:attributes) do
valid_attributes
.merge(case_insensitive_fields.each_with_object({}) { |field, h| h[field.to_sym] = valid_attributes[field.to_sym]&.downcase })
.merge(case_insensitive_integer_fields_with_r_option.each_with_object({}) { |field, h| h[field.to_sym] = "r" })
end
it "is still valid" do
expect(parser).to be_valid
end
end
end
describe "#validate_nulls" do
@ -306,7 +315,7 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
it "fetches the question's check_answer_label if it exists" do
parser.valid?
expect(parser.errors[:field_29]).to eql([I18n.t("validations.not_answered", question: "buyer 1’s gender identity.")])
expect(parser.errors[:field_29]).to eql([I18n.t("validations.not_answered", question: "buyer 1’s sex registered at birth.")])
end
end
@ -779,7 +788,7 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
:field_21, # Postcode
:field_22, # Postcode
:field_28, # Buyer 1 age
:field_29, # Buyer 1 gender
:field_29, # Buyer 1 sex registered at birth
:field_32, # Buyer 1 working situation
:field_7, # Purchaser code
].each do |field|
@ -809,7 +818,7 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
:field_21, # Postcode
:field_22, # Postcode
:field_28, # Buyer 1 age
:field_29, # Buyer 1 gender
:field_29, # Buyer 1 sex registered at birth
:field_32, # Buyer 1 working situation
:field_7, # Purchaser code
].each do |field|

129
spec/services/csv/sales_log_csv_service_spec.rb

@ -1,6 +1,8 @@
require "rails_helper"
RSpec.describe Csv::SalesLogCsvService do
include CollectionTimeHelper
subject(:task) { Rake::Task["data_import:add_variable_definitions"] }
let(:form_handler_mock) { instance_double(FormHandler) }
@ -21,17 +23,22 @@ RSpec.describe Csv::SalesLogCsvService do
purchid: nil,
hholdcount: 3,
age1: 30,
sexrab1: "F",
sex1: "X",
age2: 35,
sexrab2: "M",
sex2: "X",
sexrab3: "F",
sex3: "X",
age4_known: 1,
sexrab4: "R",
sex4: "X",
details_known_5: 2,
age6_known: nil,
age6: nil,
ecstat6: nil,
relat6: nil,
sexrab6: nil,
sex6: nil,
town_or_city: "Town or city",
address_line1_as_entered: "address line 1 as entered",
@ -192,10 +199,10 @@ RSpec.describe Csv::SalesLogCsvService do
expect(la_label_value).to eq "Westminster"
end
context "when the requested form is 2024" do
let(:now) { Time.zone.local(2024, 5, 1) }
context "when the requested form is 2024", metadata: { year: 24 } do
let(:now) { collection_start_date_for_year(2024) }
let(:year) { 2024 }
let(:fixed_time) { Time.zone.local(2024, 5, 1) }
let(:fixed_time) { collection_start_date_for_year(2024) }
before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
@ -212,10 +219,10 @@ RSpec.describe Csv::SalesLogCsvService do
end
end
context "when the requested form is 2025" do
let(:now) { Time.zone.local(2025, 5, 1) }
context "when the requested form is 2025", metadata: { year: 25 } do
let(:now) { collection_start_date_for_year(2025) }
let(:year) { 2025 }
let(:fixed_time) { Time.zone.local(2025, 5, 1) }
let(:fixed_time) { collection_start_date_for_year(2025) }
before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
@ -232,13 +239,18 @@ RSpec.describe Csv::SalesLogCsvService do
end
end
context "when the requested form is 2023" do
let(:now) { Time.zone.local(2024, 1, 1) }
let(:year) { 2023 }
context "when the requested form is 2026", metadata: { year: 26 } do
let(:now) { collection_start_date_for_year(2026) }
let(:year) { 2026 }
let(:fixed_time) { collection_start_date_for_year(2026) }
it "exports the CSV with the 2023 ordering and all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_labels_23.csv")
values_to_delete = %w[ID]
before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
end
it "exports the CSV with the 2026 ordering and all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_labels_26.csv")
values_to_delete = %w[ID OWNINGORGID MANINGORGID CREATEDBYID USERNAMEID AMENDEDBYID]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
@ -300,53 +312,58 @@ RSpec.describe Csv::SalesLogCsvService do
expect(la_label_value).to eq "Westminster"
end
context "when the requested form is 2025" do
let(:now) { Time.zone.local(2025, 5, 1) }
let(:fixed_time) { Time.zone.local(2025, 5, 1) }
let(:year) { 2025 }
context "when the requested form is 2024", metadata: { year: 24 } do
let(:now) { collection_start_date_for_year(2024) }
let(:fixed_time) { collection_start_date_for_year(2024) }
let(:year) { 2024 }
before do
log.update!(manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
end
it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_25.csv")
values_to_delete = %w[ID OWNINGORGID MANINGORGID CREATEDBYID USERNAMEID AMENDEDBYID]
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_24.csv")
values_to_delete = %w[ID]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
end
expect(csv[1..]).to eq expected_content[1..] # Skip the first line as it contains the definitions
expect(csv).to eq expected_content
end
end
context "when the requested form is 2024" do
let(:now) { Time.zone.local(2024, 5, 1) }
let(:fixed_time) { Time.zone.local(2024, 5, 1) }
let(:year) { 2024 }
context "when the requested form is 2025", metadata: { year: 25 } do
let(:now) { collection_start_date_for_year(2025) }
let(:fixed_time) { collection_start_date_for_year(2025) }
let(:year) { 2025 }
before do
log.update!(manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
end
it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_24.csv")
values_to_delete = %w[ID]
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_25.csv")
values_to_delete = %w[ID OWNINGORGID MANINGORGID CREATEDBYID USERNAMEID AMENDEDBYID]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
end
expect(csv).to eq expected_content
expect(csv[1..]).to eq expected_content[1..] # Skip the first line as it contains the definitions
end
end
context "when the requested form is 2023" do
let(:now) { Time.zone.local(2024, 1, 1) }
let(:year) { 2023 }
context "when the requested form is 2026", metadata: { year: 26 } do
let(:now) { collection_start_date_for_year(2026) }
let(:fixed_time) { collection_start_date_for_year(2026) }
let(:year) { 2026 }
before do
log.update!(manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
end
it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_23.csv")
values_to_delete = %w[ID]
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_26.csv")
values_to_delete = %w[ID OWNINGORGID MANINGORGID CREATEDBYID USERNAMEID AMENDEDBYID]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
@ -375,10 +392,10 @@ RSpec.describe Csv::SalesLogCsvService do
expect(attribute_line).not_to include(*%w[address_line1_as_entered address_line2_as_entered town_or_city_as_entered county_as_entered postcode_full_as_entered la_as_entered created_by value_value_check monthly_charges_value_check])
end
context "and the requested form is 2024" do
context "and the requested form is 2024", metadata: { year: 24 } do
let(:year) { 2024 }
let(:now) { Time.zone.local(2024, 5, 1) }
let(:fixed_time) { Time.zone.local(2024, 5, 1) }
let(:now) { collection_start_date_for_year(2024) }
let(:fixed_time) { collection_start_date_for_year(2024) }
before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
@ -399,10 +416,10 @@ RSpec.describe Csv::SalesLogCsvService do
end
end
context "and the requested form is 2025" do
context "and the requested form is 2025", metadata: { year: 25 } do
let(:year) { 2025 }
let(:now) { Time.zone.local(2025, 5, 1) }
let(:fixed_time) { Time.zone.local(2025, 5, 1) }
let(:now) { collection_start_date_for_year(2025) }
let(:fixed_time) { collection_start_date_for_year(2025) }
before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
@ -422,5 +439,43 @@ RSpec.describe Csv::SalesLogCsvService do
end
end
end
context "and the requested form is 2026", metadata: { year: 26 } do
let(:year) { 2026 }
let(:now) { collection_start_date_for_year(2026) }
let(:fixed_time) { collection_start_date_for_year(2026) }
before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1, buildheightclass: 2)
end
context "and exporting with labels" do
let(:service) { described_class.new(user:, export_type: "labels", year:) }
it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_non_support_labels_26.csv")
values_to_delete = %w[id owning_organisation_id managing_organisation_id assigned_to_id updated_by_id]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
end
expect(csv[1..]).to eq expected_content[1..] # Skip the first line as it contains the definitions
end
end
context "and exporting with codes" do
let(:service) { described_class.new(user:, export_type: "codes", year:) }
it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_non_support_codes_26.csv")
values_to_delete = %w[id owning_organisation_id managing_organisation_id assigned_to_id updated_by_id]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
end
expect(csv[1..]).to eq expected_content[1..] # Skip the first line as it contains the definitions
end
end
end
end
end

66
spec/services/exports/sales_log_export_service_spec.rb

@ -333,7 +333,7 @@ RSpec.describe Exports::SalesLogExportService do
end
end
context "when exporting only 24/25 collection period" do
context "when exporting only 24/25 collection period", metadata: { year: 24 } do
let(:start_time) { Time.zone.local(2024, 4, 3) }
before do
@ -365,6 +365,70 @@ RSpec.describe Exports::SalesLogExportService do
end
end
context "when exporting only 25/26 collection period", metadata: { year: 25 } do
let(:start_time) { collection_start_date_for_year(2025) }
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
end
after do
Timecop.unfreeze
Singleton.__init__(FormHandler)
end
context "and one sales log is available for export" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export) }
let(:expected_zip_filename) { "core_sales_2025_2026_apr_mar_f0001_inc0001.zip" }
let(:expected_data_filename) { "core_sales_2025_2026_apr_mar_f0001_inc0001_pt001.xml" }
let(:xml_export_file) { File.open("spec/fixtures/exports/sales_log_25_26.xml", "r:UTF-8") }
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to have_same_xml_contents_as(expected_content)
end
export_service.export_xml_sales_logs(full_update: true, collection_year: 2025)
end
end
end
context "when exporting only 26/27 collection period", metadata: { year: 26 } do
let(:start_time) { collection_start_date_for_year(2026) }
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
end
after do
Timecop.unfreeze
Singleton.__init__(FormHandler)
end
context "and one sales log is available for export" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export) }
let(:expected_zip_filename) { "core_sales_2026_2027_apr_mar_f0001_inc0001.zip" }
let(:expected_data_filename) { "core_sales_2026_2027_apr_mar_f0001_inc0001_pt001.xml" }
let(:xml_export_file) { File.open("spec/fixtures/exports/sales_log_26_27.xml", "r:UTF-8") }
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to have_same_xml_contents_as(expected_content)
end
export_service.export_xml_sales_logs(full_update: true, collection_year: 2026)
end
end
end
context "when exporting various fees, correctly maps the values" do
context "with discounted ownership and mscharge" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, mscharge: 123) }

6
yarn.lock

@ -3434,9 +3434,9 @@ mini-css-extract-plugin@^2.6.0:
tapable "^2.2.1"
minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
version "3.1.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
dependencies:
brace-expansion "^1.1.7"

Loading…
Cancel
Save