Browse Source

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

pull/3202/head
Nat Dean-Lewis 6 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.wchair,
log.age1, log.age1,
log.sex1, log.sexrab1,
log.ethnic, # 30 log.ethnic, # 30
log.nationality_all_group, log.nationality_all_group,
log.ecstat1, log.ecstat1,
log.buy1livein, log.buy1livein,
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat2], { "P" => 1, "X" => 2, "R" => 3 }[log.relat2],
log.age2, log.age2,
log.sex2, log.sexrab2,
log.ethnic_group2, log.ethnic_group2,
log.nationality_all_buyer2_group, log.nationality_all_buyer2_group,
log.ecstat2, log.ecstat2,
@ -581,19 +581,19 @@ class BulkUpload::SalesLogToCsv
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat3], { "P" => 1, "X" => 2, "R" => 3 }[log.relat3],
log.age3, log.age3,
log.sex3, log.sexrab3,
log.ecstat3, log.ecstat3,
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat4], { "P" => 1, "X" => 2, "R" => 3 }[log.relat4],
log.age4, log.age4,
log.sex4, log.sexrab4,
log.ecstat4, log.ecstat4,
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat5], # 50 { "P" => 1, "X" => 2, "R" => 3 }[log.relat5], # 50
log.age5, log.age5,
log.sex5, log.sexrab5,
log.ecstat5, log.ecstat5,
{ "P" => 1, "X" => 2, "R" => 3 }[log.relat6], { "P" => 1, "X" => 2, "R" => 3 }[log.relat6],
log.age6, log.age6,
log.sex6, log.sexrab6,
log.ecstat6, log.ecstat6,
log.prevten, log.prevten,
@ -665,12 +665,7 @@ class BulkUpload::SalesLogToCsv
log.extrabor, log.extrabor,
log.deposit, # 120 log.deposit, # 120
log.mscharge, log.mscharge,
log.sexrab1, log.buildheightclass, # 122
log.sexrab2,
log.sexrab3,
log.sexrab4,
log.sexrab5,
log.sexrab6, # 127
] ]
end 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 def questions
@questions ||= [ @questions ||= [
Form::Sales::Questions::HasServiceCharge.new(nil, nil, self), Form::Sales::Questions::HasServiceCharge.new(nil, nil, self, staircasing: false),
Form::Sales::Questions::ServiceCharge.new(nil, nil, self), Form::Sales::Questions::ServiceCharge.new(nil, nil, self, staircasing: false),
] ]
end end
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 class Form::Sales::Questions::HasServiceCharge < ::Form::Question
def initialize(id, hsh, subsection) def initialize(id, hsh, subsection, staircasing:)
super super(id, hsh, subsection)
@id = "has_mscharge" @id = "has_mscharge"
@type = "radio" @type = "radio"
@answer_options = ANSWER_OPTIONS @answer_options = ANSWER_OPTIONS
@ -15,7 +15,8 @@ class Form::Sales::Questions::HasServiceCharge < ::Form::Question
], ],
} }
@copy_key = "sales.sale_information.servicecharges.has_servicecharge" @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 end
ANSWER_OPTIONS = { ANSWER_OPTIONS = {
@ -23,5 +24,11 @@ class Form::Sales::Questions::HasServiceCharge < ::Form::Question
"0" => { "value" => "No" }, "0" => { "value" => "No" },
}.freeze }.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 end

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

@ -1,16 +1,24 @@
class Form::Sales::Questions::ServiceCharge < ::Form::Question class Form::Sales::Questions::ServiceCharge < ::Form::Question
def initialize(id, hsh, subsection) def initialize(id, hsh, subsection, staircasing:)
super super(id, hsh, subsection)
@id = "mscharge" @id = "mscharge"
@type = "numeric" @type = "numeric"
@min = 1 @min = 1
@max = 9999.99
@step = 0.01 @step = 0.01
@width = 5 @width = 5
@prefix = "£" @prefix = "£"
@copy_key = "sales.sale_information.servicecharges.servicecharge" @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 @strip_commas = true
end 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 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_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::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::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::Buyer1EthnicGroup.new(nil, nil, self),
Form::Sales::Pages::Buyer1EthnicBackgroundBlack.new(nil, nil, self), Form::Sales::Pages::Buyer1EthnicBackgroundBlack.new(nil, nil, self),
Form::Sales::Pages::Buyer1EthnicBackgroundAsian.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::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::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::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, buyer_2_ethnicity_nationality_pages,
Form::Sales::Pages::Buyer2WorkingSituation.new(nil, nil, self), 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), 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::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", 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::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), (2..6).flat_map { |index| person_pages(index) },
(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?),
].flatten.compact ].flatten.compact
end 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 def buyer_2_ethnicity_nationality_pages
if form.start_date.year >= 2023 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 ||= [ @pages ||= [
(uprn_questions if form.start_date.year >= 2024), (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::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::PropertyNumberOfBedrooms.new(nil, nil, self),
Form::Sales::Pages::AboutPriceValueCheck.new("about_price_bedrooms_value_check", 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?), (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::Mortgageused.new("staircase_mortgage_used_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::MonthlyRentStaircasingOwned.new(nil, nil, self), Form::Sales::Pages::MonthlyRentStaircasingOwned.new(nil, nil, self),
Form::Sales::Pages::MonthlyRentStaircasing.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), Form::Sales::Pages::MonthlyChargesValueCheck.new("monthly_charges_shared_ownership_value_check", nil, self),
].compact ].compact
end end

2
app/models/validations/financial_validations.rb

@ -228,7 +228,7 @@ private
end end
if record.weekly_value(record["brent"]) > rent_range.hard_max 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 :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 :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") 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) def add_range_error(record, question)
field = question.check_answer_label || question.id field = question.check_answer_label || question.id
min = [question.prefix, number_with_delimiter(question.min, delimiter: ","), question.suffix].join("") if question.min 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].join("") if question.max max = [question.prefix, number_with_delimiter(question.max, delimiter: ","), question.suffix_label(record)].join("") if question.max
if min && 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:) 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 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 :bulk_upload
attribute :block_log_creation, :boolean, default: -> { false } attribute :block_log_creation, :boolean, default: -> { false }
@ -459,6 +479,8 @@ class BulkUpload::Lettings::Year2025::RowParser
return @valid = true if blank_row? return @valid = true if blank_row?
normalise_case_insensitive_fields
super(:before_log) super(:before_log)
@before_errors = errors.dup @before_errors = errors.dup
@ -560,6 +582,13 @@ class BulkUpload::Lettings::Year2025::RowParser
private 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 def validate_valid_radio_option
log.attributes.each_key do |question_id| log.attributes.each_key do |question_id|
question = log.form.get_question(question_id, log) 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 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 :bulk_upload
attribute :block_log_creation, :boolean, default: -> { false } attribute :block_log_creation, :boolean, default: -> { false }
@ -494,6 +514,8 @@ class BulkUpload::Lettings::Year2026::RowParser
return @valid = true if blank_row? return @valid = true if blank_row?
normalise_case_insensitive_fields
super(:before_log) super(:before_log)
@before_errors = errors.dup @before_errors = errors.dup
@ -600,6 +622,13 @@ class BulkUpload::Lettings::Year2026::RowParser
private 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 def validate_valid_radio_option
log.attributes.each_key do |question_id| log.attributes.each_key do |question_id|
question = log.form.get_question(question_id, log) 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 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 :bulk_upload
attribute :block_log_creation, :boolean, default: -> { false } attribute :block_log_creation, :boolean, default: -> { false }
@ -454,6 +476,8 @@ class BulkUpload::Sales::Year2025::RowParser
return true if blank_row? return true if blank_row?
normalise_case_insensitive_fields
super(:before_log) super(:before_log)
@before_errors = errors.dup @before_errors = errors.dup
@ -525,6 +549,13 @@ class BulkUpload::Sales::Year2025::RowParser
private 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 def prevtenbuy2
case field_64 case field_64
when "R" when "R"

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

@ -4,7 +4,7 @@ class BulkUpload::Sales::Year2026::CsvParser
include CollectionTimeHelper include CollectionTimeHelper
# TODO: CLDC-4162: Update when 2026 format is known # TODO: CLDC-4162: Update when 2026 format is known
FIELDS = 127 FIELDS = 122
FORM_YEAR = 2026 FORM_YEAR = 2026
attr_reader :path attr_reader :path
@ -27,7 +27,7 @@ class BulkUpload::Sales::Year2026::CsvParser
def cols def cols
# TODO: CLDC-4162: Update when 2026 format is known # TODO: CLDC-4162: Update when 2026 format is known
@cols ||= ("A".."DR").to_a @cols ||= ("A".."DP").to_a
end end
def row_parsers 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_27: "Is the property built or adapted to wheelchair user standards?",
field_28: "Age of buyer 1", 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_30: "What is buyer 1’s ethnic group?",
field_31: "What is buyer 1’s nationality?", field_31: "What is buyer 1’s nationality?",
field_32: "Working situation of buyer 1", field_32: "Working situation of buyer 1",
field_33: "Will buyer 1 live in the property?", field_33: "Will buyer 1 live in the property?",
field_34: "Is buyer 2 or person 2 the partner of buyer 1?", field_34: "Is buyer 2 or person 2 the partner of buyer 1?",
field_35: "Age of person 2", 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_37: "Which of the following best describes buyer 2’s ethnic background?",
field_38: "What is buyer 2’s nationality?", field_38: "What is buyer 2’s nationality?",
field_39: "What is buyer 2 or person 2’s working situation?", 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_42: "Is person 3 the partner of buyer 1?",
field_43: "Age of person 3", 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_45: "Working situation of person 3",
field_46: "Is person 4 the partner of buyer 1?", field_46: "Is person 4 the partner of buyer 1?",
field_47: "Age of person 4", 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_49: "Working situation of person 4",
field_50: "Is person 5 the partner of buyer 1?", field_50: "Is person 5 the partner of buyer 1?",
field_51: "Age of person 5", 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_53: "Working situation of person 5",
field_54: "Is person 6 the partner of buyer 1?", field_54: "Is person 6 the partner of buyer 1?",
field_55: "Age of person 6", 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_57: "Working situation of person 6",
field_58: "What was buyer 1’s previous tenure?", 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_120: "How much was the cash deposit paid on the property?",
field_121: "What are the total monthly leasehold charges for 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_122: "What is the building height classification?",
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",
}.freeze }.freeze
ERROR_BASE_KEY = "validations.sales.2026.bulk_upload".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 :bulk_upload
attribute :block_log_creation, :boolean, default: -> { false } attribute :block_log_creation, :boolean, default: -> { false }
@ -281,13 +298,7 @@ class BulkUpload::Sales::Year2026::RowParser
attribute :field_119, :integer attribute :field_119, :integer
attribute :field_120, :decimal attribute :field_120, :decimal
attribute :field_121, :decimal attribute :field_121, :decimal
attribute :field_122, :integer
attribute :field_122, :string
attribute :field_123, :string
attribute :field_124, :string
attribute :field_125, :string
attribute :field_126, :string
attribute :field_127, :string
validates :field_1, validates :field_1,
presence: { presence: {
@ -468,6 +479,8 @@ class BulkUpload::Sales::Year2026::RowParser
return true if blank_row? return true if blank_row?
normalise_case_insensitive_fields
super(:before_log) super(:before_log)
@before_errors = errors.dup @before_errors = errors.dup
@ -526,8 +539,7 @@ class BulkUpload::Sales::Year2026::RowParser
"field_21", # postcode "field_21", # postcode
"field_22", # postcode "field_22", # postcode
"field_28", # age1 "field_28", # age1
"field_29", # sex1 "field_29", # sexrab1
"field_122", # sexrab1
"field_32", # ecstat1 "field_32", # ecstat1
) )
end end
@ -540,6 +552,13 @@ class BulkUpload::Sales::Year2026::RowParser
private 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 def prevtenbuy2
case field_64 case field_64
when "R" when "R"
@ -676,13 +695,6 @@ private
age5: %i[field_51], age5: %i[field_51],
age6_known: %i[field_55], age6_known: %i[field_55],
age6: %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], relat2: %i[field_34],
relat3: %i[field_42], relat3: %i[field_42],
relat4: %i[field_46], relat4: %i[field_46],
@ -795,12 +807,13 @@ private
lasttransaction: %i[field_104 field_105 field_106], lasttransaction: %i[field_104 field_105 field_106],
initialpurchase: %i[field_100 field_101 field_102], initialpurchase: %i[field_100 field_101 field_102],
sexrab1: %i[field_122], sexrab1: %i[field_29],
sexrab2: %i[field_123], sexrab2: %i[field_36],
sexrab3: %i[field_124], sexrab3: %i[field_44],
sexrab4: %i[field_125], sexrab4: %i[field_48],
sexrab5: %i[field_126], sexrab5: %i[field_52],
sexrab6: %i[field_127], sexrab6: %i[field_56],
buildheightclass: %i[field_122],
} }
end end
@ -829,19 +842,13 @@ private
attributes["age6_known"] = age6_known? 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["age6"] = field_55 if attributes["age6_known"]&.zero? && field_55&.match(/\A\d{1,3}\z|\AR\z/)
attributes["sex1"] = field_29 attributes["sexrab1"] = field_29
attributes["sex2"] = field_36 attributes["sexrab2"] = field_36
attributes["sex3"] = field_44 attributes["sexrab3"] = field_44
attributes["sex4"] = field_48 attributes["sexrab4"] = field_48
attributes["sex5"] = field_52 attributes["sexrab5"] = field_52
attributes["sex6"] = field_56 attributes["sexrab6"] = field_56
attributes["buildheightclass"] = field_122
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["relat2"] = relationship_from_is_partner(field_34) attributes["relat2"] = relationship_from_is_partner(field_34)
attributes["relat3"] = relationship_from_is_partner(field_42) attributes["relat3"] = relationship_from_is_partner(field_42)
@ -1042,23 +1049,23 @@ private
end end
def person_2_present? 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 end
def person_3_present? 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 end
def person_4_present? 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 end
def person_5_present? 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 end
def person_6_present? 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 end
def relationship_from_is_partner(is_partner) def relationship_from_is_partner(is_partner)
@ -1281,7 +1288,6 @@ private
saledate saledate
age1 age1
sexrab1 sexrab1
sex1
ecstat1 ecstat1
owning_organisation owning_organisation
postcode_full postcode_full
@ -1458,8 +1464,7 @@ private
errors.add(:field_21, error_message) # Postcode errors.add(:field_21, error_message) # Postcode
errors.add(:field_22, error_message) # Postcode errors.add(:field_22, error_message) # Postcode
errors.add(:field_28, error_message) # Buyer 1 age errors.add(:field_28, error_message) # Buyer 1 age
errors.add(:field_29, error_message) # Buyer 1 gender errors.add(:field_29, error_message) # Buyer 1 sex registered at birth
errors.add(:field_122, error_message) # Buyer 1 sex registered at birth
errors.add(:field_32, error_message) # Buyer 1 working situation errors.add(:field_32, error_message) # Buyer 1 working situation
errors.add(:field_7, error_message) # Purchaser code errors.add(:field_7, error_message) # Purchaser code
end end

11
app/services/csv/sales_log_csv_service.rb

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

15
app/services/exports/sales_log_export_constants.rb

@ -139,14 +139,25 @@ module Exports::SalesLogExportConstants
(1..6).each do |index| (1..6).each do |index|
ALL_YEAR_EXPORT_FIELDS << "AGE#{index}" ALL_YEAR_EXPORT_FIELDS << "AGE#{index}"
ALL_YEAR_EXPORT_FIELDS << "ECSTAT#{index}" ALL_YEAR_EXPORT_FIELDS << "ECSTAT#{index}"
ALL_YEAR_EXPORT_FIELDS << "SEX#{index}"
end end
(2..6).each do |index| (2..6).each do |index|
ALL_YEAR_EXPORT_FIELDS << "RELAT#{index}" ALL_YEAR_EXPORT_FIELDS << "RELAT#{index}"
end 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| (1..6).each do |index|
YEAR_2026_EXPORT_FIELDS << "SEXRAB#{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) included_fields.merge(ALL_YEAR_EXPORT_FIELDS)
year_fields = case sales_log.collection_start_year year_fields = case sales_log.collection_start_year
when 2024
YEAR_2024_EXPORT_FIELDS
when 2025
YEAR_2025_EXPORT_FIELDS
when 2026 when 2026
YEAR_2026_EXPORT_FIELDS YEAR_2026_EXPORT_FIELDS
else else

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

@ -76,6 +76,8 @@
<% end %> <% end %>
<% if @pages_with_errors_count > 1 %> <% 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"> <div class="govuk-button-group">
<%= f.submit "See all related answers", name: "check_errors", class: "govuk-body govuk-link submit-button-link" %> <%= f.submit "See all related answers", name: "check_errors", class: "govuk-body govuk-link submit-button-link" %>
</div> </div>

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

@ -44,13 +44,6 @@ en:
hint_text: "" hint_text: ""
question_text: "Enter gender identity" 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: ethnic_group:
page_header: "" page_header: ""
check_answer_label: "Lead tenant’s ethnic group" check_answer_label: "Lead tenant’s ethnic group"
@ -158,13 +151,6 @@ en:
hint_text: "" hint_text: ""
question_text: "Enter gender identity" 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: ecstat2:
page_header: "" page_header: ""
check_answer_label: "Person 2’s working situation" check_answer_label: "Person 2’s working situation"
@ -220,13 +206,6 @@ en:
hint_text: "" hint_text: ""
question_text: "Enter gender identity" 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: ecstat3:
page_header: "" page_header: ""
check_answer_label: "Person 3’s working situation" check_answer_label: "Person 3’s working situation"
@ -282,13 +261,6 @@ en:
hint_text: "" hint_text: ""
question_text: "Enter gender identity" 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: ecstat4:
page_header: "" page_header: ""
check_answer_label: "Person 4’s working situation" check_answer_label: "Person 4’s working situation"
@ -344,13 +316,6 @@ en:
hint_text: "" hint_text: ""
question_text: "Enter gender identity" 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: ecstat5:
page_header: "" page_header: ""
check_answer_label: "Person 5’s working situation" check_answer_label: "Person 5’s working situation"
@ -406,13 +371,6 @@ en:
hint_text: "" hint_text: ""
question_text: "Enter gender identity" 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: ecstat6:
page_header: "" page_header: ""
check_answer_label: "Person 6’s working situation" check_answer_label: "Person 6’s working situation"
@ -468,13 +426,6 @@ en:
hint_text: "" hint_text: ""
question_text: "Enter gender identity" 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: ecstat7:
page_header: "" page_header: ""
check_answer_label: "Person 7’s working situation" check_answer_label: "Person 7’s working situation"
@ -530,13 +481,6 @@ en:
hint_text: "" hint_text: ""
question_text: "Enter gender identity" 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: ecstat8:
page_header: "" page_header: ""
check_answer_label: "Person 8’s working situation" 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." 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?" 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: ethnic_group:
page_header: "" page_header: ""
check_answer_label: "Buyer 1’s ethnic group" 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." 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?" 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: ethnic_group2:
page_header: "" page_header: ""
check_answer_label: "Buyer 2’s ethnic group" 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." 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?" 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: ecstat3:
page_header: "" page_header: ""
check_answer_label: "Person 3’s working situation" 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." 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?" 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: ecstat4:
page_header: "" page_header: ""
check_answer_label: "Person 4’s working situation" 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." 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?" 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: ecstat5:
page_header: "" page_header: ""
check_answer_label: "Person 5’s working situation" 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." 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?" 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: ecstat6:
page_header: "" page_header: ""
check_answer_label: "Person 6’s working situation" 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: "" hint_text: ""
question_text: "What type of unit is the property?" 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: builtype:
page_header: "" page_header: ""
check_answer_label: "Type of building" 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" 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.boolean "manual_address_entry_selected", default: false
t.integer "referral_type" t.integer "referral_type"
t.integer "working_situation_illness_check" t.integer "working_situation_illness_check"
t.integer "referral_register"
t.integer "referral_noms"
t.integer "referral_org"
t.string "sexrab1" t.string "sexrab1"
t.string "sexrab2" t.string "sexrab2"
t.string "sexrab3" 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_description6"
t.string "gender_description7" t.string "gender_description7"
t.string "gender_description8" t.string "gender_description8"
t.integer "referral_register"
t.integer "referral_noms"
t.integer "referral_org"
t.integer "tenancyother_value_check" t.integer "tenancyother_value_check"
t.index ["assigned_to_id"], name: "index_lettings_logs_on_assigned_to_id" 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 ["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 ["created_by_id"], name: "index_lettings_logs_on_created_by_id"
t.index ["location_id"], name: "index_lettings_logs_on_location_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 ["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 ["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 ["owning_organisation_id"], name: "index_lettings_logs_on_owning_organisation_id"
t.index ["scheme_id"], name: "index_lettings_logs_on_scheme_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" 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 "sexrab4"
t.string "sexrab5" t.string "sexrab5"
t.string "sexrab6" t.string "sexrab6"
t.integer "buildheightclass"
t.index ["assigned_to_id"], name: "index_sales_logs_on_assigned_to_id" 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 ["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 ["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 ["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 ["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 ["owning_organisation_id"], name: "index_sales_logs_on_owning_organisation_id"
t.index ["updated_by_id"], name: "index_sales_logs_on_updated_by_id" t.index ["updated_by_id"], name: "index_sales_logs_on_updated_by_id"
end end

8
spec/factories/sales_log.rb

@ -90,6 +90,7 @@ FactoryBot.define do
buy1livein { 1 } buy1livein { 1 }
relat2 { "P" } relat2 { "P" }
proptype { 1 } proptype { 1 }
buildheightclass { 2 }
age2_known { 0 } age2_known { 0 }
age2 { Faker::Number.within(range: 25..45) } age2 { Faker::Number.within(range: 25..45) }
builtype { 1 } builtype { 1 }
@ -293,12 +294,13 @@ FactoryBot.define do
buy1livein { 1 } buy1livein { 1 }
relat2 { "P" } relat2 { "P" }
proptype { 1 } proptype { 1 }
buildheightclass { 2 }
age2_known { 0 } age2_known { 0 }
age2 { 33 } age2 { 33 }
builtype { 1 } builtype { 1 }
ethnic { 3 } ethnic { 3 }
ethnic_group { 17 } ethnic_group { 17 }
sexrab2 { "X" } sexrab2 { "R" }
sex2 { "X" } sex2 { "X" }
buy2livein { "1" } buy2livein { "1" }
ecstat1 { "1" } ecstat1 { "1" }
@ -333,11 +335,11 @@ FactoryBot.define do
prevshared { 2 } prevshared { 2 }
sexrab3 { "F" } sexrab3 { "F" }
sex3 { "F" } sex3 { "F" }
sexrab4 { "X" } sexrab4 { "R" }
sex4 { "X" } sex4 { "X" }
sexrab5 { "M" } sexrab5 { "M" }
sex5 { "M" } sex5 { "M" }
sexrab6 { "X" } sexrab6 { "R" }
sex6 { "X" } sex6 { "X" }
mortgage { 20_000 } mortgage { 20_000 }
ecstat3 { 9 } 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? sexrab4,What was person 4's sex at birth?
sexrab5,What was person 5's sex at birth? sexrab5,What was person 5's sex at birth?
sexrab6,What was person 6'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"] } subject(:task) { Rake::Task["data_import:add_variable_definitions"] }
let(:path) { "spec/fixtures/variable_definitions" } let(:path) { "spec/fixtures/variable_definitions" }
let(:total_variable_definitions_count) { 450 } let(:total_variable_definitions_count) { 451 }
before do before do
Rake.application.rake_require("tasks/log_variable_definitions") 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" require "rails_helper"
RSpec.describe Form::Sales::Questions::HasServiceCharge, type: :model do 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_id) { nil }
let(:question_definition) { 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 it "has correct page" do
expect(question.page).to eq(page) expect(question.page).to eq(page)
@ -46,4 +48,44 @@ RSpec.describe Form::Sales::Questions::HasServiceCharge, type: :model do
], ],
}) })
end 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 end

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

@ -1,11 +1,14 @@
require "rails_helper" require "rails_helper"
RSpec.describe Form::Sales::Questions::ServiceCharge, type: :model do 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_id) { nil }
let(:question_definition) { 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 it "has correct page" do
expect(question.page).to eq(page) 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 it "has the correct prefix" do
expect(question.prefix).to eq("£") expect(question.prefix).to eq("£")
end 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 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_joint_purchase_value_check
age_1_old_persons_shared_ownership_value_check age_1_old_persons_shared_ownership_value_check
buyer_1_sex_registered_at_birth buyer_1_sex_registered_at_birth
buyer_1_gender_identity
buyer_1_ethnic_group buyer_1_ethnic_group
buyer_1_ethnic_background_black buyer_1_ethnic_background_black
buyer_1_ethnic_background_asian 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_retirement_value_check
age_2_buyer_not_retired_value_check age_2_buyer_not_retired_value_check
buyer_2_sex_registered_at_birth buyer_2_sex_registered_at_birth
buyer_2_gender_identity
buyer_2_ethnic_group buyer_2_ethnic_group
buyer_2_ethnic_background_black buyer_2_ethnic_background_black
buyer_2_ethnic_background_asian 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_not_retired_value_check
age_2_partner_under_16_value_check age_2_partner_under_16_value_check
person_2_sex_registered_at_birth person_2_sex_registered_at_birth
person_2_gender_identity
person_2_working_situation person_2_working_situation
working_situation_2_retirement_value_check working_situation_2_retirement_value_check
working_situation_2_not_retired_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_not_retired_value_check
age_3_partner_under_16_value_check age_3_partner_under_16_value_check
person_3_sex_registered_at_birth person_3_sex_registered_at_birth
person_3_gender_identity
person_3_working_situation person_3_working_situation
working_situation_3_retirement_value_check working_situation_3_retirement_value_check
working_situation_3_not_retired_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_not_retired_value_check
age_4_partner_under_16_value_check age_4_partner_under_16_value_check
person_4_sex_registered_at_birth person_4_sex_registered_at_birth
person_4_gender_identity
person_4_working_situation person_4_working_situation
working_situation_4_retirement_value_check working_situation_4_retirement_value_check
working_situation_4_not_retired_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_not_retired_value_check
age_5_partner_under_16_value_check age_5_partner_under_16_value_check
person_5_sex_registered_at_birth person_5_sex_registered_at_birth
person_5_gender_identity
person_5_working_situation person_5_working_situation
working_situation_5_retirement_value_check working_situation_5_retirement_value_check
working_situation_5_not_retired_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_not_retired_value_check
age_6_partner_under_16_value_check age_6_partner_under_16_value_check
person_6_sex_registered_at_birth person_6_sex_registered_at_birth
person_6_gender_identity
person_6_working_situation person_6_working_situation
working_situation_6_retirement_value_check working_situation_6_retirement_value_check
working_situation_6_not_retired_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" require "rails_helper"
RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do
include CollectionTimeHelper
subject(:property_information) { described_class.new(nil, nil, section) } subject(:property_information) { described_class.new(nil, nil, section) }
let(:section) { instance_double(Form::Sales::Sections::PropertyInformation) } let(:section) { instance_double(Form::Sales::Sections::PropertyInformation) }
@ -11,14 +13,16 @@ RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do
describe "pages" do describe "pages" do
let(:section) { instance_double(Form::Sales::Sections::Household, form:) } let(:section) { instance_double(Form::Sales::Sections::Household, form:) }
let(:form) { instance_double(Form, start_date:) } let(:start_year_2024_or_later?) { true }
let(:start_year_2025_or_later?) { true }
before do let(:start_year_2026_or_later?) { true }
allow(form).to receive_messages(start_year_2024_or_later?: false, start_year_2025_or_later?: false) 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?) }
end
context "when 2023" do 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 it "has correct pages" do
expect(property_information.pages.map(&:id)).to eq( expect(property_information.pages.map(&:id)).to eq(
@ -44,11 +48,9 @@ RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do
end end
context "when 2024" do context "when 2024" do
let(:start_date) { Time.utc(2024, 2, 8) } let(:start_date) { collection_start_date_for_year(2024) }
let(:start_year_2025_or_later?) { false }
before do let(:start_year_2026_or_later?) { false }
allow(form).to receive_messages(start_year_2024_or_later?: true, start_year_2025_or_later?: false)
end
it "has correct pages" do it "has correct pages" do
expect(property_information.pages.map(&:id)).to eq( expect(property_information.pages.map(&:id)).to eq(
@ -73,11 +75,33 @@ RSpec.describe Form::Sales::Subsections::PropertyInformation, type: :model do
end end
context "when 2025" do 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 it "has correct pages" do
allow(form).to receive_messages(start_year_2024_or_later?: true, start_year_2025_or_later?: true) 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
end
context "when 2026" do
let(:start_date) { collection_start_date_for_year(2026) }
it "has correct pages" do it "has correct pages" do
expect(property_information.pages.map(&:id)).to eq( 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 local_authority_combined_income_max_value_check
about_price_la_value_check about_price_la_value_check
property_unit_type property_unit_type
building_height_class
property_number_of_bedrooms property_number_of_bedrooms
about_price_bedrooms_value_check about_price_bedrooms_value_check
monthly_charges_property_type_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 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 end
context "when valid row with valid decimal (integer) field_11" do 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 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 end
context "when valid row with valid decimal (integer) field_11" do 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).size).to eq(0)
expect(questions.map(&:id)).to eql([]) expect(questions.map(&:id)).to eql([])
end 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 end
describe "#validate_nulls" do 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_105: "07",
field_106: "2023", field_106: "2023",
field_110: "900", field_110: "900",
field_122: "F", field_122: "1",
field_123: "F",
field_124: "M",
field_125: "M",
field_126: "R",
field_127: "R",
} }
end end
@ -298,6 +293,20 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
expect(questions.map(&:id).size).to eq(0) expect(questions.map(&:id).size).to eq(0)
expect(questions.map(&:id)).to eql([]) expect(questions.map(&:id)).to eql([])
end 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 end
describe "#validate_nulls" do 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 it "fetches the question's check_answer_label if it exists" do
parser.valid? 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
end end
@ -779,7 +788,7 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
:field_21, # Postcode :field_21, # Postcode
:field_22, # Postcode :field_22, # Postcode
:field_28, # Buyer 1 age :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_32, # Buyer 1 working situation
:field_7, # Purchaser code :field_7, # Purchaser code
].each do |field| ].each do |field|
@ -809,7 +818,7 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
:field_21, # Postcode :field_21, # Postcode
:field_22, # Postcode :field_22, # Postcode
:field_28, # Buyer 1 age :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_32, # Buyer 1 working situation
:field_7, # Purchaser code :field_7, # Purchaser code
].each do |field| ].each do |field|

129
spec/services/csv/sales_log_csv_service_spec.rb

@ -1,6 +1,8 @@
require "rails_helper" require "rails_helper"
RSpec.describe Csv::SalesLogCsvService do RSpec.describe Csv::SalesLogCsvService do
include CollectionTimeHelper
subject(:task) { Rake::Task["data_import:add_variable_definitions"] } subject(:task) { Rake::Task["data_import:add_variable_definitions"] }
let(:form_handler_mock) { instance_double(FormHandler) } let(:form_handler_mock) { instance_double(FormHandler) }
@ -21,17 +23,22 @@ RSpec.describe Csv::SalesLogCsvService do
purchid: nil, purchid: nil,
hholdcount: 3, hholdcount: 3,
age1: 30, age1: 30,
sexrab1: "F",
sex1: "X", sex1: "X",
age2: 35, age2: 35,
sexrab2: "M",
sex2: "X", sex2: "X",
sexrab3: "F",
sex3: "X", sex3: "X",
age4_known: 1, age4_known: 1,
sexrab4: "R",
sex4: "X", sex4: "X",
details_known_5: 2, details_known_5: 2,
age6_known: nil, age6_known: nil,
age6: nil, age6: nil,
ecstat6: nil, ecstat6: nil,
relat6: nil, relat6: nil,
sexrab6: nil,
sex6: nil, sex6: nil,
town_or_city: "Town or city", town_or_city: "Town or city",
address_line1_as_entered: "address line 1 as entered", 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" expect(la_label_value).to eq "Westminster"
end end
context "when the requested form is 2024" do context "when the requested form is 2024", metadata: { year: 24 } do
let(:now) { Time.zone.local(2024, 5, 1) } let(:now) { collection_start_date_for_year(2024) }
let(: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 before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1) 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
end end
context "when the requested form is 2025" do context "when the requested form is 2025", metadata: { year: 25 } do
let(:now) { Time.zone.local(2025, 5, 1) } let(:now) { collection_start_date_for_year(2025) }
let(: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 before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1) 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
end end
context "when the requested form is 2023" do context "when the requested form is 2026", metadata: { year: 26 } do
let(:now) { Time.zone.local(2024, 1, 1) } let(:now) { collection_start_date_for_year(2026) }
let(:year) { 2023 } 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 before do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_labels_23.csv") log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
values_to_delete = %w[ID] 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| values_to_delete.each do |attribute|
index = attribute_line.index(attribute) index = attribute_line.index(attribute)
content_line[index] = nil content_line[index] = nil
@ -300,53 +312,58 @@ RSpec.describe Csv::SalesLogCsvService do
expect(la_label_value).to eq "Westminster" expect(la_label_value).to eq "Westminster"
end end
context "when the requested form is 2025" do context "when the requested form is 2024", metadata: { year: 24 } do
let(:now) { Time.zone.local(2025, 5, 1) } let(:now) { collection_start_date_for_year(2024) }
let(:fixed_time) { Time.zone.local(2025, 5, 1) } let(:fixed_time) { collection_start_date_for_year(2024) }
let(:year) { 2025 } let(:year) { 2024 }
before do before do
log.update!(manual_address_entry_selected: false, uprn: "1", uprn_known: 1) log.update!(manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
end end
it "exports the CSV with all values correct" do it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_25.csv") expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_24.csv")
values_to_delete = %w[ID OWNINGORGID MANINGORGID CREATEDBYID USERNAMEID AMENDEDBYID] values_to_delete = %w[ID]
values_to_delete.each do |attribute| values_to_delete.each do |attribute|
index = attribute_line.index(attribute) index = attribute_line.index(attribute)
content_line[index] = nil content_line[index] = nil
end 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
end end
context "when the requested form is 2024" do context "when the requested form is 2025", metadata: { year: 25 } do
let(:now) { Time.zone.local(2024, 5, 1) } let(:now) { collection_start_date_for_year(2025) }
let(:fixed_time) { Time.zone.local(2024, 5, 1) } let(:fixed_time) { collection_start_date_for_year(2025) }
let(:year) { 2024 } let(:year) { 2025 }
before do before do
log.update!(manual_address_entry_selected: false, uprn: "1", uprn_known: 1) log.update!(manual_address_entry_selected: false, uprn: "1", uprn_known: 1)
end end
it "exports the CSV with all values correct" do it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_24.csv") expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_25.csv")
values_to_delete = %w[ID] values_to_delete = %w[ID OWNINGORGID MANINGORGID CREATEDBYID USERNAMEID AMENDEDBYID]
values_to_delete.each do |attribute| values_to_delete.each do |attribute|
index = attribute_line.index(attribute) index = attribute_line.index(attribute)
content_line[index] = nil content_line[index] = nil
end 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
end end
context "when the requested form is 2023" do context "when the requested form is 2026", metadata: { year: 26 } do
let(:now) { Time.zone.local(2024, 1, 1) } let(:now) { collection_start_date_for_year(2026) }
let(:year) { 2023 } 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 it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_23.csv") expected_content = CSV.read("spec/fixtures/files/sales_logs_csv_export_codes_26.csv")
values_to_delete = %w[ID] values_to_delete = %w[ID OWNINGORGID MANINGORGID CREATEDBYID USERNAMEID AMENDEDBYID]
values_to_delete.each do |attribute| values_to_delete.each do |attribute|
index = attribute_line.index(attribute) index = attribute_line.index(attribute)
content_line[index] = nil 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]) 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 end
context "and the requested form is 2024" do context "and the requested form is 2024", metadata: { year: 24 } do
let(:year) { 2024 } let(:year) { 2024 }
let(:now) { Time.zone.local(2024, 5, 1) } let(:now) { collection_start_date_for_year(2024) }
let(:fixed_time) { Time.zone.local(2024, 5, 1) } let(:fixed_time) { collection_start_date_for_year(2024) }
before do before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1) 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
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(:year) { 2025 }
let(:now) { Time.zone.local(2025, 5, 1) } let(:now) { collection_start_date_for_year(2025) }
let(:fixed_time) { Time.zone.local(2025, 5, 1) } let(:fixed_time) { collection_start_date_for_year(2025) }
before do before do
log.update!(nationality_all: 36, manual_address_entry_selected: false, uprn: "1", uprn_known: 1) 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 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
end end

66
spec/services/exports/sales_log_export_service_spec.rb

@ -333,7 +333,7 @@ RSpec.describe Exports::SalesLogExportService do
end end
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) } let(:start_time) { Time.zone.local(2024, 4, 3) }
before do before do
@ -365,6 +365,70 @@ RSpec.describe Exports::SalesLogExportService do
end end
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 "when exporting various fees, correctly maps the values" do
context "with discounted ownership and mscharge" do context "with discounted ownership and mscharge" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, mscharge: 123) } 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" tapable "^2.2.1"
minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2" version "3.1.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
dependencies: dependencies:
brace-expansion "^1.1.7" brace-expansion "^1.1.7"

Loading…
Cancel
Save