Browse Source

CLDC-4166: merge in latest main

pull/3194/head
Nat Dean-Lewis 1 week ago
parent
commit
4899d9549d
  1. 15
      .github/workflows/aws_deploy.yml
  2. 24
      .github/workflows/review_app_prompt.yml
  3. 83
      .github/workflows/review_pipeline.yml
  4. 4
      app/helpers/bulk_upload/sales_log_to_csv.rb
  5. 15
      app/models/form/lettings/questions/layear.rb
  6. 2
      app/models/form/lettings/questions/waityear.rb
  7. 14
      app/models/form/sales/pages/service_charge_changed.rb
  8. 27
      app/models/form/sales/questions/has_service_charges_changed.rb
  9. 17
      app/models/form/sales/questions/new_service_charges.rb
  10. 1
      app/models/form/sales/subsections/shared_ownership_staircasing_transaction.rb
  11. 6
      app/models/log.rb
  12. 4
      app/models/sales_log.rb
  13. 9
      app/models/validations/sales/financial_validations.rb
  14. 4
      app/services/bulk_upload/sales/year2026/csv_parser.rb
  15. 12
      app/services/bulk_upload/sales/year2026/row_parser.rb
  16. 6
      app/services/exports/sales_log_export_constants.rb
  17. 3
      app/services/exports/sales_log_export_service.rb
  18. 2
      app/views/form/guidance/_address_fallback.html.erb
  19. 13
      config/locales/forms/2026/sales/sale_information.en.yml
  20. 4
      config/locales/validations/sales/financial.en.yml
  21. 8
      db/migrate/20260305095832_add_service_charge_changed_to_sales_logs.rb
  22. 6
      db/schema.rb
  23. 2
      spec/fixtures/exports/sales_log_26_27.xml
  24. 10
      spec/fixtures/files/2026_27_sales_bulk_upload.csv
  25. 2
      spec/fixtures/files/lettings_log_csv_export_labels_25.csv
  26. 2
      spec/fixtures/files/lettings_log_csv_export_labels_26.csv
  27. 2
      spec/fixtures/files/lettings_log_csv_export_non_support_labels_25.csv
  28. 2
      spec/fixtures/files/lettings_log_csv_export_non_support_labels_26.csv
  29. 6
      spec/fixtures/files/sales_logs_csv_export_codes_26.csv
  30. 6
      spec/fixtures/files/sales_logs_csv_export_labels_26.csv
  31. 6
      spec/fixtures/files/sales_logs_csv_export_non_support_codes_26.csv
  32. 6
      spec/fixtures/files/sales_logs_csv_export_non_support_labels_26.csv
  33. 2
      spec/fixtures/variable_definitions/sales_download_26_27.csv
  34. 2
      spec/lib/tasks/log_variable_definitions_spec.rb
  35. 23
      spec/models/form/lettings/questions/layear_spec.rb
  36. 2
      spec/models/form/lettings/questions/waityear_spec.rb
  37. 31
      spec/models/form/sales/pages/service_charge_changed_spec.rb
  38. 56
      spec/models/form/sales/questions/has_service_charges_changed_spec.rb
  39. 53
      spec/models/form/sales/questions/new_service_charges_spec.rb
  40. 1
      spec/models/form/sales/subsections/shared_ownership_staircasing_transaction_spec.rb
  41. 59
      spec/models/validations/sales/financial_validations_spec.rb
  42. 2
      spec/services/bulk_upload/sales/year2026/row_parser_spec.rb
  43. 101
      spec/services/exports/sales_log_export_service_spec.rb

15
.github/workflows/aws_deploy.yml

@ -22,6 +22,10 @@ on:
release_tag:
required: false
type: string
ref:
required: false
type: string
default: ""
concurrency:
group: deploy-${{ inputs.environment }}${{ inputs.concurrency_tag }}
@ -42,6 +46,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.sha }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
@ -53,16 +59,19 @@ jobs:
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Resolve commit SHA
run: echo "commit_sha=${{ inputs.ref || github.sha }}" >> $GITHUB_ENV
- name: Check if image with tag already exists
run: |
echo "image-exists=$(if aws ecr list-images --repository-name=$repository --query "imageIds[*].imageTag" | grep -q ${{ github.sha }}; then echo true; else echo false; fi)" >> $GITHUB_ENV
echo "image-exists=$(if aws ecr describe-images --repository-name=$repository --image-ids imageTag=${{ env.commit_sha }} > /dev/null 2>&1; then echo true; else echo false; fi)" >> $GITHUB_ENV
- name: Build, tag, and push docker image to ECR if there is no image, failing for releases
id: build-image
if: ${{ env.image-exists == 'false' }}
env:
registry: ${{ steps.ecr-login.outputs.registry }}
commit_tag: ${{ github.sha }}
commit_tag: ${{ env.commit_sha }}
run: |
if [[ ${{ inputs.environment }} == 'production' ]]; then
echo "Error: Deployment to production environment is not allowed as there is no docker image (i.e. the AWS deploy on staging was unsuccessful for this commit)."
@ -100,7 +109,7 @@ jobs:
id: update-image-tags
env:
registry: ${{ steps.ecr-login.outputs.registry }}
commit_tag: ${{ github.sha }}
commit_tag: ${{ inputs.ref || github.sha }}
readable_tag: ${{ inputs.environment }}-${{ env.additional-tag }}
run: |
manifest=$(aws ecr batch-get-image --repository-name $repository --image-ids imageTag=$commit_tag --output text --query images[].imageManifest)

24
.github/workflows/review_app_prompt.yml

@ -0,0 +1,24 @@
name: Review app deploy prompt
on:
pull_request:
types: [opened]
jobs:
prompt:
name: Add review app deploy instructions
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment with deploy instructions
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'To deploy a review app for this PR, comment `/deploy-review`.',
});

83
.github/workflows/review_pipeline.yml

@ -1,57 +1,94 @@
name: Review app pipeline
concurrency:
group: review-${{ github.event.pull_request.number }}
on:
pull_request:
types:
- opened
- synchronize
- reopened
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
required: true
type: string
description: "The number of the PR for which to deploy a review app. Note: this is NOT the ticket number"
defaults:
run:
shell: bash
permissions: {}
jobs:
get_pr_details:
name: Get PR details
if: github.event_name == 'workflow_dispatch' || (github.event.issue.pull_request && startsWith(github.event.comment.body, '/deploy-review'))
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.get_pr_details.outputs.pr_number }}
pr_head_sha: ${{ steps.get_pr_details.outputs.pr_head_sha }}
steps:
- name: Get PR number and HEAD SHA
id: get_pr_details
uses: actions/github-script@v7
with:
script: |
let prNumber;
if (context.eventName === 'workflow_dispatch') {
prNumber = '${{ inputs.pr_number }}';
} else {
prNumber = context.issue.number.toString();
}
core.setOutput('pr_number', prNumber);
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: parseInt(prNumber),
});
core.setOutput('pr_head_sha', pr.head.sha);
infra:
name: Deploy review app infrastructure
needs: [get_pr_details]
uses: communitiesuk/submit-social-housing-lettings-and-sales-data-infrastructure/.github/workflows/create_review_app_infra.yml@main
with:
key: ${{ github.event.pull_request.number }}
key: ${{ needs.get_pr_details.outputs.pr_number }}
app_repo_role: arn:aws:iam::815624722760:role/core-application-repo
permissions:
id-token: write
code:
name: Deploy review app code
needs: [infra]
needs: [get_pr_details, infra]
uses: ./.github/workflows/aws_deploy.yml
with:
aws_account_id: 837698168072
aws_role_prefix: core-dev
aws_task_prefix: core-review-${{ github.event.pull_request.number }}
concurrency_tag: ${{ github.event.pull_request.number }}
aws_task_prefix: core-review-${{ needs.get_pr_details.outputs.pr_number }}
concurrency_tag: ${{ needs.get_pr_details.outputs.pr_number }}
environment: review
ref: ${{ needs.get_pr_details.outputs.pr_head_sha }}
permissions:
id-token: write
comment:
name: Add link to PR
needs: [code]
needs: [get_pr_details, code]
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Comment on PR with URL
uses: unsplash/comment-on-pr@v1.3.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@v7
with:
msg: "Created review app at https://review.submit-social-housing-data.communities.gov.uk/${{ github.event.pull_request.number }}. Note that the review app will be automatically deprovisioned after 30 days and will need the review app pipeline running again."
check_for_duplicate_msg: true
duplicate_msg_pattern: Created review app at*
script: |
const prNumber = ${{ needs.get_pr_details.outputs.pr_number }};
const body = `Created review app at https://review.submit-social-housing-data.communities.gov.uk/${prNumber}. Note that the review app will be automatically deprovisioned after 30 days and will need the review app pipeline running again.`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const duplicate = comments.find(c => c.body.startsWith('Created review app at'));
if (!duplicate) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: body,
});
}

4
app/helpers/bulk_upload/sales_log_to_csv.rb

@ -678,7 +678,9 @@ class BulkUpload::SalesLogToCsv
log.gender_same_as_sex5,
log.gender_description5,
log.gender_same_as_sex6,
log.gender_description6, # 134
log.gender_description6,
log.hasservicechargeschanged,
log.newservicecharges, # 136
]
end

15
app/models/form/lettings/questions/layear.rb

@ -8,7 +8,20 @@ class Form::Lettings::Questions::Layear < ::Form::Question
end
def answer_options
if form.start_year_2024_or_later?
if form.start_year_2025_or_later?
{
"1" => { "value" => "Just moved to local authority area with this new let" },
"2" => { "value" => "Under 1 year" },
"7" => { "value" => "1 year but under 2 years" },
"8" => { "value" => "2 years but under 3 years" },
"9" => { "value" => "3 years but under 4 years" },
"10" => { "value" => "4 years but under 5 years" },
"11" => { "value" => "5 years but under 10 years" },
"12" => { "value" => "10 years or more" },
"divider" => { "value" => true },
"6" => { "value" => "Don’t know" },
}
elsif form.start_year_2024_or_later?
{
"1" => { "value" => "Just moved to local authority area with this new let" },
"2" => { "value" => "Less than 1 year" },

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

@ -11,7 +11,7 @@ class Form::Lettings::Questions::Waityear < ::Form::Question
if form.start_year_2025_or_later?
return {
"13" => { "value" => "Household not on the housing register (or waiting list) in this area" },
"2" => { "value" => "Less than 1 year" },
"2" => { "value" => "Under 1 year" },
"7" => { "value" => "1 year but under 2 years" },
"8" => { "value" => "2 years but under 3 years" },
"9" => { "value" => "3 years but under 4 years" },

14
app/models/form/sales/pages/service_charge_changed.rb

@ -0,0 +1,14 @@
class Form::Sales::Pages::ServiceChargeChanged < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "service_charge_changed"
@copy_key = "sales.sale_information.servicecharges_changed"
end
def questions
@questions ||= [
Form::Sales::Questions::HasServiceChargesChanged.new(nil, nil, self),
Form::Sales::Questions::NewServiceCharges.new(nil, nil, self),
]
end
end

27
app/models/form/sales/questions/has_service_charges_changed.rb

@ -0,0 +1,27 @@
class Form::Sales::Questions::HasServiceChargesChanged < ::Form::Question
def initialize(id, hsh, page)
super
@id = "hasservicechargeschanged"
@type = "radio"
@answer_options = ANSWER_OPTIONS
@conditional_for = {
"newservicecharges" => [1],
}
@hidden_in_check_answers = {
"depends_on" => [
{
"hasservicechargeschanged" => 1,
},
],
}
@copy_key = "sales.sale_information.servicecharges_changed.has_service_charges_changed"
@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" => "Yes" },
"2" => { "value" => "No" },
}.freeze
QUESTION_NUMBER_FROM_YEAR = { 2026 => 0 }.freeze
end

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

@ -0,0 +1,17 @@
class Form::Sales::Questions::NewServiceCharges < ::Form::Question
def initialize(id, hsh, page)
super
@id = "newservicecharges"
@type = "numeric"
@min = 0
@max = 9999.99
@step = 0.01
@width = 5
@prefix = "£"
@copy_key = "sales.sale_information.servicecharges_changed.new_service_charges"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@strip_commas = true
end
QUESTION_NUMBER_FROM_YEAR = { 2026 => 0 }.freeze
end

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

@ -26,6 +26,7 @@ class Form::Sales::Subsections::SharedOwnershipStaircasingTransaction < ::Form::
Form::Sales::Pages::MonthlyRentStaircasingOwned.new(nil, nil, self),
Form::Sales::Pages::MonthlyRentStaircasing.new(nil, nil, self),
(Form::Sales::Pages::ServiceChargeStaircasing.new("service_charge_staircasing", nil, self) if form.start_year_2026_or_later?),
(Form::Sales::Pages::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),
].compact
end

6
app/models/log.rb

@ -346,9 +346,9 @@ class Log < ApplicationRecord
end
def clear_gender_description_unless_gender_not_same_as_sex!
# we do this as the gender same as sex page always contains the gender description box that's hidden
# default submit will send a "" for gender description. this ensure it's nil in this case
# as well as blanking it if the user writes it in mistakenly in bulk upload
# gender_description is always routed to (even when hidden on the page), so default submit will set it as ""
# This method ensures gender_description is cleared if gender is the same as sex
# This also has the benefit of clearing a mistakenly input gender_description in bulk upload if gender is the same as sex
max_person = lettings? ? 8 : 6
(1..max_person).each do |person_index|
gender_same_as_sex = public_send("gender_same_as_sex#{person_index}")

4
app/models/sales_log.rb

@ -583,4 +583,8 @@ class SalesLog < Log
def mscharge_value
mscharge if discounted_ownership_sale? || !form.start_year_2025_or_later?
end
def hasservicechargeschanged_label
form.get_question(:hasservicechargeschanged, self)&.label_from_value(hasservicechargeschanged)
end
end

9
app/models/validations/sales/financial_validations.rb

@ -139,6 +139,15 @@ module Validations::Sales::FinancialValidations
end
end
def validate_newservicecharges_different_from_mscharge(record)
return unless record.hasservicechargeschanged == 1 && record.newservicecharges && record.has_mscharge == 1 && record.mscharge
if record.newservicecharges == record.mscharge
record.errors.add :newservicecharges, I18n.t("validations.sales.financial.newservicecharges.same_as_previous")
record.errors.add :mscharge, I18n.t("validations.sales.financial.mscharge.same_as_new")
end
end
private
def is_relationship_child?(relationship)

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

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

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

@ -149,6 +149,8 @@ class BulkUpload::Sales::Year2026::RowParser
field_132: "If 'No', enter person 5's gender identity",
field_133: "Is the gender person 6 identifies with the same as their sex registered at birth?",
field_134: "If 'No', enter person 6's gender identity",
field_135: "Will the service charge change after this staircasing transaction takes place?",
field_136: "What are the new total monthly service charges for the property?",
}.freeze
ERROR_BASE_KEY = "validations.sales.2026.bulk_upload".freeze
@ -328,6 +330,9 @@ class BulkUpload::Sales::Year2026::RowParser
attribute :field_133, :integer
attribute :field_134, :string
attribute :field_135, :integer
attribute :field_136, :decimal
validates :field_1,
presence: {
message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "sale completion date (day)."),
@ -863,6 +868,7 @@ private
sexrab4: %i[field_48],
sexrab5: %i[field_52],
sexrab6: %i[field_56],
buildheightclass: %i[field_122],
gender_same_as_sex1: %i[field_123],
@ -877,6 +883,9 @@ private
gender_description5: %i[field_132],
gender_same_as_sex6: %i[field_133],
gender_description6: %i[field_134],
hasservicechargeschanged: %i[field_135],
newservicecharges: %i[field_136],
}
end
@ -926,6 +935,9 @@ private
attributes["gender_same_as_sex6"] = field_133
attributes["gender_description6"] = field_134
attributes["hasservicechargeschanged"] = field_135
attributes["newservicecharges"] = field_136
attributes["relat2"] = relationship_from_is_partner(field_34)
attributes["relat3"] = relationship_from_is_partner(field_42)
attributes["relat4"] = relationship_from_is_partner(field_46)

6
app/services/exports/sales_log_export_constants.rb

@ -157,7 +157,11 @@ module Exports::SalesLogExportConstants
YEAR_2025_EXPORT_FIELDS << "SEX#{index}"
end
YEAR_2026_EXPORT_FIELDS = Set["BUILDHEIGHTCLASS"]
YEAR_2026_EXPORT_FIELDS = Set[
"BUILDHEIGHTCLASS",
"HASSERVICECHARGESCHANGED",
"NEWSERVICECHARGES",
]
(1..6).each do |index|
YEAR_2026_EXPORT_FIELDS << "SEXRAB#{index}"

3
app/services/exports/sales_log_export_service.rb

@ -135,6 +135,9 @@ module Exports
attribute_hash["hasestatefee"] = sales_log.has_management_fee
attribute_hash["estatefee"] = sales_log.management_fee
attribute_hash["hasservicechargeschanged"] = sales_log.hasservicechargeschanged
attribute_hash["newservicecharges"] = sales_log.newservicecharges
attribute_hash["stairlastday"] = sales_log.lasttransaction&.day
attribute_hash["stairlastmonth"] = sales_log.lasttransaction&.month
attribute_hash["stairlastyear"] = sales_log.lasttransaction&.year

2
app/views/form/guidance/_address_fallback.html.erb

@ -1,3 +1,3 @@
<div class="govuk-button-group">
<%= govuk_link_to "Clear address and search instead", address_search_input_path(@log.log_type, @log.id), class: "govuk-button govuk-button--secondary" %>
<%= govuk_link_to "Clear address and search by UPRN instead", address_search_input_path(@log.log_type, @log.id), class: "govuk-button govuk-button--secondary" %>
</div>

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

@ -285,6 +285,19 @@ en:
hint_text: ""
question_text: "Enter the total monthly charge"
servicecharges_changed:
page_header: ""
has_service_charges_changed:
check_answer_label: "Service charge will change"
check_answer_prompt: "Tell us if the service charge will change"
hint_text: "This includes any charges for day-to-day maintenance and repairs, building insurance, and any contributions to a sinking or reserved fund. It does not include estate management fees."
question_text: "Will the service charge change after this staircasing transaction takes place?"
new_service_charges:
check_answer_label: "New monthly service charges"
check_answer_prompt: ""
hint_text: ""
question_text: "Enter the new total monthly charge"
purchase_price:
discounted_ownership:
page_header: "About the price of the property"

4
config/locales/validations/sales/financial.en.yml

@ -48,6 +48,10 @@ en:
mscharge:
monthly_leasehold_charges:
not_zero: "Monthly leasehold charges cannot be £0 if the property has monthly charges."
same_as_new: "You said that the service charge will change and you entered the same amount as the new service charge. If the service charge will not change, answer 'No' to that question before entering the service charge here."
newservicecharges:
same_as_previous: "You said that the service charge will change and you entered the same amount as the previous question. If the service charge will not change, answer 'No'."
resale:
equity_over_max: "The maximum initial equity stake is %{max_equity}%."

8
db/migrate/20260305095832_add_service_charge_changed_to_sales_logs.rb

@ -0,0 +1,8 @@
class AddServiceChargeChangedToSalesLogs < ActiveRecord::Migration[7.2]
def change
change_table :sales_logs, bulk: true do |t|
t.column :hasservicechargeschanged, :integer
t.column :newservicecharges, :decimal, precision: 10, scale: 2
end
end
end

6
db/schema.rb

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_02_25_135309) do
ActiveRecord::Schema[7.2].define(version: 2026_03_05_095832) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -824,8 +824,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_25_135309) do
t.string "sexrab4"
t.string "sexrab5"
t.string "sexrab6"
t.integer "mortlen_known"
t.integer "buildheightclass"
t.integer "mortlen_known"
t.integer "hasservicechargeschanged"
t.decimal "newservicecharges", precision: 10, scale: 2
t.integer "gender_same_as_sex1"
t.integer "gender_same_as_sex2"
t.integer "gender_same_as_sex3"

2
spec/fixtures/exports/sales_log_26_27.xml vendored

@ -163,5 +163,7 @@
<MSCHARGE_VALUE_CHECK/>
<DUPLICATESET/>
<STAIRCASETOSALE/>
<HASSERVICECHARGESCHANGED/>
<NEWSERVICECHARGES/>
</form>
</forms>

10
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/lettings_log_csv_export_labels_25.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/lettings_log_csv_export_labels_26.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/lettings_log_csv_export_non_support_labels_25.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/files/lettings_log_csv_export_non_support_labels_26.csv vendored

File diff suppressed because one or more lines are too long

6
spec/fixtures/files/sales_logs_csv_export_codes_26.csv vendored

File diff suppressed because one or more lines are too long

6
spec/fixtures/files/sales_logs_csv_export_labels_26.csv vendored

File diff suppressed because one or more lines are too long

6
spec/fixtures/files/sales_logs_csv_export_non_support_codes_26.csv vendored

File diff suppressed because one or more lines are too long

6
spec/fixtures/files/sales_logs_csv_export_non_support_labels_26.csv vendored

File diff suppressed because one or more lines are too long

2
spec/fixtures/variable_definitions/sales_download_26_27.csv vendored

@ -17,4 +17,6 @@ gender_same_as_sex5,Is the gender person 5 identifies with the same as their sex
gender_description5,If 'No', enter person 5's gender identity
gender_same_as_sex6,Is the gender person 6 identifies with the same as their sex registered at birth?
gender_description6,If 'No', enter person 6's gender identity
hasservicechargeschanged,Will the service charge change after this staircasing transaction takes place?
newservicecharges,What are the new total monthly service charges for the property?
hholdcount,In total, how many people live in the property?

1 sexrab1,What was buyer 1's sex at birth?
17 gender_description5,If 'No', enter person 5's gender identity
18 gender_same_as_sex6,Is the gender person 6 identifies with the same as their sex registered at birth?
19 gender_description6,If 'No', enter person 6's gender identity
20 hasservicechargeschanged,Will the service charge change after this staircasing transaction takes place?
21 newservicecharges,What are the new total monthly service charges for the property?
22 hholdcount,In total, how many people live in the property?

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) { 464 }
let(:total_variable_definitions_count) { 466 }
before do
Rake.application.rake_require("tasks/log_variable_definitions")

23
spec/models/form/lettings/questions/layear_spec.rb

@ -10,7 +10,7 @@ RSpec.describe Form::Lettings::Questions::Layear, type: :model do
let(:form) { instance_double(Form, start_date: Time.zone.local(2023, 4, 1)) }
before do
allow(form).to receive(:start_year_2024_or_later?).and_return(false)
allow(form).to receive_messages(start_year_2024_or_later?: false, start_year_2025_or_later?: false)
allow(page).to receive(:subsection).and_return(subsection)
allow(subsection).to receive(:form).and_return(form)
end
@ -68,6 +68,27 @@ RSpec.describe Form::Lettings::Questions::Layear, type: :model do
end
end
context "with 2025/26 form" do
before do
allow(form).to receive(:start_year_2025_or_later?).and_return(true)
end
it "has the correct answer_options" do
expect(question.answer_options).to eq({
"1" => { "value" => "Just moved to local authority area with this new let" },
"2" => { "value" => "Under 1 year" },
"7" => { "value" => "1 year but under 2 years" },
"8" => { "value" => "2 years but under 3 years" },
"9" => { "value" => "3 years but under 4 years" },
"10" => { "value" => "4 years but under 5 years" },
"11" => { "value" => "5 years but under 10 years" },
"12" => { "value" => "10 years or more" },
"divider" => { "value" => true },
"6" => { "value" => "Don’t know" },
})
end
end
it "has the correct check_answers_card_number" do
expect(question.check_answers_card_number).to eq(0)
end

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

@ -85,7 +85,7 @@ RSpec.describe Form::Lettings::Questions::Waityear, type: :model do
it "has the correct answer_options" do
expect(question.answer_options).to eq({
"13" => { "value" => "Household not on the housing register (or waiting list) in this area" },
"2" => { "value" => "Less than 1 year" },
"2" => { "value" => "Under 1 year" },
"7" => { "value" => "1 year but under 2 years" },
"8" => { "value" => "2 years but under 3 years" },
"9" => { "value" => "3 years but under 4 years" },

31
spec/models/form/sales/pages/service_charge_changed_spec.rb

@ -0,0 +1,31 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::ServiceChargeChanged, 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: collection_start_date_for_year(2026))) }
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[hasservicechargeschanged newservicecharges])
end
it "has the correct id" do
expect(page.id).to eq("service_charge_changed")
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

56
spec/models/form/sales/questions/has_service_charges_changed_spec.rb

@ -0,0 +1,56 @@
require "rails_helper"
RSpec.describe Form::Sales::Questions::HasServiceChargesChanged, type: :model do
include CollectionTimeHelper
subject(:question) { described_class.new(question_id, question_definition, page) }
let(:question_id) { nil }
let(:question_definition) { nil }
let(:subsection) { instance_double(Form::Subsection, form: instance_double(Form, start_date:)) }
let(:page) { instance_double(Form::Page, subsection:) }
let(:start_date) { collection_start_date_for_year(2026) }
it "has correct page" do
expect(question.page).to eq(page)
end
it "has the correct id" do
expect(question.id).to eq("hasservicechargeschanged")
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" => "Yes" },
"2" => { "value" => "No" },
})
end
it "has correct conditional for" do
expect(question.conditional_for).to eq({
"newservicecharges" => [1],
})
end
it "has correct hidden_in_check_answers for" do
expect(question.hidden_in_check_answers).to eq({
"depends_on" => [
{
"hasservicechargeschanged" => 1,
},
],
})
end
it "has the correct question number" do
expect(question.question_number).to eq(0)
end
end

53
spec/models/form/sales/questions/new_service_charges_spec.rb

@ -0,0 +1,53 @@
require "rails_helper"
RSpec.describe Form::Sales::Questions::NewServiceCharges, type: :model do
include CollectionTimeHelper
subject(:question) { described_class.new(question_id, question_definition, page) }
let(:question_id) { nil }
let(:question_definition) { nil }
let(:subsection) { instance_double(Form::Subsection, form: instance_double(Form, start_date:)) }
let(:page) { instance_double(Form::Page, subsection:) }
let(:start_date) { collection_start_date_for_year(2026) }
it "has correct page" do
expect(question.page).to eq(page)
end
it "has the correct id" do
expect(question.id).to eq("newservicecharges")
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 the correct width" do
expect(question.width).to be 5
end
it "has the correct min" do
expect(question.min).to be 0
end
it "has the correct max" do
expect(question.max).to be 9999.99
end
it "has the correct step" do
expect(question.step).to be 0.01
end
it "has the correct prefix" do
expect(question.prefix).to eq("£")
end
it "has the correct question number" do
expect(question.question_number).to eq(0)
end
end

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

@ -77,6 +77,7 @@ RSpec.describe Form::Sales::Subsections::SharedOwnershipStaircasingTransaction,
monthly_rent_staircasing_owned
monthly_rent_staircasing
service_charge_staircasing
service_charge_changed
monthly_charges_shared_ownership_value_check
],
)

59
spec/models/validations/sales/financial_validations_spec.rb

@ -478,4 +478,63 @@ RSpec.describe Validations::Sales::FinancialValidations do
expect(record.errors).to be_empty
end
end
describe "#validate_newservicecharges_different_from_mscharge" do
let(:record) { FactoryBot.build(:sales_log, ownershipsch: 1, staircase: 1) }
it "does not add errors when hasservicechargeschanged is nil" do
record.hasservicechargeschanged = nil
record.newservicecharges = 100
record.has_mscharge = 1
record.mscharge = 100
financial_validator.validate_newservicecharges_different_from_mscharge(record)
expect(record.errors).to be_empty
end
it "does not add errors when hasservicechargeschanged is 2 (No)" do
record.hasservicechargeschanged = 2
record.newservicecharges = 100
record.has_mscharge = 1
record.mscharge = 100
financial_validator.validate_newservicecharges_different_from_mscharge(record)
expect(record.errors).to be_empty
end
it "does not add errors when newservicecharges is nil" do
record.hasservicechargeschanged = 1
record.newservicecharges = nil
record.has_mscharge = 1
record.mscharge = 100
financial_validator.validate_newservicecharges_different_from_mscharge(record)
expect(record.errors).to be_empty
end
it "does not add errors when mscharge is nil" do
record.hasservicechargeschanged = 1
record.newservicecharges = 100
record.has_mscharge = 2
record.mscharge = nil
financial_validator.validate_newservicecharges_different_from_mscharge(record)
expect(record.errors).to be_empty
end
it "does not add errors when newservicecharges is different from mscharge" do
record.hasservicechargeschanged = 1
record.newservicecharges = 150
record.has_mscharge = 1
record.mscharge = 100
financial_validator.validate_newservicecharges_different_from_mscharge(record)
expect(record.errors).to be_empty
end
it "adds an error when hasservicechargeschanged is 1 (Yes) and newservicecharges equals mscharge" do
record.hasservicechargeschanged = 1
record.newservicecharges = 100
record.has_mscharge = 1
record.mscharge = 100
financial_validator.validate_newservicecharges_different_from_mscharge(record)
expect(record.errors["newservicecharges"]).to include(match I18n.t("validations.sales.financial.newservicecharges.same_as_previous"))
expect(record.errors["mscharge"]).to include(match I18n.t("validations.sales.financial.mscharge.same_as_new"))
end
end
end

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

@ -116,6 +116,8 @@ RSpec.describe BulkUpload::Sales::Year2026::RowParser do
field_123: "1",
field_125: "2",
field_126: "Non-binary",
field_135: "1",
field_136: "150",
}
end

101
spec/services/exports/sales_log_export_service_spec.rb

@ -451,18 +451,12 @@ RSpec.describe Exports::SalesLogExportService do
end
context "with shared ownership and mscharge" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, ownershipsch: 1, staircase: 2, type: 30, mscharge: 321, has_management_fee: 1, management_fee: 222) }
def replace_mscharge_and_shared_ownership_values(export_file)
def replace_shared_ownership_values(export_file)
export_file.sub!("<HASSERVICECHARGES/>", "<HASSERVICECHARGES>1</HASSERVICECHARGES>")
export_file.sub!("<SERVICECHARGES/>", "<SERVICECHARGES>321.0</SERVICECHARGES>")
export_file.sub!("<HASESTATEFEE/>", "<HASESTATEFEE>1</HASESTATEFEE>")
export_file.sub!("<ESTATEFEE/>", "<ESTATEFEE>222.0</ESTATEFEE>")
export_file.sub!("<MSCHARGE>100.0</MSCHARGE>", "<MSCHARGE/>")
export_file.sub!("<HASMSCHARGE>1</HASMSCHARGE>", "<HASMSCHARGE/>")
export_file.sub!("<TYPE>8</TYPE>", "<TYPE>30</TYPE>")
export_file.sub!("<STAIRCASE/>", "<STAIRCASE>2</STAIRCASE>")
export_file.sub!("<GRANT>10000.0</GRANT>", "<GRANT/>")
export_file.sub!("<PPCODENK>0</PPCODENK>", "<PPCODENK>1</PPCODENK>")
export_file.sub!("<PPOSTC1>SW1A</PPOSTC1>", "<PPOSTC1/>")
@ -474,16 +468,93 @@ RSpec.describe Exports::SalesLogExportService do
export_file.sub!("<PREVLOCNAME>Westminster</PREVLOCNAME>", "<PREVLOCNAME/>")
end
it "exports mscharge fields as hasmscharge and mscharge" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expected_content = replace_mscharge_and_shared_ownership_values(expected_content)
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)
context "when not staircasing" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, ownershipsch: 1, staircase: 2, type: 30, mscharge: 321, has_management_fee: 1, management_fee: 222) }
def replace_non_staircasing_values(export_file)
export_file.sub!("<HASESTATEFEE/>", "<HASESTATEFEE>1</HASESTATEFEE>")
export_file.sub!("<ESTATEFEE/>", "<ESTATEFEE>222.0</ESTATEFEE>")
export_file.sub!("<TYPE>8</TYPE>", "<TYPE>30</TYPE>")
export_file.sub!("<STAIRCASE/>", "<STAIRCASE>2</STAIRCASE>")
end
export_service.export_xml_sales_logs
it "exports mscharge fields as hasmscharge and mscharge" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
replace_shared_ownership_values(expected_content)
replace_non_staircasing_values(expected_content)
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
end
end
context "when staircasing" do
context "when exporting only 26/27 collection period", metadata: { year: 26 } do
let(:start_time) { collection_start_date_for_year(2026) }
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") }
let!(:sales_log) { FactoryBot.create(:sales_log, :export, ownershipsch: 1, staircase: 1, type: 2, mscharge: 321, has_management_fee: 1, management_fee: 222, hasservicechargeschanged: 1, newservicecharges: 150) }
def replace_staircasing_values(export_file)
export_file.sub!("<HASESTATEFEE/>", "<HASESTATEFEE>1</HASESTATEFEE>")
export_file.sub!("<ESTATEFEE/>", "<ESTATEFEE>222.0</ESTATEFEE>")
export_file.sub!("<TYPE>8</TYPE>", "<TYPE>2</TYPE>")
export_file.sub!("<STAIRCASE/>", "<STAIRCASE>1</STAIRCASE>")
export_file.sub!("<ARMEDFORCESSPOUSE>5</ARMEDFORCESSPOUSE>", "<ARMEDFORCESSPOUSE/>")
export_file.sub!("<BUILTYPE>1</BUILTYPE>", "<BUILTYPE/>")
export_file.sub!("<BUY2LIVING>3</BUY2LIVING>", "<BUY2LIVING/>")
export_file.sub!("<DEPOSIT>80000.0</DEPOSIT>", "<DEPOSIT/>")
export_file.sub!("<DISABLED>1</DISABLED>", "<DISABLED/>")
export_file.sub!("<ECSTAT1>1</ECSTAT1>", "<ECSTAT1/>")
export_file.sub!("<ECSTAT2>1</ECSTAT2>", "<ECSTAT2/>")
export_file.sub!("<ESTATEFEE>222.0</ESTATEFEE>", "<ESTATEFEE/>")
export_file.sub!("<ETHNIC>17</ETHNIC>", "<ETHNIC/>")
export_file.sub!("<ETHNICGROUP1>17</ETHNICGROUP1>", "<ETHNICGROUP1/>")
export_file.sub!("<ETHNICGROUP2>17</ETHNICGROUP2>", "<ETHNICGROUP2/>")
export_file.sub!("<HASESTATEFEE>1</HASESTATEFEE>", "<HASESTATEFEE/>")
export_file.sub!("<HB>4</HB>", "<HB/>")
export_file.sub!("<HHOLDCOUNT>4</HHOLDCOUNT>", "<HHOLDCOUNT/>")
export_file.sub!("<HHREGRES>7</HHREGRES>", "<HHREGRES/>")
export_file.sub!("<INC1MORT>1</INC1MORT>", "<INC1MORT/>")
export_file.sub!("<INC1NK>0</INC1NK>", "<INC1NK/>")
export_file.sub!("<INC2MORT>1</INC2MORT>", "<INC2MORT/>")
export_file.sub!("<INC2NK>0</INC2NK>", "<INC2NK/>")
export_file.sub!("<INCOME1>10000</INCOME1>", "<INCOME1/>")
export_file.sub!("<INCOME2>10000</INCOME2>", "<INCOME2/>")
export_file.sub!("<LIVEINBUYER1>1</LIVEINBUYER1>", "<LIVEINBUYER1/>")
export_file.sub!("<LIVEINBUYER2>1</LIVEINBUYER2>", "<LIVEINBUYER2/>")
export_file.sub!("<MORTGAGE>20000.0</MORTGAGE>", "<MORTGAGE/>")
export_file.sub!("<MORTLEN1>10</MORTLEN1>", "<MORTLEN1/>")
export_file.sub!("<NATIONALITYALL1>826</NATIONALITYALL1>", "<NATIONALITYALL1/>")
export_file.sub!("<NATIONALITYALL2>826</NATIONALITYALL2>", "<NATIONALITYALL2/>")
export_file.sub!("<PREVOWN>1</PREVOWN>", "<PREVOWN/>")
export_file.sub!("<PREVSHARED>2</PREVSHARED>", "<PREVSHARED/>")
export_file.sub!("<PREVTEN>1</PREVTEN>", "<PREVTEN/>")
export_file.sub!("<SAVINGSNK>1</SAVINGSNK>", "<SAVINGSNK/>")
export_file.sub!("<WCHAIR>1</WCHAIR>", "<WCHAIR/>")
export_file.sub!("<WHEEL>1</WHEEL>", "<WHEEL/>")
export_file.sub!("<HASSERVICECHARGESCHANGED/>", "<HASSERVICECHARGESCHANGED>1</HASSERVICECHARGESCHANGED>")
export_file.sub!("<NEWSERVICECHARGES/>", "<NEWSERVICECHARGES>150.0</NEWSERVICECHARGES>")
end
it "exports mscharge fields and hasservicechargeschanged and newservicecharges" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
replace_shared_ownership_values(expected_content)
replace_staircasing_values(expected_content)
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
end
end

Loading…
Cancel
Save