Browse Source

Merge branch 'main' into CLDC-4325-ensure-tests-will-pass-on-go-live

# Conflicts:
#	spec/models/validations/sales/sale_information_validations_spec.rb
#	spec/requests/start_controller_spec.rb
pull/3250/head
samyou-softwire 2 weeks ago
parent
commit
64db3166d2
  1. 4
      Gemfile.lock
  2. 12
      app/models/form/lettings/questions/previous_tenure.rb
  3. 26
      app/models/form/sales/questions/buyer_still_serving.rb
  4. 2
      app/models/form/sales/questions/living_before_purchase_years.rb
  5. 6
      app/models/form/sales/questions/mortgageused.rb
  6. 4
      app/models/form/sales/questions/property_number_of_bedrooms.rb
  7. 2
      app/models/form/sales/subsections/shared_ownership_initial_purchase.rb
  8. 2
      app/models/form/sales/subsections/shared_ownership_staircasing_transaction.rb
  9. 6
      app/models/scheme.rb
  10. 2
      app/models/validations/sales/sale_information_validations.rb
  11. 9
      app/models/validations/soft_validations.rb
  12. 1
      app/services/address_client.rb
  13. 39
      app/services/bulk_upload/lettings/year2025/csv_parser.rb
  14. 17
      app/services/bulk_upload/lettings/year2025/row_parser.rb
  15. 39
      app/services/bulk_upload/lettings/year2026/csv_parser.rb
  16. 17
      app/services/bulk_upload/lettings/year2026/row_parser.rb
  17. 40
      app/services/bulk_upload/sales/year2025/csv_parser.rb
  18. 19
      app/services/bulk_upload/sales/year2025/row_parser.rb
  19. 40
      app/services/bulk_upload/sales/year2026/csv_parser.rb
  20. 21
      app/services/bulk_upload/sales/year2026/row_parser.rb
  21. 1
      app/services/uprn_client.rb
  22. 12
      app/views/schemes/support.html.erb
  23. 1
      app/views/start/guidance.html.erb
  24. 16
      config/locales/forms/2025/lettings/tenancy_information.en.yml
  25. 16
      config/locales/forms/2026/lettings/tenancy_information.en.yml
  26. 14
      config/locales/forms/2026/sales/sale_information.en.yml
  27. 4
      config/locales/forms/2026/sales/setup.en.yml
  28. 2
      config/locales/validations/sales/2026/bulk_upload.en.yml
  29. 2
      docs/Gemfile.lock
  30. 10
      lib/tasks/update_schemes_logs_impacted_by_nursing_level_removal.rake
  31. 2
      spec/factories/scheme.rb
  32. 4
      spec/fixtures/files/2026_27_sales_bulk_upload.csv
  33. 2
      spec/fixtures/files/blank_bulk_upload_sales.csv
  34. 2
      spec/fixtures/files/sales_logs_csv_export_codes_26.csv
  35. 2
      spec/fixtures/files/sales_logs_csv_export_labels_26.csv
  36. 2
      spec/fixtures/files/sales_logs_csv_export_non_support_codes_26.csv
  37. 2
      spec/fixtures/files/sales_logs_csv_export_non_support_labels_26.csv
  38. 1
      spec/fixtures/variable_definitions/sales_download_26_27.csv
  39. 2
      spec/lib/tasks/log_variable_definitions_spec.rb
  40. 2
      spec/models/form/lettings/questions/address_search_spec.rb
  41. 12
      spec/models/form/lettings/questions/previous_tenure_spec.rb
  42. 4
      spec/models/form/sales/pages/buyer_still_serving_spec.rb
  43. 2
      spec/models/form/sales/questions/address_search_spec.rb
  44. 37
      spec/models/form/sales/questions/buyer_still_serving_spec.rb
  45. 91
      spec/models/form/sales/questions/living_before_purchase_years_spec.rb
  46. 30
      spec/models/form/sales/questions/mortgageused_spec.rb
  47. 18
      spec/models/form/sales/questions/property_number_of_bedrooms_spec.rb
  48. 6
      spec/models/form/sales/subsections/shared_ownership_initial_purchase_spec.rb
  49. 4
      spec/models/form/sales/subsections/shared_ownership_staircasing_transaction_spec.rb
  50. 28
      spec/models/validations/sales/sale_information_validations_spec.rb
  51. 116
      spec/models/validations/soft_validations_spec.rb
  52. 20
      spec/request_helper.rb
  53. 20
      spec/requests/address_search_controller_spec.rb
  54. 2
      spec/services/address_client_spec.rb
  55. 19
      spec/services/bulk_upload/lettings/year2025/csv_parser_spec.rb
  56. 16
      spec/services/bulk_upload/lettings/year2025/row_parser_spec.rb
  57. 19
      spec/services/bulk_upload/lettings/year2026/csv_parser_spec.rb
  58. 16
      spec/services/bulk_upload/lettings/year2026/row_parser_spec.rb
  59. 19
      spec/services/bulk_upload/sales/year2025/csv_parser_spec.rb
  60. 16
      spec/services/bulk_upload/sales/year2025/row_parser_spec.rb
  61. 19
      spec/services/bulk_upload/sales/year2026/csv_parser_spec.rb
  62. 16
      spec/services/bulk_upload/sales/year2026/row_parser_spec.rb
  63. 2
      spec/services/exports/lettings_log_export_service_spec.rb
  64. 2
      spec/services/uprn_client_spec.rb
  65. 6
      yarn.lock

4
Gemfile.lock

@ -112,7 +112,7 @@ GEM
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
base64 (0.3.0)
bcrypt (3.1.21)
bcrypt (3.1.22)
benchmark (0.5.0)
better_html (2.2.0)
actionview (>= 7.0)
@ -246,7 +246,7 @@ GEM
jmespath (1.6.2)
jsbundling-rails (1.3.0)
railties (>= 6.0.0)
json (2.18.1)
json (2.19.2)
json-schema (4.1.1)
addressable (>= 2.8)
jwt (2.8.0)

12
app/models/form/lettings/questions/previous_tenure.rb

@ -52,22 +52,22 @@ class Form::Lettings::Questions::PreviousTenure < ::Form::Question
"35" => { "value" => "Extra care housing" },
"38" => { "value" => "Older people’s housing for tenants with low support needs" },
"6" => { "value" => "Other supported housing" },
"3" => { "value" => "Private sector tenancy" },
"27" => { "value" => "Owner occupation (low-cost home ownership)" },
"26" => { "value" => "Owner occupation (private)" },
"3" => { "value" => "Private sector tenancy" },
"28" => { "value" => "Living with friends or family (long-term)" },
"39" => { "value" => "Sofa surfing (moving regularly between family or friends, no permanent bed)" },
"14" => { "value" => "Bed and breakfast" },
"7" => { "value" => "Direct access hostel" },
"10" => { "value" => "Hospital" },
"29" => { "value" => "Prison or approved probation hostel" },
"19" => { "value" => "Rough sleeping" },
"18" => { "value" => "Any other temporary accommodation" },
"19" => { "value" => "Rough sleeping" },
"21" => { "value" => "Refuge" },
"13" => { "value" => "Children’s home or foster care" },
"24" => { "value" => "Home Office Asylum Support" },
"37" => { "value" => "Host family or similar refugee accommodation" },
"23" => { "value" => "Mobile home or caravan" },
"21" => { "value" => "Refuge" },
"9" => { "value" => "Residential care home" },
"4" => { "value" => "Tied housing or rented with job" },
"25" => { "value" => "Any other accommodation" },
@ -82,22 +82,22 @@ class Form::Lettings::Questions::PreviousTenure < ::Form::Question
"35" => { "value" => "Extra care housing" },
"38" => { "value" => "Older people’s housing for tenants with low support needs" },
"6" => { "value" => "Other supported housing" },
"3" => { "value" => "Private sector tenancy" },
"27" => { "value" => "Owner occupation (low-cost home ownership)" },
"26" => { "value" => "Owner occupation (private)" },
"3" => { "value" => "Private sector tenancy" },
"28" => { "value" => "Living with friends or family (long-term)" },
"39" => { "value" => "Sofa surfing (moving regularly between family or friends, no permanent bed)" },
"14" => { "value" => "Bed and breakfast" },
"7" => { "value" => "Direct access hostel" },
"10" => { "value" => "Hospital" },
"29" => { "value" => "Prison or approved probation hostel" },
"19" => { "value" => "Rough sleeping" },
"18" => { "value" => "Any other temporary accommodation" },
"19" => { "value" => "Rough sleeping" },
"21" => { "value" => "Refuge" },
"13" => { "value" => "Children’s home or foster care" },
"24" => { "value" => "Home Office Asylum Support" },
"37" => { "value" => "Host family or similar refugee accommodation" },
"23" => { "value" => "Mobile home or caravan" },
"21" => { "value" => "Refuge" },
"9" => { "value" => "Residential care home" },
"4" => { "value" => "Tied housing or rented with job" },
"25" => { "value" => "Any other accommodation" },

26
app/models/form/sales/questions/buyer_still_serving.rb

@ -3,17 +3,27 @@ class Form::Sales::Questions::BuyerStillServing < ::Form::Question
super
@id = "hhregresstill"
@type = "radio"
@answer_options = ANSWER_OPTIONS
@answer_options = answer_options
@question_number = get_question_number_from_hash(QUESTION_NUMBER_FROM_YEAR)
end
ANSWER_OPTIONS = {
"4" => { "value" => "Yes" },
"5" => { "value" => "No" },
"6" => { "value" => "Buyer prefers not to say" },
"divider" => { "value" => true },
"7" => { "value" => "Don’t know" },
}.freeze
def answer_options
if form.start_year_2026_or_later?
{
"4" => { "value" => "Yes" },
"5" => { "value" => "No - they left up to and including 2 years ago" },
"6" => { "value" => "No - they left more than 2 years ago" },
}.freeze
else
{
"4" => { "value" => "Yes" },
"5" => { "value" => "No" },
"6" => { "value" => "Buyer prefers not to say" },
"divider" => { "value" => true },
"7" => { "value" => "Don’t know" },
}.freeze
end
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 63, 2024 => 65, 2025 => 62, 2026 => 70 }.freeze
end

2
app/models/form/sales/questions/living_before_purchase_years.rb

@ -4,7 +4,7 @@ class Form::Sales::Questions::LivingBeforePurchaseYears < ::Form::Question
@id = "proplen"
@copy_key = "sales.sale_information.living_before_purchase.#{joint_purchase ? 'joint_purchase' : 'not_joint_purchase'}.proplen"
@type = "numeric"
@min = 0
@min = form.start_year_2026_or_later? ? 1 : 0
@max = 80
@step = 1
@width = 5

6
app/models/form/sales/questions/mortgageused.rb

@ -6,6 +6,12 @@ class Form::Sales::Questions::Mortgageused < ::Form::Question
@answer_options = ANSWER_OPTIONS
@ownershipsch = ownershipsch
@question_number = get_question_number_from_hash(QUESTION_NUMBER_FROM_YEAR_AND_SECTION, value_key: form.start_year_2025_or_later? ? subsection.id : ownershipsch)
sub_copy_key = if subsection.id == "shared_ownership_staircasing_transaction"
"staircase_equity"
else
"non_staircase_equity"
end
@copy_key = "#{form.type}.#{subsection.copy_key}.#{@id}.#{sub_copy_key}" if form.start_year_2026_or_later?
@top_guidance_partial = top_guidance_partial
end

4
app/models/form/sales/questions/property_number_of_bedrooms.rb

@ -11,5 +11,9 @@ class Form::Sales::Questions::PropertyNumberOfBedrooms < ::Form::Question
@question_number = get_question_number_from_hash(QUESTION_NUMBER_FROM_YEAR)
end
def derived?(log)
log.is_bedsit?
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 11, 2024 => 18, 2025 => 17, 2026 => 18 }.freeze
end

2
app/models/form/sales/subsections/shared_ownership_initial_purchase.rb

@ -40,7 +40,7 @@ class Form::Sales::Subsections::SharedOwnershipInitialPurchase < ::Form::Subsect
Form::Sales::Pages::SharedOwnershipDepositValueCheck.new("shared_ownership_deposit_value_check", nil, self),
Form::Sales::Pages::MonthlyRent.new(nil, nil, self),
Form::Sales::Pages::ServiceCharge.new("service_charge", nil, self),
Form::Sales::Pages::MonthlyChargesValueCheck.new("monthly_charges_shared_ownership_value_check", nil, self),
Form::Sales::Pages::MonthlyChargesValueCheck.new("monthly_charges_initial_purchase_value_check", nil, self),
Form::Sales::Pages::EstateManagementFee.new("estate_management_fee", nil, self),
].compact
end

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

@ -27,7 +27,7 @@ class Form::Sales::Subsections::SharedOwnershipStaircasingTransaction < ::Form::
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::ServiceChargeChanged.new(nil, 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_staircasing_value_check", nil, self),
].compact
end

6
app/models/scheme.rb

@ -170,7 +170,6 @@ class Scheme < ApplicationRecord
"Low level": 2,
"Medium level": 3,
"High level": 4,
"Nursing care in a care home": 5,
"Floating support": 6,
}.freeze
@ -265,7 +264,7 @@ class Scheme < ApplicationRecord
Scheme.registered_under_care_acts.keys.map { |key, _| OpenStruct.new(id: key, name: key.to_s) }
end
def support_level_options_with_hints
def self.support_level_options_with_hints
hints = {
"Low level": "Staff visiting once a week, fortnightly or less.",
"Medium level": "Staff on site daily or making frequent visits with some out-of-hours cover.",
@ -274,13 +273,12 @@ class Scheme < ApplicationRecord
Scheme.support_types.keys.excluding("Missing").excluding("Floating support").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize, description: hints[key.to_sym]) }
end
def intended_length_of_stay_options_with_hints
def self.intended_length_of_stay_options_with_hints
hints = {
"Very short stay": "Up to one month.",
"Short stay": "Up to one year.",
"Medium stay": "More than one year but with an expectation to move on.",
"Permanent": "Provides a home for life with no requirement for the tenant to move.",
}
Scheme.intended_stays.keys.excluding("Missing").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize, description: hints[key.to_sym]) }
end

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

@ -77,7 +77,7 @@ module Validations::Sales::SaleInformationValidations
def validate_discounted_ownership_value(record)
return unless record.saledate && record.form.start_year_2024_or_later?
return unless record.value && record.deposit && record.ownershipsch
return unless record.mortgage || record.mortgageused == 2 || record.mortgageused == 3
return unless record.mortgage || record.mortgageused == 2
return unless record.discount || record.grant || record.type == 29
tolerance = record.value_with_discount_tolerance

9
app/models/validations/soft_validations.rb

@ -76,7 +76,8 @@ module Validations::SoftValidations
end
def no_household_member_likely_to_be_pregnant?
all_male_tenants_in_a_pregnant_household? || non_males_in_pregnant_household_not_in_pregnancy_range?
# in this 2026 check we used a wider age range
all_male_tenants_in_a_pregnant_household? || non_males_in_pregnant_household_not_in_range?(13, 55)
end
def all_male_tenants_in_a_pregnant_household?
@ -84,7 +85,7 @@ module Validations::SoftValidations
end
def non_males_in_pregnant_household_not_in_pregnancy_range?
all_tenants_age_and_gender_information_completed? && non_males_in_the_household? && !any_non_male_in_expected_pregnancy_age_range(16, 50) && preg_occ == 1
non_males_in_pregnant_household_not_in_range?(16, 50)
end
TWO_YEARS_IN_DAYS = 730
@ -252,6 +253,10 @@ private
public_send("details_known_#{tenant_number}").zero?
end
def non_males_in_pregnant_household_not_in_range?(min, max)
all_tenants_age_and_gender_information_completed? && non_males_in_the_household? && !any_non_male_in_expected_pregnancy_age_range(min, max) && preg_occ == 1
end
def any_non_male_in_expected_pregnancy_age_range(min, max)
person_count = hhmemb || 8

1
app/services/address_client.rb

@ -46,6 +46,7 @@ private
key: ENV["OS_DATA_KEY"],
maxresults: @options[:maxresults] || 10,
minmatch: @options[:minmatch] || 0.4,
fq: ["COUNTRY_CODE:E"],
}
uri.query = URI.encode_www_form(params)
uri.to_s

39
app/services/bulk_upload/lettings/year2025/csv_parser.rb

@ -9,6 +9,8 @@ class BulkUpload::Lettings::Year2025::CsvParser
attr_reader :path
ROW_PARSER_CLASS = BulkUpload::Lettings::Year2025::RowParser
def initialize(path:)
@path = path
end
@ -33,11 +35,30 @@ class BulkUpload::Lettings::Year2025::CsvParser
@row_parsers ||= body_rows.map { |row|
next if row.empty?
invalid_fields = []
stripped_row = row[col_offset..]
hash = Hash[field_numbers.zip(stripped_row)]
hash_rows = field_numbers
.zip(stripped_row)
.map do |field, value|
field_is_valid = value_is_valid_for_field?(field, value)
correct_value = field_is_valid ? value : nil
invalid_fields << field unless field_is_valid
[field, correct_value]
end
hash = Hash[hash_rows]
row_parser = ROW_PARSER_CLASS.new(hash)
BulkUpload::Lettings::Year2025::RowParser.new(hash)
invalid_fields.each do |field|
row_parser.add_invalid_field(field)
end
row_parser
}.compact
end
@ -110,6 +131,20 @@ private
@normalised_string
end
# this is needed as a string passed to an int attribute is by default mapped to '0'.
# this is bad as some questions will accept a '0'. so you could enter something invalid and not be told about it
def value_is_valid_for_field?(field, value)
field_type = ROW_PARSER_CLASS.attribute_types[field]
if field_type.is_a?(ActiveModel::Type::Integer)
value.nil? || Integer(value, exception: false).present?
elsif field_type.is_a?(ActiveModel::Type::Decimal)
value.nil? || Float(value, exception: false).present?
else
true
end
end
def first_record_start_date
if with_headers?
year = row_parsers.first.field_10.to_s.strip.length.between?(1, 2) ? row_parsers.first.field_10.to_i + 2000 : row_parsers.first.field_10.to_i

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

@ -506,6 +506,8 @@ class BulkUpload::Lettings::Year2025::RowParser
end
end
add_errors_for_invalid_fields
@valid = errors.blank?
end
@ -582,6 +584,10 @@ class BulkUpload::Lettings::Year2025::RowParser
end
end
def add_invalid_field(field)
invalid_fields << field
end
private
def normalise_case_insensitive_fields
@ -1020,6 +1026,17 @@ private
end
end
def invalid_fields
@invalid_fields ||= []
end
def add_errors_for_invalid_fields
invalid_fields.each do |field|
errors.delete(field) # take precedence over any other errors as this is a BU format issue
errors.add(field, I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: QUESTIONS[field.to_sym]))
end
end
def field_mapping_for_errors
{
lettype: [:field_11],

39
app/services/bulk_upload/lettings/year2026/csv_parser.rb

@ -8,6 +8,8 @@ class BulkUpload::Lettings::Year2026::CsvParser
attr_reader :path
ROW_PARSER_CLASS = BulkUpload::Lettings::Year2026::RowParser
def initialize(path:)
@path = path
end
@ -32,11 +34,30 @@ class BulkUpload::Lettings::Year2026::CsvParser
@row_parsers ||= body_rows.map { |row|
next if row.empty?
invalid_fields = []
stripped_row = row[col_offset..]
hash = Hash[field_numbers.zip(stripped_row)]
hash_rows = field_numbers
.zip(stripped_row)
.map do |field, value|
field_is_valid = value_is_valid_for_field?(field, value)
correct_value = field_is_valid ? value : nil
invalid_fields << field unless field_is_valid
[field, correct_value]
end
hash = Hash[hash_rows]
row_parser = ROW_PARSER_CLASS.new(hash)
BulkUpload::Lettings::Year2026::RowParser.new(hash)
invalid_fields.each do |field|
row_parser.add_invalid_field(field)
end
row_parser
}.compact
end
@ -118,4 +139,18 @@ private
Date.new(year, rows.first[8].to_i, rows.first[7].to_i)
end
end
# this is needed as a string passed to an int attribute is by default mapped to '0'.
# this is bad as some questions will accept a '0'. so you could enter something invalid and not be told about it
def value_is_valid_for_field?(field, value)
field_type = ROW_PARSER_CLASS.attribute_types[field]
if field_type.is_a?(ActiveModel::Type::Integer)
value.nil? || Integer(value, exception: false).present?
elsif field_type.is_a?(ActiveModel::Type::Decimal)
value.nil? || Float(value, exception: false).present?
else
true
end
end
end

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

@ -541,6 +541,8 @@ class BulkUpload::Lettings::Year2026::RowParser
end
end
add_errors_for_invalid_fields
@valid = errors.blank?
end
@ -620,6 +622,10 @@ class BulkUpload::Lettings::Year2026::RowParser
end
end
def add_invalid_field(field)
invalid_fields << field
end
private
def normalise_case_insensitive_fields
@ -1098,6 +1104,17 @@ private
end
end
def invalid_fields
@invalid_fields ||= []
end
def add_errors_for_invalid_fields
invalid_fields.each do |field|
errors.delete(field) # take precedence over any other errors as this is a BU format issue
errors.add(field, I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: QUESTIONS[field.to_sym]))
end
end
def field_mapping_for_errors
{
lettype: [:field_11],

40
app/services/bulk_upload/sales/year2025/csv_parser.rb

@ -9,6 +9,8 @@ class BulkUpload::Sales::Year2025::CsvParser
attr_reader :path
ROW_PARSER_CLASS = BulkUpload::Sales::Year2025::RowParser
def initialize(path:)
@path = path
end
@ -33,10 +35,30 @@ class BulkUpload::Sales::Year2025::CsvParser
@row_parsers ||= body_rows.map { |row|
next if row.empty?
invalid_fields = []
stripped_row = row[col_offset..]
hash = Hash[field_numbers.zip(stripped_row)]
BulkUpload::Sales::Year2025::RowParser.new(hash)
hash_rows = field_numbers
.zip(stripped_row)
.map do |field, value|
field_is_valid = value_is_valid_for_field?(field, value)
correct_value = field_is_valid ? value : nil
invalid_fields << field unless field_is_valid
[field, correct_value]
end
hash = Hash[hash_rows]
row_parser = ROW_PARSER_CLASS.new(hash)
invalid_fields.each do |field|
row_parser.add_invalid_field(field)
end
row_parser
}.compact
end
@ -112,6 +134,20 @@ private
@normalised_string
end
# this is needed as a string passed to an int attribute is by default mapped to '0'.
# this is bad as some questions will accept a '0'. so you could enter something invalid and not be told about it
def value_is_valid_for_field?(field, value)
field_type = ROW_PARSER_CLASS.attribute_types[field]
if field_type.is_a?(ActiveModel::Type::Integer)
value.nil? || Integer(value, exception: false).present?
elsif field_type.is_a?(ActiveModel::Type::Decimal)
value.nil? || Float(value, exception: false).present?
else
true
end
end
def first_record_start_date
if with_headers?
year = row_parsers.first.field_3.to_s.strip.length.between?(1, 2) ? row_parsers.first.field_3.to_i + 2000 : row_parsers.first.field_3.to_i

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

@ -288,7 +288,7 @@ class BulkUpload::Sales::Year2025::RowParser
attribute :field_112, :integer
attribute :field_113, :decimal
attribute :field_114, :integer
attribute :field_114, :decimal
attribute :field_115, :decimal
attribute :field_116, :integer
attribute :field_117, :decimal
@ -503,6 +503,8 @@ class BulkUpload::Sales::Year2025::RowParser
end
end
add_errors_for_invalid_fields
errors.blank?
end
@ -549,6 +551,10 @@ class BulkUpload::Sales::Year2025::RowParser
end
end
def add_invalid_field(field)
invalid_fields << field
end
private
def normalise_case_insensitive_fields
@ -677,6 +683,17 @@ private
[9, 14, 27, 29].include?(field_11)
end
def invalid_fields
@invalid_fields ||= []
end
def add_errors_for_invalid_fields
invalid_fields.each do |field|
errors.delete(field) # take precedence over any other errors as this is a BU format issue
errors.add(field, I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: QUESTIONS[field.to_sym]))
end
end
def field_mapping_for_errors
{
purchid: %i[field_7],

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

@ -8,6 +8,8 @@ class BulkUpload::Sales::Year2026::CsvParser
attr_reader :path
ROW_PARSER_CLASS = BulkUpload::Sales::Year2026::RowParser
def initialize(path:)
@path = path
end
@ -32,10 +34,30 @@ class BulkUpload::Sales::Year2026::CsvParser
@row_parsers ||= body_rows.map { |row|
next if row.empty?
invalid_fields = []
stripped_row = row[col_offset..]
hash = Hash[field_numbers.zip(stripped_row)]
BulkUpload::Sales::Year2026::RowParser.new(hash)
hash_rows = field_numbers
.zip(stripped_row)
.map do |field, value|
field_is_valid = value_is_valid_for_field?(field, value)
correct_value = field_is_valid ? value : nil
invalid_fields << field unless field_is_valid
[field, correct_value]
end
hash = Hash[hash_rows]
row_parser = ROW_PARSER_CLASS.new(hash)
invalid_fields.each do |field|
row_parser.add_invalid_field(field)
end
row_parser
}.compact
end
@ -111,6 +133,20 @@ private
@normalised_string
end
# this is needed as a string passed to an int attribute is by default mapped to '0'.
# this is bad as some questions will accept a '0'. so you could enter something invalid and not be told about it
def value_is_valid_for_field?(field, value)
field_type = ROW_PARSER_CLASS.attribute_types[field]
if field_type.is_a?(ActiveModel::Type::Integer)
value.nil? || Integer(value, exception: false).present?
elsif field_type.is_a?(ActiveModel::Type::Decimal)
value.nil? || Float(value, exception: false).present?
else
true
end
end
def first_record_start_date
if with_headers?
year = row_parsers.first.field_3.to_s.strip.length.between?(1, 2) ? row_parsers.first.field_3.to_i + 2000 : row_parsers.first.field_3.to_i

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

@ -18,7 +18,7 @@ class BulkUpload::Sales::Year2026::RowParser
field_11: "What is the type of discounted ownership sale?",
field_12: "Is this a joint purchase?",
field_13: "Are there more than 2 joint buyers of this property?",
field_14: "Did you interview the buyer to answer these questions?",
field_14: "Did you interview the buyer(s) to answer these questions?",
field_15: "Has the buyer seen or been given access to the MHCLG privacy notice?",
field_16: "If known, enter this property’s UPRN",
@ -320,7 +320,7 @@ class BulkUpload::Sales::Year2026::RowParser
attribute :field_127, :integer
attribute :field_128, :decimal
attribute :field_129, :integer
attribute :field_129, :decimal
attribute :field_130, :decimal
attribute :field_131, :integer
attribute :field_132, :decimal
@ -552,6 +552,8 @@ class BulkUpload::Sales::Year2026::RowParser
end
end
add_errors_for_invalid_fields
errors.blank?
end
@ -598,6 +600,10 @@ class BulkUpload::Sales::Year2026::RowParser
end
end
def add_invalid_field(field)
invalid_fields << field
end
private
def normalise_case_insensitive_fields
@ -730,6 +736,17 @@ private
[9, 14, 29].include?(field_11)
end
def invalid_fields
@invalid_fields ||= []
end
def add_errors_for_invalid_fields
invalid_fields.each do |field|
errors.delete(field) # take precedence over any other errors as this is a BU format issue
errors.add(field, I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: QUESTIONS[field.to_sym]))
end
end
def field_mapping_for_errors
{
purchid: %i[field_7],

1
app/services/uprn_client.rb

@ -49,6 +49,7 @@ private
uprn:,
key: ENV["OS_DATA_KEY"],
dataset: %w[DPA LPI].join(","),
fq: ["COUNTRY_CODE:E"],
}
uri.query = URI.encode_www_form(params)
uri.to_s

12
app/views/schemes/support.html.erb

@ -12,23 +12,15 @@
<%= render partial: "organisations/headings", locals: { main: "What support does this scheme provide?", sub: @scheme.service_name } %>
<%= govuk_inset_text(text: "Only update a scheme if you’re fixing an error. If the scheme is changing, create a new scheme.") if @scheme.confirmed? %>
<% support_level_options_hints = { "Low level": "Staff visiting once a week, fortnightly or less.", "Medium level": "Staff on site daily or making frequent visits with some out-of-hours cover.", "High level": "Intensive level of staffing provided on a 24-hour basis." } %>
<% support_level_options_with_hints = Scheme.support_types.keys.excluding("Missing").excluding("Floating support").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize, description: support_level_options_hints[key.to_sym]) } %>
<%= f.govuk_collection_radio_buttons :support_type,
support_level_options_with_hints,
Scheme.support_level_options_with_hints,
:id,
:name,
:description,
legend: { text: "Level of support given", size: "m" } %>
<% intended_length_of_stay_options_hints = { "Very short stay": "Up to one month.", "Short stay": "Up to one year.", "Medium stay": "More than one year but with an expectation to move on.", "Permanent": "Provides a home for life with no requirement for the tenant to move." } %>
<% intended_length_of_stay_options_with_hints = Scheme.intended_stays.keys.excluding("Missing").map { |key, _| OpenStruct.new(id: key, name: key.to_s.humanize, description: intended_length_of_stay_options_hints[key.to_sym]) } %>
<%= f.govuk_collection_radio_buttons :intended_stay,
intended_length_of_stay_options_with_hints,
Scheme.intended_length_of_stay_options_with_hints,
:id,
:name,
:description,

1
app/views/start/guidance.html.erb

@ -26,6 +26,7 @@
<%= govuk_list [
"Tenants in general needs housing allocated a new letting. This includes tenants moving into the social rented sector from outside, existing social tenants moving between properties or landlords, and existing social tenants renewing lettings in the same property. If fixed-term and social or affordable rent, only include tenancies of 2 years or more.",
"Tenants in supported housing (social housing and sheltered accommodation) allocated a new letting. This includes tenants moving into the social rented sector from outside, existing social tenants moving between properties or landlords, and existing social tenants renewing lettings in the same property. All supported housing tenancies should be reported regardless of length.",
"Tenancies in leased properties being let on social housing terms, regardless of whether the property had previously been used as social stock.",
"Starter tenancies provided by local authorities (LAs) and lettings with an introductory period provided by private registered providers (PRPs) should be completed in CORE at the beginning of the starter or introductory period. The tenancy type and length entered should be based on the tenancy the tenant will roll onto once the starter or introductory period has been completed. You do not need to submit another CORE log once the period has been completed.",
"Room moves within a shared housing unit that result in a different property type or support needs – this is classed as an internal transfer of an existing social tenant to another property.",
"Existing tenants who are issued with a new tenancy agreement when stock is acquired, transferred or permanently decanted.",

16
config/locales/forms/2025/lettings/tenancy_information.en.yml

@ -21,27 +21,27 @@ en:
tenancy_type:
page_header: ""
tenancy:
check_answer_label: "Type of main tenancy"
check_answer_label: "Type of tenancy"
check_answer_prompt: ""
hint_text: ""
question_text: "What is the type of tenancy?"
tenancyother:
check_answer_label: ""
check_answer_label: "Type of tenancy (other)"
check_answer_prompt: ""
hint_text: ""
question_text: "Please state the tenancy type"
question_text: "Please state the type of tenancy"
starter_tenancy_type:
page_header: ""
tenancy:
check_answer_label: "Type of main tenancy after the starter or introductory period has ended"
check_answer_label: "Type of tenancy"
check_answer_prompt: ""
hint_text: ""
question_text: "What is the type of tenancy after the starter or introductory period has ended?"
hint_text: "This is for the main tenancy after any starter or introductory period."
question_text: "What is the type of tenancy?"
tenancyother:
check_answer_label: ""
check_answer_label: "Type of tenancy (other)"
check_answer_prompt: ""
hint_text: ""
question_text: "Please state the tenancy type"
question_text: "Please state the type of tenancy"
tenancylength:
tenancy_length:

16
config/locales/forms/2026/lettings/tenancy_information.en.yml

@ -21,27 +21,27 @@ en:
tenancy_type:
page_header: ""
tenancy:
check_answer_label: "Type of main tenancy"
check_answer_label: "Type of tenancy"
check_answer_prompt: ""
hint_text: ""
question_text: "What is the type of tenancy?"
tenancyother:
check_answer_label: ""
check_answer_label: "Type of tenancy (other)"
check_answer_prompt: ""
hint_text: ""
question_text: "Please state the tenancy type"
question_text: "Please state the type of tenancy"
starter_tenancy_type:
page_header: ""
tenancy:
check_answer_label: "Type of main tenancy after the starter or introductory period has ended"
check_answer_label: "Type of tenancy"
check_answer_prompt: ""
hint_text: ""
question_text: "What is the type of tenancy after the starter or introductory period has ended?"
hint_text: "This is for the main tenancy after any starter or introductory period."
question_text: "What is the type of tenancy?"
tenancyother:
check_answer_label: ""
check_answer_label: "Type of tenancy (other)"
check_answer_prompt: ""
hint_text: ""
question_text: "Please state the tenancy type"
question_text: "Please state the type of tenancy"
tenancylength:
tenancy_length:

14
config/locales/forms/2026/sales/sale_information.en.yml

@ -171,10 +171,16 @@ en:
mortgageused:
page_header: ""
check_answer_label: "Mortgage used"
check_answer_prompt: "Tell us if a mortgage was used"
hint_text: ""
question_text: "Was a mortgage used for the purchase of this property?"
staircase_equity:
check_answer_label: "Mortgage used"
check_answer_prompt: "Tell us if a mortgage was used"
hint_text: ""
question_text: "Was a mortgage used for this staircasing transaction?"
non_staircase_equity:
check_answer_label: "Mortgage used"
check_answer_prompt: "Tell us if a mortgage was used"
hint_text: ""
question_text: "Was a mortgage used for the purchase of this property?"
mortgage:
page_header: ""

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

@ -86,13 +86,13 @@ en:
check_answer_label: "Buyers interviewed in person"
check_answer_prompt: "Tell us if buyers interviewed in person"
hint_text: "You should still try to answer all questions even if the buyers weren’t interviewed in person"
question_text: "Were the buyers interviewed for any of the answers you will provide on this log?"
question_text: "Did you interview the buyers to answer these questions?"
not_joint_purchase:
page_header: ""
check_answer_label: "Buyer interviewed in person"
check_answer_prompt: "Tell us if buyer interviewed in person"
hint_text: "You should still try to answer all questions even if the buyer wasn’t interviewed in person"
question_text: "Was the buyer interviewed for any of the answers you will provide on this log?"
question_text: "Did you interview the buyer to answer these questions?"
privacynotice:
joint_purchase:

2
config/locales/validations/sales/2026/bulk_upload.en.yml

@ -46,4 +46,4 @@ en:
invalid: "Select a valid nationality."
mortlen:
invalid: "Mortgage length must be a number or the letter R"
invalid_for_interviewed: "You indicated that the buyer was interviewed, but selected “Don’t know” for mortgage length. Please provide the mortgage length or update your response."
invalid_for_interviewed: "You indicated that you interviewed the buyer(s), but selected “Don’t know” for mortgage length. Please provide the mortgage length or update your response."

2
docs/Gemfile.lock

@ -198,7 +198,7 @@ GEM
gemoji (~> 3.0)
html-pipeline (~> 2.2)
jekyll (>= 3.0, < 5.0)
json (2.18.1)
json (2.19.2)
kramdown (2.3.2)
rexml
kramdown-parser-gfm (1.1.0)

10
lib/tasks/update_schemes_logs_impacted_by_nursing_level_removal.rake

@ -0,0 +1,10 @@
desc "Update schemes that had a level of support of 'Nursing' to be incomplete, and mark all logs that use that scheme as incomplete"
task update_schemes_logs_impacted_by_nursing_level_removal: :environment do
ActiveRecord::Base.transaction do
impacted_schemes = Scheme.where(support_type: 5)
impacted_logs = LettingsLog.filter_by_year_or_later(2025).where(scheme: impacted_schemes)
impacted_schemes.update!(support_type: nil, confirmed: false)
impacted_logs.update!(scheme: nil)
end
end

2
spec/factories/scheme.rb

@ -3,7 +3,7 @@ FactoryBot.define do
service_name { "#{Faker::Name.name}'s Housing & Co." }
sensitive { Faker::Number.within(range: 0..1) }
registered_under_care_act { 1 }
support_type { [0, 2, 3, 4, 5].sample }
support_type { [0, 2, 3, 4].sample }
scheme_type { 4 }
arrangement_type { "D" }
intended_stay { %w[M P S V X].sample }

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

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/blank_bulk_upload_sales.csv vendored

@ -1,4 +1,4 @@
Question,What is the purchaser code?,What is the day of the sale completion date? - DD,What is the month of the sale completion date? - MM,What is the year of the sale completion date? - YY,[BLANK],Was the buyer interviewed for any of the answers you will provide on this log?,Age of Buyer 1,Age of Buyer 2 or Person 2,Age of Person 3,Age of Person 4,Age of Person 5,Age of Person 6,Gender identity of Buyer 1,Gender identity of Buyer 2 or Person 2,Gender identity of Person 3,Gender identity of Person 4,Gender identity of Person 5,Gender identity of Person 6,Person 2's relationship to lead tenant,Person 3's relationship to lead tenant,Person 4's relationship to lead tenant,Person 5's relationship to lead tenant,Person 6's relationship to lead tenant,Working situation of Buyer 1,Working situation of Buyer 2 or Person 2,Working situation of Person 3,Working situation of Person 4,Working situation of Person 5,Working situation of Person 6,What is the buyer 1's ethnic group?,What is buyer 1's nationality?,What is buyer 1's gross annual income?,What is buyer 2's gross annual income?,Was buyer 1's income used for a mortgage application?,Was buyer 2's income used for a mortgage application?,"What is the total amount the buyers had in savings before they paid any deposit for the property?
Question,What is the purchaser code?,What is the day of the sale completion date? - DD,What is the month of the sale completion date? - MM,What is the year of the sale completion date? - YY,[BLANK],Did you interview the buyer to answer these questions?,Age of Buyer 1,Age of Buyer 2 or Person 2,Age of Person 3,Age of Person 4,Age of Person 5,Age of Person 6,Gender identity of Buyer 1,Gender identity of Buyer 2 or Person 2,Gender identity of Person 3,Gender identity of Person 4,Gender identity of Person 5,Gender identity of Person 6,Person 2's relationship to lead tenant,Person 3's relationship to lead tenant,Person 4's relationship to lead tenant,Person 5's relationship to lead tenant,Person 6's relationship to lead tenant,Working situation of Buyer 1,Working situation of Buyer 2 or Person 2,Working situation of Person 3,Working situation of Person 4,Working situation of Person 5,Working situation of Person 6,What is the buyer 1's ethnic group?,What is buyer 1's nationality?,What is buyer 1's gross annual income?,What is buyer 2's gross annual income?,Was buyer 1's income used for a mortgage application?,Was buyer 2's income used for a mortgage application?,"What is the total amount the buyers had in savings before they paid any deposit for the property?
To the nearest £10",Have any of the buyers previously owned a property?,[BLANK],What was buyer 1's previous tenure?,What is the local authority of buyer 1's last settled home,Part 1 of postcode of buyer 1's last settled home,Part 2 of postcode of buyer 1's last settled home,Do you know the postcode of buyer 1's last settled home?,Was the buyer registered with their PRP (HA)?,Was the buyer registered with the local authority?,Was the buyer registered with a Help to Buy agent?,Was the buyer registered with another PRP (HA)?,Does anyone in the household consider themselves to have a disability?,Does anyone in the household use a wheelchair?,How many bedrooms does the property have?,What type of unit is the property?,Which type of building is the property?,What is the local authority of the property?,Part 1 of postcode of property,Part 2 of postcode of property,Is the property built or adapted to wheelchair-user standards?,What is the type of shared ownership sale?,"Is this a resale?

1 Question What is the purchaser code? What is the day of the sale completion date? - DD What is the month of the sale completion date? - MM What is the year of the sale completion date? - YY [BLANK] Was the buyer interviewed for any of the answers you will provide on this log? Did you interview the buyer to answer these questions? Age of Buyer 1 Age of Buyer 2 or Person 2 Age of Person 3 Age of Person 4 Age of Person 5 Age of Person 6 Gender identity of Buyer 1 Gender identity of Buyer 2 or Person 2 Gender identity of Person 3 Gender identity of Person 4 Gender identity of Person 5 Gender identity of Person 6 Person 2's relationship to lead tenant Person 3's relationship to lead tenant Person 4's relationship to lead tenant Person 5's relationship to lead tenant Person 6's relationship to lead tenant Working situation of Buyer 1 Working situation of Buyer 2 or Person 2 Working situation of Person 3 Working situation of Person 4 Working situation of Person 5 Working situation of Person 6 What is the buyer 1's ethnic group? What is buyer 1's nationality? What is buyer 1's gross annual income? What is buyer 2's gross annual income? Was buyer 1's income used for a mortgage application? Was buyer 2's income used for a mortgage application? What is the total amount the buyers had in savings before they paid any deposit for the property? To the nearest £10 Have any of the buyers previously owned a property? [BLANK] What was buyer 1's previous tenure? What is the local authority of buyer 1's last settled home Part 1 of postcode of buyer 1's last settled home Part 2 of postcode of buyer 1's last settled home Do you know the postcode of buyer 1's last settled home? Was the buyer registered with their PRP (HA)? Was the buyer registered with the local authority? Was the buyer registered with a Help to Buy agent? Was the buyer registered with another PRP (HA)? Does anyone in the household consider themselves to have a disability? Does anyone in the household use a wheelchair? How many bedrooms does the property have? What type of unit is the property? Which type of building is the property? What is the local authority of the property? Part 1 of postcode of property Part 2 of postcode of property Is the property built or adapted to wheelchair-user standards? What is the type of shared ownership sale? Is this a resale? Shared ownership What is the day of the practical completion or handover date? - DD Shared ownership What is the month of the practical completion or handover date? - MM Shared ownership What is the year of the practical completion or handover date? - YY Shared ownership What is the day of the exchange of contracts date? - DD Shared ownership What is the month of the exchange of contracts date? - MM Shared ownership What is the year of the exchange of contracts date? - YY Shared ownership Was the household re-housed under a local authority nominations agreement? Shared ownership How many bedrooms did the buyer's previous property have? Shared ownership What was the type of the buyer's previous property? Shared ownership What was the full purchase price? Shared ownership What was the initial percentage equity stake purchased? Shared ownership What is the mortgage amount? Shared ownership Does this include any extra borrowing? Shared ownership How much was the cash deposit paid on the property? Shared ownership How much cash discount was given through Social Homebuy? Shared ownership What is the basic monthly rent? Shared ownership What are the total monthly leasehold charges for the property? Shared ownership What is the type of discounted ownership sale? What was the full purchase price? Discounted ownership What was the amount of any loan, grant, discount or subsidy given? Discounted ownership What was the percentage discount? Discounted ownership What is the mortgage amount? Discounted ownership Does this include any extra borrowing? Discounted ownership How much was the cash deposit paid on the property? Discounted ownership What are the total monthly leasehold charges for the property? Discounted ownership What is the type of outright sale? What is the 'Other' type of outright sale? Outright sale [BLANK] What is the full purchase price? Outright sale What is the mortgage amount? Outright sale Does this include any extra borrowing? Outright sale How much was the cash deposit paid on the property? Outright sale What are the total monthly leasehold charges for the property? Outright sale Which organisation owned this property before the sale? Organisation's CORE ID Username BLANK Has the buyer ever served in the UK Armed Forces and for how long? [BLANK] Are any of the buyers a spouse or civil partner of a UK Armed Forces regular who died in service within the last 2 years? What is the name of the mortgage lender? Shared ownership What is the name of the 'Other' mortgage lender? Shared ownership What is the name of the mortgage lender? Discounted ownership What is the name of the 'Other' mortgage lender? Discounted ownership What is the name of the mortgage lender? Outright sale What is the name of the 'Other' mortgage lender? Outright sale Were the buyers receiving any of these housing-related benefits immediately before buying this property? What is the length of the mortgage in years? Shared ownership What is the length of the mortgage in years? Discounted ownership What is the length of the mortgage in years? Outright sale How long have the buyers been living in the property before the purchase? Discounted ownership Are there more than two joint purchasers of this property? How long have the buyers been living in the property before the purchase? Shared ownership Is this a staircasing transaction? Data Protection question Was this purchase made through an ownership scheme? Is the buyer a company? Outright sale Will the buyers live in the property? Is this a joint purchase? Will buyer 1 live in the property? Will buyer 2 live in the property? Besides the buyers, how many people live in the property? What percentage of the property has been bought in this staircasing transaction? Shared ownership What percentage of the property does the buyer now own in total? Shared ownership What was the rent type of the buyer's previous property? Shared ownership Was a mortgage used for the purchase of this property? Shared ownership Was a mortgage used for the purchase of this property? Discounted ownership Was a mortgage used for the purchase of this property? Outright sale
2 Values Max 9 digits 1 - 31 1 - 12 19 - 23 1 or null 1 or null 15 - 110 or R 1 - 110 or R M, F, X or R P, C, X or R 0 - 10 1 - 19 12 -13, 17 -19 0 - 99999 1 or 2 1 or 2 0 - 999990 1 - 3 1 - 7 or 9 ONS CODE - E + 9 digits XXX(X) XXX 1 or null 1 - 3 1 - 3 1 - 9 1 - 4 or 9 1 or 2 ONS CODE E + 9 digits XXX(X) XXX 1 - 3 2, 16, 18, 24, 28 or 30-31 1 or 2 1 - 31 1 - 12 19 - 23 1 - 31 1 - 12 19 - 23 1 - 3 1 - 9 1 - 4 or 9 0 - 999999 0 - 100 0 - 999999 1 - 3 0 - 999999 0 - 999.99 8, 9, 14, 21, 22, 27 or 29 0 - 999999 0 - 100 0 - 999999 1 - 3 0 - 999999 0 - 999.99 10 or 12 0 - 999999 1-3 0 - 999999 0-999.99 Up to 7 digits Username of CORE account this sales log should be assigned to 3 - 8 4 - 7 1 - 40 1 - 40 1 - 40 1 - 4 Integer <=60 Integer <=60 Integer <=60 Integer <=80 1 - 3 Integer <=80 1 - 3 1 1 - 3 1 - 2 1 - 2 1 - 2 1 - 2 1 - 2 0 - 5 1 - 100 1 - 100 1-3 or 9-10 1 - 2 1 - 2 1 - 2
3 Can be Null? No No No No If fields 14, 19 and 25 are all also null If fields 15, 20 and 26 are all also null If fields 16, 21 and 27 are all also null If fields 17, 22 and 28 are all also null If fields 18, 23 and 29 are all also null No If fields 8, 19 and 25 are all also null If fields 9, 20 and 26 are also null If fields 10, 21 and 27 are all also null If fields 11, 22 and 28 are all also null If fields 12, 23 and 29 are all also null If fields 8, 14 and 25 are all also null If fields 9, 15 and 26 are all also null If fields 10, 16 and 27 are all also null If fields 11, 17 and 28 are all also null If fields 12, 18 and 29 are all also null If field 6 = 1 If fields 8, 14 and 19 are all also null If fields 9, 15 and 20 are all also null If fields 10, 16 and 21 are all also null If fields 11, 17 and 22 are all also null If fields 12, 18 and 23 are all also null If field 6 = 1 If field 116 = 2 If field 32 is null If field 116 = 2 If field 6 = 1 If field 6 = 1 No If field 43 = 1 If fields 41 and 42 BOTH have valid entries Yes If field 6 = 1 No If field 113 = 2 or 3 If field 113 = 2 or 3 OR field 39 = 3 - 7 or 9 If field 113 = 2 or 3 If field 57 is null, 2, 16, 24 or 28 If field 113 = 2 or 3 If field 113 = 1 or 3 If field 76 is null If field 76 is null, 9 or 14 If field 76 is null, 8, 21 or 22 If field 113 = 1 or 3 If field 113 = 1 or 2 If field 84 is null or 10 If field 113 = 1 or 2 No Yes No No If field 113 = 2 or 3 If field 113 = 2 or 3 OR If field 98 is not 40 If field 113 = 1 or 3 If field 113 = 1 or 3 OR If field 100 is not 40 If field 113 = 1 or 2 If field 113 = 1 or 2 OR If field 102 is not 40 No If field 113 = 2 or 3 If field 113 = 1 or 3 If field 113 = 1 or 2 If field 113 = 1 or 3 If field 116 = 2 If field 113 = 2 or 3 If field 113 = 2 or 3 No No If field 113 = 1 or 2 If field 113 = 1 or 2 No No If field 116 = 2 No If field 113 = 2 or 3 If field 113 = 2 or 3 If field 113 = 1 or 2 OR If field 39 = 3 - 9 If field 113 = 2 or 3 If field 113 = 1 or 3 If field 113 = 1 or 2
4 Bulk upload format and duplicate check Yes

2
spec/fixtures/files/sales_logs_csv_export_codes_26.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/sales_logs_csv_export_labels_26.csv vendored

File diff suppressed because one or more lines are too long

2
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_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

@ -21,3 +21,4 @@ hasservicechargeschanged,Will the service charge change after this staircasing t
newservicecharges,What are the new total monthly service charges for the property?
hholdcount,In total, how many people live in the property?
hhtype,Household type
noint,Did you interview the buyer(s) to answer these questions?

1 sexrab1,What was buyer 1's sex at birth?
21 newservicecharges,What are the new total monthly service charges for the property?
22 hholdcount,In total, how many people live in the property?
23 hhtype,Household type
24 noint,Did you interview the buyer(s) to answer these questions?

2
spec/lib/tasks/log_variable_definitions_spec.rb

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

2
spec/models/form/lettings/questions/address_search_spec.rb

@ -48,7 +48,7 @@ RSpec.describe Form::Lettings::Questions::AddressSearch, type: :model do
],
}.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=123")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=123")
.to_return(status: 200, body:, headers: {})
end

12
spec/models/form/lettings/questions/previous_tenure_spec.rb

@ -75,21 +75,21 @@ RSpec.describe Form::Lettings::Questions::PreviousTenure, type: :model do
"35" => { "value" => "Extra care housing" },
"38" => { "value" => "Older people’s housing for tenants with low support needs" },
"6" => { "value" => "Other supported housing" },
"3" => { "value" => "Private sector tenancy" },
"27" => { "value" => "Owner occupation (low-cost home ownership)" },
"26" => { "value" => "Owner occupation (private)" },
"3" => { "value" => "Private sector tenancy" },
"28" => { "value" => "Living with friends or family (long-term)" },
"39" => { "value" => "Sofa surfing (moving regularly between family or friends, no permanent bed)" },
"14" => { "value" => "Bed and breakfast" },
"7" => { "value" => "Direct access hostel" },
"10" => { "value" => "Hospital" },
"29" => { "value" => "Prison or approved probation hostel" },
"19" => { "value" => "Rough sleeping" },
"18" => { "value" => "Any other temporary accommodation" },
"19" => { "value" => "Rough sleeping" },
"21" => { "value" => "Refuge" },
"13" => { "value" => "Children’s home or foster care" },
"24" => { "value" => "Home Office Asylum Support" },
"23" => { "value" => "Mobile home or caravan" },
"21" => { "value" => "Refuge" },
"9" => { "value" => "Residential care home" },
"4" => { "value" => "Tied housing or rented with job" },
"37" => { "value" => "Host family or similar refugee accommodation" },
@ -113,21 +113,21 @@ RSpec.describe Form::Lettings::Questions::PreviousTenure, type: :model do
"35" => { "value" => "Extra care housing" },
"38" => { "value" => "Older people’s housing for tenants with low support needs" },
"6" => { "value" => "Other supported housing" },
"3" => { "value" => "Private sector tenancy" },
"27" => { "value" => "Owner occupation (low-cost home ownership)" },
"26" => { "value" => "Owner occupation (private)" },
"3" => { "value" => "Private sector tenancy" },
"28" => { "value" => "Living with friends or family (long-term)" },
"39" => { "value" => "Sofa surfing (moving regularly between family or friends, no permanent bed)" },
"14" => { "value" => "Bed and breakfast" },
"7" => { "value" => "Direct access hostel" },
"10" => { "value" => "Hospital" },
"29" => { "value" => "Prison or approved probation hostel" },
"19" => { "value" => "Rough sleeping" },
"18" => { "value" => "Any other temporary accommodation" },
"19" => { "value" => "Rough sleeping" },
"21" => { "value" => "Refuge" },
"13" => { "value" => "Children’s home or foster care" },
"24" => { "value" => "Home Office Asylum Support" },
"23" => { "value" => "Mobile home or caravan" },
"21" => { "value" => "Refuge" },
"9" => { "value" => "Residential care home" },
"4" => { "value" => "Tied housing or rented with job" },
"37" => { "value" => "Host family or similar refugee accommodation" },

4
spec/models/form/sales/pages/buyer_still_serving_spec.rb

@ -1,11 +1,13 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::BuyerStillServing, 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: Time.zone.local(2023, 4, 1))) }
let(:subsection) { instance_double(Form::Subsection, form: instance_double(Form, start_date: current_collection_start_date, start_year_2026_or_later?: true)) }
it "has correct subsection" do
expect(page.subsection).to eq(subsection)

2
spec/models/form/sales/questions/address_search_spec.rb

@ -48,7 +48,7 @@ RSpec.describe Form::Sales::Questions::AddressSearch, type: :model do
],
}.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=123")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=123")
.to_return(status: 200, body:, headers: {})
end

37
spec/models/form/sales/questions/buyer_still_serving_spec.rb

@ -1,11 +1,14 @@
require "rails_helper"
RSpec.describe Form::Sales::Questions::BuyerStillServing, 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: Time.zone.local(2023, 4, 1)))) }
let(:start_year_2026_or_later?) { true }
let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, form: instance_double(Form, start_date: current_collection_start_date, start_year_2026_or_later?: start_year_2026_or_later?))) }
it "has correct page" do
expect(question.page).to eq(page)
@ -23,13 +26,29 @@ RSpec.describe Form::Sales::Questions::BuyerStillServing, type: :model do
expect(question.derived?(nil)).to be false
end
it "has the correct answer_options" do
expect(question.answer_options).to eq({
"4" => { "value" => "Yes" },
"5" => { "value" => "No" },
"6" => { "value" => "Buyer prefers not to say" },
"divider" => { "value" => true },
"7" => { "value" => "Don’t know" },
})
context "when 2025", metadata: { year: 25 } do
let(:start_year_2026_or_later?) { false }
it "has the correct answer_options" do
expect(question.answer_options).to eq({
"4" => { "value" => "Yes" },
"5" => { "value" => "No" },
"6" => { "value" => "Buyer prefers not to say" },
"divider" => { "value" => true },
"7" => { "value" => "Don’t know" },
})
end
end
context "when 2026", metadata: { year: 26 } do
let(:start_year_2026_or_later?) { true }
it "has the correct answer_options" do
expect(question.answer_options).to eq({
"4" => { "value" => "Yes" },
"5" => { "value" => "No - they left up to and including 2 years ago" },
"6" => { "value" => "No - they left more than 2 years ago" },
})
end
end
end

91
spec/models/form/sales/questions/living_before_purchase_years_spec.rb

@ -1,83 +1,58 @@
require "rails_helper"
RSpec.describe Form::Sales::Questions::LivingBeforePurchaseYears, type: :model do
include CollectionTimeHelper
subject(:question) { described_class.new(question_id, question_definition, page, ownershipsch: 1, joint_purchase: true) }
let(:question_id) { nil }
let(:question_definition) { nil }
let(:start_date) { Time.utc(2024, 2, 8) }
let(:subsection) { instance_double(Form::Subsection, id: "shared_ownership_initial_purchase", form: instance_double(Form, start_date:, start_year_2026_or_later?: false)) }
let(:page) { instance_double(Form::Page, subsection:) }
let(:start_year) { current_collection_start_year }
let(:start_year_2026_or_later?) { false }
let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, id: "shared_ownership_initial_purchase", form: instance_double(Form, start_date: collection_start_date_for_year(start_year), start_year_2026_or_later?: start_year_2026_or_later?))) }
context "when 2022" do
let(:start_date) { Time.utc(2022, 2, 8) }
it "has correct page" do
expect(question.page).to eq(page)
end
it "has correct page" do
expect(question.page).to eq(page)
end
it "has the correct id" do
expect(question.id).to eq("proplen")
end
it "has the correct id" do
expect(question.id).to eq("proplen")
end
it "has the correct type" do
expect(question.type).to eq("numeric")
end
it "has the correct type" do
expect(question.type).to eq("numeric")
end
it "is not marked as derived" do
expect(question.derived?(nil)).to be false
end
it "is not marked as derived" do
expect(question.derived?(nil)).to be false
end
it "has correct width" do
expect(question.width).to eq(5)
end
it "has correct width" do
expect(question.width).to eq(5)
end
it "has correct step" do
expect(question.step).to eq(1)
end
it "has correct step" do
expect(question.step).to eq(1)
end
it "has correct max" do
expect(question.max).to eq(80)
end
context "with year 2025", metadata: { year: 25 } do
let(:start_year) { 2025 }
it "has correct min" do
expect(question.min).to eq(0)
end
it "has correct max" do
expect(question.max).to eq(80)
end
end
context "when 2023" do
let(:start_date) { Time.utc(2023, 2, 8) }
it "has correct page" do
expect(question.page).to eq(page)
end
it "has the correct id" do
expect(question.id).to eq("proplen")
end
it "has the correct type" do
expect(question.type).to eq("numeric")
end
it "is not marked as derived" do
expect(question.derived?(nil)).to be false
end
it "has correct width" do
expect(question.width).to eq(5)
end
it "has correct step" do
expect(question.step).to eq(1)
end
context "with year 2026", metadata: { year: 26 } do
let(:start_year) { 2026 }
let(:start_year_2026_or_later?) { true }
it "has correct min" do
expect(question.min).to eq(0)
end
it "has correct max" do
expect(question.max).to eq(80)
expect(question.min).to eq(1)
end
end

30
spec/models/form/sales/questions/mortgageused_spec.rb

@ -15,8 +15,8 @@ RSpec.describe Form::Sales::Questions::Mortgageused, type: :model do
let(:start_year_2025_or_later?) { true }
let(:start_year_2026_or_later?) { true }
let(:subsection_id) { "shared_ownership_initial_purchase" }
let(:form) { instance_double(Form, start_date: saledate, 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?) }
let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, form:, id: subsection_id)) }
let(:form) { instance_double(Form, type: "sales", start_date: saledate, 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?) }
let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, form:, id: subsection_id, copy_key: subsection_id)) }
context "when it is a shared ownership scheme" do
let(:ownershipsch) { 1 }
@ -120,6 +120,10 @@ RSpec.describe Form::Sales::Questions::Mortgageused, type: :model do
expect(question.question_number).to eq 106
end
it "has the correct copy_key" do
expect(question.copy_key).to eq "sales.discounted_ownership_scheme.mortgageused"
end
it "does not show the don't know option" do
expect_the_question_not_to_show_dont_know
end
@ -134,6 +138,11 @@ RSpec.describe Form::Sales::Questions::Mortgageused, type: :model do
context "and it is a staircasing transaction" do
let(:staircase) { 1 }
let(:subsection_id) { "shared_ownership_staircasing_transaction" }
it "has the correct copy_key" do
expect(question.copy_key).to eq "sales.shared_ownership_staircasing_transaction.mortgageused"
end
it "shows the don't know option" do
expect_the_question_to_show_dont_know
@ -150,10 +159,15 @@ RSpec.describe Form::Sales::Questions::Mortgageused, type: :model do
context "and it is not a staircasing transaction" do
let(:staircase) { 2 }
let(:subsection_id) { "shared_ownership_initial_purchase" }
it "does not show the don't know option" do
expect_the_question_not_to_show_dont_know
end
it "has the correct copy_key" do
expect(question.copy_key).to eq "sales.shared_ownership_initial_purchase.mortgageused"
end
end
end
end
@ -169,6 +183,10 @@ RSpec.describe Form::Sales::Questions::Mortgageused, type: :model do
expect(question.question_number).to eq 116
end
it "has the correct copy_key" do
expect(question.copy_key).to eq "sales.discounted_ownership_scheme.mortgageused.non_staircase_equity"
end
it "shows the don't know option" do
expect_the_question_to_show_dont_know
end
@ -185,6 +203,10 @@ RSpec.describe Form::Sales::Questions::Mortgageused, type: :model do
expect(question.question_number).to eq 107
end
it "has the correct copy_key" do
expect(question.copy_key).to eq "sales.shared_ownership_staircasing_transaction.mortgageused.staircase_equity"
end
it "shows the don't know option" do
expect_the_question_to_show_dont_know
end
@ -198,6 +220,10 @@ RSpec.describe Form::Sales::Questions::Mortgageused, type: :model do
expect(question.question_number).to eq 90
end
it "has the correct copy_key" do
expect(question.copy_key).to eq "sales.shared_ownership_initial_purchase.mortgageused.non_staircase_equity"
end
it "shows the don't know option" do
expect_the_question_to_show_dont_know
end

18
spec/models/form/sales/questions/property_number_of_bedrooms_spec.rb

@ -19,8 +19,22 @@ RSpec.describe Form::Sales::Questions::PropertyNumberOfBedrooms, type: :model do
expect(question.type).to eq("numeric")
end
it "is not marked as derived" do
expect(question.derived?(nil)).to be false
describe "#derived?" do
context "when the log is a bedsit" do
let(:log) { build(:sales_log, proptype: 2) }
it "is marked as derived" do
expect(question.derived?(log)).to be true
end
end
context "when the log is not a bedsit" do
let(:log) { build(:sales_log, proptype: 1) }
it "is not marked as derived" do
expect(question.derived?(log)).to be false
end
end
end
it "has the correct min" do

6
spec/models/form/sales/subsections/shared_ownership_initial_purchase_spec.rb

@ -59,7 +59,7 @@ RSpec.describe Form::Sales::Subsections::SharedOwnershipInitialPurchase, type: :
shared_ownership_deposit_value_check
monthly_rent
service_charge
monthly_charges_shared_ownership_value_check
monthly_charges_initial_purchase_value_check
estate_management_fee
],
)
@ -102,7 +102,7 @@ RSpec.describe Form::Sales::Subsections::SharedOwnershipInitialPurchase, type: :
shared_ownership_deposit_value_check
monthly_rent
service_charge
monthly_charges_shared_ownership_value_check
monthly_charges_initial_purchase_value_check
estate_management_fee
],
)
@ -145,7 +145,7 @@ RSpec.describe Form::Sales::Subsections::SharedOwnershipInitialPurchase, type: :
shared_ownership_deposit_value_check
monthly_rent
service_charge
monthly_charges_shared_ownership_value_check
monthly_charges_initial_purchase_value_check
estate_management_fee
],
)

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

@ -48,7 +48,7 @@ RSpec.describe Form::Sales::Subsections::SharedOwnershipStaircasingTransaction,
staircase_mortgage_used_shared_ownership
monthly_rent_staircasing_owned
monthly_rent_staircasing
monthly_charges_shared_ownership_value_check
monthly_charges_staircasing_value_check
],
)
end
@ -78,7 +78,7 @@ RSpec.describe Form::Sales::Subsections::SharedOwnershipStaircasingTransaction,
monthly_rent_staircasing
service_charge_staircasing
service_charge_changed
monthly_charges_shared_ownership_value_check
monthly_charges_staircasing_value_check
],
)
end

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

@ -546,6 +546,20 @@ RSpec.describe Validations::Sales::SaleInformationValidations do
expect(record.errors["discount"]).to be_empty
expect(record.errors["grant"]).to be_empty
end
it "does not add errors if mortgageused is don't know" do
record.mortgageused = 3
record.value = 30_000
record.deposit = 5_000
record.discount = 50
expect(record.errors["mortgageused"]).to be_empty
expect(record.errors["mortgage"]).to be_empty
expect(record.errors["value"]).to be_empty
expect(record.errors["deposit"]).to be_empty
expect(record.errors["ownershipsch"]).to be_empty
expect(record.errors["discount"]).to be_empty
expect(record.errors["grant"]).to be_empty
end
end
context "and is 2026" do
@ -601,6 +615,20 @@ RSpec.describe Validations::Sales::SaleInformationValidations do
expect(record.errors["discount"]).to include("The mortgage (£66,113.00) and cash deposit (£0.00) added together is £66,113.00.</br></br>The full purchase price (£123,000.00) subtracted by the sum of the full purchase price (£123,000.00) multiplied by the percentage discount (50.2%) is £61,254.00.</br></br>These two amounts should be the same.")
expect(record.errors["grant"]).to include("The mortgage (£66,113.00) and cash deposit (£0.00) added together is £66,113.00.</br></br>The full purchase price (£123,000.00) subtracted by the sum of the full purchase price (£123,000.00) multiplied by the percentage discount (50.2%) is £61,254.00.</br></br>These two amounts should be the same.")
end
it "does not add errors if mortgageused is don't know" do
record.mortgageused = 3
record.value = 30_000
record.deposit = 5_000
record.discount = 50
expect(record.errors["mortgageused"]).to be_empty
expect(record.errors["mortgage"]).to be_empty
expect(record.errors["value"]).to be_empty
expect(record.errors["deposit"]).to be_empty
expect(record.errors["ownershipsch"]).to be_empty
expect(record.errors["discount"]).to be_empty
expect(record.errors["grant"]).to be_empty
end
end
end
end

116
spec/models/validations/soft_validations_spec.rb

@ -180,7 +180,7 @@ RSpec.describe Validations::SoftValidations do
end
end
context "when all tenants are male and household members are over 8" do
context "when all tenants are male and more than 8 household members" do
it "does not show the interruption screen" do
(1..8).each do |n|
record.send("sex#{n}=", "M")
@ -196,7 +196,7 @@ RSpec.describe Validations::SoftValidations do
context "when female tenants are under 16" do
it "shows the interruption screen" do
record.age2 = 14
record.age2 = 15
record.sex2 = "F"
record.preg_occ = 1
record.hhmemb = 2
@ -209,9 +209,20 @@ RSpec.describe Validations::SoftValidations do
end
end
context "when female tenants are 16 and over" do
it "does not show the interruption screen" do
record.age1 = 16
record.sex1 = "F"
record.preg_occ = 1
record.hhmemb = 1
record.age1_known = 0
expect(record.non_males_in_pregnant_household_not_in_pregnancy_range?).to be false
end
end
context "when female tenants are over 50" do
it "shows the interruption screen" do
record.age1 = 54
record.age1 = 51
record.sex1 = "F"
record.preg_occ = 1
record.hhmemb = 1
@ -220,9 +231,20 @@ RSpec.describe Validations::SoftValidations do
end
end
context "when female tenants are 50 or under" do
it "shows the interruption screen" do
record.age1 = 50
record.sex1 = "F"
record.preg_occ = 1
record.hhmemb = 1
record.age1_known = 0
expect(record.non_males_in_pregnant_household_not_in_pregnancy_range?).to be false
end
end
context "when non-binary tenants are under 16" do
it "does not show the interruption screen" do
record.age2 = 14
record.age2 = 15
record.sex2 = "X"
record.preg_occ = 1
record.hhmemb = 2
@ -237,7 +259,7 @@ RSpec.describe Validations::SoftValidations do
context "when non-binary tenants are over 50" do
it "does not show the interruption screen" do
record.age1 = 54
record.age1 = 51
record.sex1 = "X"
record.preg_occ = 1
record.hhmemb = 1
@ -298,7 +320,7 @@ RSpec.describe Validations::SoftValidations do
end
it "shows the interruption screen" do
expect(record.all_male_tenants_in_a_pregnant_household?).to be true
expect(record.no_household_member_likely_to_be_pregnant?).to be true
end
end
@ -310,11 +332,11 @@ RSpec.describe Validations::SoftValidations do
end
it "shows the interruption screen" do
expect(record.all_male_tenants_in_a_pregnant_household?).to be true
expect(record.no_household_member_likely_to_be_pregnant?).to be true
end
end
context "when all tenants are male and household members are over 8" do
context "when all tenants are male and more than 8 household members" do
before do
(1..8).each do |n|
record.send("sexrab#{n}=", "M")
@ -328,11 +350,23 @@ RSpec.describe Validations::SoftValidations do
end
it "does not show the interruption screen" do
expect(record.all_male_tenants_in_a_pregnant_household?).to be false
expect(record.no_household_member_likely_to_be_pregnant?).to be false
end
end
context "when female tenants are under 16" do
context "when female tenants are 13 or over" do
before do
record.age1 = 13
record.sexrab1 = "F"
record.gender_same_as_sex1 = 1
end
it "does not show the interruption screen" do
expect(record.no_household_member_likely_to_be_pregnant?).to be false
end
end
context "when female tenants are under 13" do
before do
record.age1 = 12
record.sexrab1 = "F"
@ -340,23 +374,48 @@ RSpec.describe Validations::SoftValidations do
end
it "shows the interruption screen" do
expect(record.non_males_in_pregnant_household_not_in_pregnancy_range?).to be true
expect(record.no_household_member_likely_to_be_pregnant?).to be true
end
end
context "when female tenants are over 50" do
context "when female tenants are 55 or under" do
before do
record.age1 = 55
record.sexrab1 = "F"
record.gender_same_as_sex1 = 1
end
it "does not show the interruption screen" do
expect(record.no_household_member_likely_to_be_pregnant?).to be false
end
end
context "when female tenants are over 55" do
before do
record.age1 = 60
record.age1 = 56
record.sexrab1 = "F"
record.gender_same_as_sex1 = 1
end
it "shows the interruption screen" do
expect(record.non_males_in_pregnant_household_not_in_pregnancy_range?).to be true
expect(record.no_household_member_likely_to_be_pregnant?).to be true
end
end
context "when non binary tenants are 13 or over" do
before do
record.age1 = 13
record.sexrab1 = "M"
record.gender_same_as_sex1 = 2
record.gender_description1 = "Non-binary"
end
it "does not show the interruption screen" do
expect(record.no_household_member_likely_to_be_pregnant?).to be false
end
end
context "when non binary tenants are under 16" do
context "when non binary tenants are under 13" do
before do
record.age1 = 12
record.sexrab1 = "M"
@ -365,20 +424,33 @@ RSpec.describe Validations::SoftValidations do
end
it "shows the interruption screen" do
expect(record.non_males_in_pregnant_household_not_in_pregnancy_range?).to be true
expect(record.no_household_member_likely_to_be_pregnant?).to be true
end
end
context "when non binary tenants are over 50" do
context "when non binary tenants are 55 or under" do
before do
record.age1 = 60
record.age1 = 55
record.sexrab1 = "M"
record.gender_same_as_sex1 = 2
record.gender_description1 = "Non-binary"
end
it "does not show the interruption screen" do
expect(record.no_household_member_likely_to_be_pregnant?).to be false
end
end
context "when non binary tenants are over 55" do
before do
record.age1 = 56
record.sexrab1 = "M"
record.gender_same_as_sex1 = 2
record.gender_description1 = "Non-binary"
end
it "shows the interruption screen" do
expect(record.non_males_in_pregnant_household_not_in_pregnancy_range?).to be true
expect(record.no_household_member_likely_to_be_pregnant?).to be true
end
end
@ -390,7 +462,7 @@ RSpec.describe Validations::SoftValidations do
end
it "does not show the interruption screen" do
expect(record.non_males_in_pregnant_household_not_in_pregnancy_range?).to be false
expect(record.no_household_member_likely_to_be_pregnant?).to be false
end
end
@ -405,7 +477,7 @@ RSpec.describe Validations::SoftValidations do
end
it "does not show the interruption screen" do
expect(record.non_males_in_pregnant_household_not_in_pregnancy_range?).to be false
expect(record.no_household_member_likely_to_be_pregnant?).to be false
end
end
@ -423,7 +495,7 @@ RSpec.describe Validations::SoftValidations do
end
it "does not show the interruption screen" do
expect(record.non_males_in_pregnant_household_not_in_pregnancy_range?).to be false
expect(record.no_household_member_likely_to_be_pregnant?).to be false
end
end
end

20
spec/request_helper.rb

@ -18,14 +18,14 @@ module RequestHelper
.to_return(status: 200, body: "{\"status\":200,\"result\":{\"postcode\":\"ZZ1 1ZZ\",\"admin_district\":\"Westminster\",\"codes\":{\"admin_district\":\"E09000033\"}}}", headers: {})
body = { results: [{ DPA: { UPRN: "10033558653" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key&maxresults=10&minmatch=0.4&query=Address%20line%201,%20SW1A%201AA")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?fq=COUNTRY_CODE%3AE&key&maxresults=10&minmatch=0.4&query=Address%20line%201,%20SW1A%201AA")
.to_return(status: 200, body:, headers: {})
body = { results: [{ DPA: { "POSTCODE": "SW1A 1AA", "POST_TOWN": "London", "PO_BOX_NUMBER": "The Mall, City Of Westminster" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key&uprn=1")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key&uprn=1")
.to_return(status: 200, body:, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key&uprn=10033558653")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key&uprn=10033558653")
.to_return(status: 200, body:, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=10033558653")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=10033558653")
.to_return(status: 200, body:, headers: {})
WebMock.stub_request(:post, /api.notifications.service.gov.uk\/v2\/notifications\/email/)
@ -45,7 +45,7 @@ module RequestHelper
],
}.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=1")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=1")
.to_return(status: 200, body:, headers: {})
body = {
@ -61,7 +61,7 @@ module RequestHelper
],
}.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key&uprn=121")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key&uprn=121")
.to_return(status: 200, body:, headers: {})
body = {
@ -76,7 +76,7 @@ module RequestHelper
],
}.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=123")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=123")
.to_return(status: 200, body:, headers: {})
body = {
@ -91,13 +91,13 @@ module RequestHelper
],
}.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=12")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=12")
.to_return(status: 200, body:, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=1234567890123")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=1234567890123")
.to_return(status: 404, body: "", headers: {})
template = Addressable::Template.new "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.4&query={+address_query}"
template = Addressable::Template.new "https://api.os.uk/search/places/v1/find?fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&maxresults=10&minmatch=0.4&query={+address_query}"
WebMock.stub_request(:get, template)
.to_return do |request|
address = request.uri.query_values["query"].split(",")

20
spec/requests/address_search_controller_spec.rb

@ -152,9 +152,9 @@ RSpec.describe AddressSearchController, type: :request do
before do
body = { results: [{ DPA: { "ADDRESS": "100, Test Street", "UPRN": "100" } }] }.to_json
uprn_body = { results: [{ DPA: nil }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=100")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=100")
.to_return(status: 200, body:, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=100")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=100")
.to_return(status: 200, body: uprn_body, headers: {})
end
@ -170,9 +170,9 @@ RSpec.describe AddressSearchController, type: :request do
before do
body = { results: [{ DPA: nil }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "321, Test Street", UPRN: "321" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=321")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=321")
.to_return(status: 200, body:, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=321")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=321")
.to_return(status: 200, body: uprn_body, headers: {})
end
@ -199,9 +199,9 @@ RSpec.describe AddressSearchController, type: :request do
before do
address_body = { results: [{ DPA: { "ADDRESS": "Path not taken", UPRN: "111" } }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "2, Test Street", UPRN: "123456" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=123456")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=123456")
.to_return(status: 200, body: address_body, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=123456")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=123456")
.to_return(status: 200, body: uprn_body, headers: {})
end
@ -217,9 +217,9 @@ RSpec.describe AddressSearchController, type: :request do
before do
address_body = { results: [{ DPA: { "ADDRESS": "70, Test Street", UPRN: "123777" } }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "Path not taken", UPRN: "111" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=70,")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=70,")
.to_return(status: 200, body: address_body, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=70,")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=70,")
.to_return(status: 200, body: uprn_body, headers: {})
end
@ -235,9 +235,9 @@ RSpec.describe AddressSearchController, type: :request do
before do
address_body = { results: [{ DPA: { "ADDRESS": "111, Test Street", UPRN: "123777" } }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "70 Bean Road", UPRN: "111" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=111")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=111")
.to_return(status: 200, body: address_body, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=111")
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=111")
.to_return(status: 200, body: uprn_body, headers: {})
end

2
spec/services/address_client_spec.rb

@ -8,7 +8,7 @@ describe AddressClient do
end
def stub_api_request(body:, status: 200)
stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.4&query=123")
stub_request(:get, "https://api.os.uk/search/places/v1/find?fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&maxresults=10&minmatch=0.4&query=123")
.to_return(status:, body:, headers: {})
end

19
spec/services/bulk_upload/lettings/year2025/csv_parser_spec.rb

@ -251,4 +251,23 @@ RSpec.describe BulkUpload::Lettings::Year2025::CsvParser do
end
end
end
context "when parsing csv with data of the wrong type" do
let(:log_to_csv) { BulkUpload::LettingsLogToCsv.new(log:) }
let(:field_numbers) { log_to_csv.default_2025_field_numbers }
let(:field_values) { log_to_csv.to_2025_row }
before do
field_46_index = field_numbers.index(46)
field_values[field_46_index] = "GBR" # should be a 3 digit code
file.write(log_to_csv.custom_field_numbers_row(field_numbers:))
file.write(log_to_csv.to_custom_csv_row(field_values:))
file.rewind
end
it "sets the invalid data to nil" do
expect(service.row_parsers[0].field_46).to be_nil
end
end
end

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

@ -644,6 +644,22 @@ RSpec.describe BulkUpload::Lettings::Year2025::RowParser do
expect(parser.errors[:field_116]).to include(match I18n.t("validations.lettings.2025.bulk_upload.invalid_option", question: ""))
end
end
describe "invalid fields" do
let(:attributes) { setup_section_params.merge({ field_45: 0 }) }
context "when a field has been marked as invalid" do
before do
parser.add_invalid_field("field_45")
end
it "sets a single error on that field" do
parser.valid?
expect(parser.errors[:field_45].size).to eq(1)
expect(parser.errors[:field_45]).to include(I18n.t("validations.lettings.2025.bulk_upload.invalid_option", question: "What is the lead tenant’s nationality?"))
end
end
end
end
end

19
spec/services/bulk_upload/lettings/year2026/csv_parser_spec.rb

@ -251,4 +251,23 @@ RSpec.describe BulkUpload::Lettings::Year2026::CsvParser do
end
end
end
context "when parsing csv with data of the wrong type" do
let(:log_to_csv) { BulkUpload::LettingsLogToCsv.new(log:) }
let(:field_numbers) { log_to_csv.default_2026_field_numbers }
let(:field_values) { log_to_csv.to_2026_row }
before do
field_46_index = field_numbers.index(46)
field_values[field_46_index] = "GBR" # should be a 3 digit code
file.write(log_to_csv.custom_field_numbers_row(field_numbers:))
file.write(log_to_csv.to_custom_csv_row(field_values:))
file.rewind
end
it "sets the invalid data to nil" do
expect(service.row_parsers[0].field_46).to be_nil
end
end
end

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

@ -536,6 +536,22 @@ RSpec.describe BulkUpload::Lettings::Year2026::RowParser do
end
end
end
describe "invalid fields" do
let(:attributes) { setup_section_params.merge({ field_46: 0 }) }
context "when a field has been marked as invalid" do
before do
parser.add_invalid_field("field_46")
end
it "sets a single error on that field" do
parser.valid?
expect(parser.errors[:field_46].size).to eq(1)
expect(parser.errors[:field_46]).to include(match(I18n.t("validations.lettings.2026.bulk_upload.invalid_option", question: "What is the lead tenant’s nationality?")))
end
end
end
end
context "when setup section not complete" do

19
spec/services/bulk_upload/sales/year2025/csv_parser_spec.rb

@ -188,4 +188,23 @@ RSpec.describe BulkUpload::Sales::Year2025::CsvParser do
expect(service.row_parsers[0].field_16).to eql(log.uprn)
end
end
context "when parsing csv with data of the wrong type" do
let(:log_to_csv) { BulkUpload::SalesLogToCsv.new(log:) }
let(:field_numbers) { log_to_csv.default_field_numbers_for_year(2025) }
let(:field_values) { log_to_csv.to_2025_row }
before do
field_32_index = field_numbers.index(32)
field_values[field_32_index] = "abc" # should be an integer
file.write(log_to_csv.custom_field_numbers_row(field_numbers:))
file.write(log_to_csv.to_custom_csv_row(field_values:))
file.rewind
end
it "sets the invalid data to nil" do
expect(service.row_parsers[0].field_32).to be_nil
end
end
end

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

@ -336,6 +336,22 @@ RSpec.describe BulkUpload::Sales::Year2025::RowParser do
expect(parser.errors[:field_32]).to include(match I18n.t("validations.sales.2025.bulk_upload.invalid_option", question: ""))
end
end
describe "invalid fields" do
let(:attributes) { setup_section_params.merge({ field_31: 0 }) }
context "when a field has been marked as invalid" do
before do
parser.add_invalid_field("field_31")
end
it "sets a single error on that field" do
parser.valid?
expect(parser.errors[:field_31].size).to eq(1)
expect(parser.errors[:field_31]).to include(match(I18n.t("validations.sales.2025.bulk_upload.invalid_option", question: "What is buyer 1’s nationality?")))
end
end
end
end
end

19
spec/services/bulk_upload/sales/year2026/csv_parser_spec.rb

@ -188,4 +188,23 @@ RSpec.describe BulkUpload::Sales::Year2026::CsvParser do
expect(service.row_parsers[0].field_16).to eql(log.uprn)
end
end
context "when parsing csv with data of the wrong type" do
let(:log_to_csv) { BulkUpload::SalesLogToCsv.new(log:) }
let(:field_numbers) { log_to_csv.default_field_numbers_for_year(2026) }
let(:field_values) { log_to_csv.to_2026_row }
before do
field_34_index = field_numbers.index(34)
field_values[field_34_index] = "abc" # should be an integer
file.write(log_to_csv.custom_field_numbers_row(field_numbers:))
file.write(log_to_csv.to_custom_csv_row(field_values:))
file.rewind
end
it "sets the invalid data to nil" do
expect(service.row_parsers[0].field_34).to be_nil
end
end
end

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

@ -342,6 +342,22 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
expect(parser.errors[:field_35]).to include(match I18n.t("validations.sales.2026.bulk_upload.invalid_option", question: ""))
end
end
describe "invalid fields" do
let(:attributes) { setup_section_params.merge({ field_34: 0 }) }
context "when a field has been marked as invalid" do
before do
parser.add_invalid_field("field_34")
end
it "sets a single error on that field" do
parser.valid?
expect(parser.errors[:field_34].size).to eq(1)
expect(parser.errors[:field_34]).to include(match(I18n.t("validations.sales.2026.bulk_upload.invalid_option", question: "What is buyer 1's nationality?")))
end
end
end
end
end

2
spec/services/exports/lettings_log_export_service_spec.rb

@ -158,7 +158,7 @@ RSpec.describe Exports::LettingsLogExportService do
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=100023336956")
stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=100023336956")
.to_return(status: 200, body: '{"status":200,"results":[{"DPA":{
"PO_BOX_NUMBER": "fake",
"ORGANISATION_NAME": "org",

2
spec/services/uprn_client_spec.rb

@ -8,7 +8,7 @@ describe UprnClient do
end
def stub_api_request(body:, status: 200)
stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=123")
stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA%2CLPI&fq=COUNTRY_CODE%3AE&key=OS_DATA_KEY&uprn=123")
.to_return(status:, body:, headers: {})
end

6
yarn.lock

@ -2619,9 +2619,9 @@ flatpickr@^4.6.9:
integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==
flatted@^3.2.9, flatted@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a"
integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==
version "3.4.2"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
for-each@^0.3.3:
version "0.3.3"

Loading…
Cancel
Save