Browse Source

CLDC-4236: reset to e6962f3222

CLDC-4236-temp-revert-pipeline-updates
Nat Dean-Lewis 1 week ago
parent
commit
dff42ac2d5
  1. 15
      .github/workflows/aws_deploy.yml
  2. 34
      .github/workflows/manual_review_code_pipeline.yml
  3. 1
      .github/workflows/production_pipeline.yml
  4. 151
      .github/workflows/review_pipeline.yml
  5. 67
      .github/workflows/review_teardown_pipeline.yml
  6. 4
      app/helpers/bulk_upload/sales_log_to_csv.rb
  7. 1
      app/models/form/sales/subsections/shared_ownership_staircasing_transaction.rb
  8. 4
      app/models/sales_log.rb
  9. 9
      app/models/validations/sales/financial_validations.rb
  10. 4
      app/services/bulk_upload/sales/year2026/csv_parser.rb
  11. 12
      app/services/bulk_upload/sales/year2026/row_parser.rb
  12. 6
      app/services/exports/sales_log_export_constants.rb
  13. 3
      app/services/exports/sales_log_export_service.rb
  14. 13
      config/locales/forms/2026/sales/sale_information.en.yml
  15. 4
      config/locales/validations/sales/financial.en.yml
  16. 6
      db/schema.rb
  17. 2
      spec/fixtures/exports/sales_log_26_27.xml
  18. 10
      spec/fixtures/files/2026_27_sales_bulk_upload.csv
  19. 6
      spec/fixtures/files/sales_logs_csv_export_codes_26.csv
  20. 6
      spec/fixtures/files/sales_logs_csv_export_labels_26.csv
  21. 6
      spec/fixtures/files/sales_logs_csv_export_non_support_codes_26.csv
  22. 6
      spec/fixtures/files/sales_logs_csv_export_non_support_labels_26.csv
  23. 2
      spec/fixtures/variable_definitions/sales_download_26_27.csv
  24. 2
      spec/lib/tasks/log_variable_definitions_spec.rb
  25. 1
      spec/models/form/sales/subsections/shared_ownership_staircasing_transaction_spec.rb
  26. 59
      spec/models/validations/sales/financial_validations_spec.rb
  27. 2
      spec/services/bulk_upload/sales/year2026/row_parser_spec.rb
  28. 101
      spec/services/exports/sales_log_export_service_spec.rb

15
.github/workflows/aws_deploy.yml

@ -22,6 +22,10 @@ on:
release_tag: release_tag:
required: false required: false
type: string type: string
ref:
required: false
type: string
default: ""
concurrency: concurrency:
group: deploy-${{ inputs.environment }}${{ inputs.concurrency_tag }} group: deploy-${{ inputs.environment }}${{ inputs.concurrency_tag }}
@ -42,6 +46,8 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.sha }}
- name: Configure AWS credentials - name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4 uses: aws-actions/configure-aws-credentials@v4
@ -53,16 +59,19 @@ jobs:
id: ecr-login id: ecr-login
uses: aws-actions/amazon-ecr-login@v2 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 - name: Check if image with tag already exists
run: | 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 - name: Build, tag, and push docker image to ECR if there is no image, failing for releases
id: build-image id: build-image
if: ${{ env.image-exists == 'false' }} if: ${{ env.image-exists == 'false' }}
env: env:
registry: ${{ steps.ecr-login.outputs.registry }} registry: ${{ steps.ecr-login.outputs.registry }}
commit_tag: ${{ github.sha }} commit_tag: ${{ env.commit_sha }}
run: | run: |
if [[ ${{ inputs.environment }} == 'production' ]]; then 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)." 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 id: update-image-tags
env: env:
registry: ${{ steps.ecr-login.outputs.registry }} registry: ${{ steps.ecr-login.outputs.registry }}
commit_tag: ${{ github.sha }} commit_tag: ${{ inputs.ref || github.sha }}
readable_tag: ${{ inputs.environment }}-${{ env.additional-tag }} readable_tag: ${{ inputs.environment }}-${{ env.additional-tag }}
run: | run: |
manifest=$(aws ecr batch-get-image --repository-name $repository --image-ids imageTag=$commit_tag --output text --query images[].imageManifest) manifest=$(aws ecr batch-get-image --repository-name $repository --image-ids imageTag=$commit_tag --output text --query images[].imageManifest)

34
.github/workflows/manual_review_code_pipeline.yml

@ -1,29 +1,51 @@
name: Manual review app code pipeline name: Manual review app build and deploy
concurrency: concurrency:
group: review-${{ inputs.review_app_key }} group: deploy-review${{ inputs.pr_number }}
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
review_app_key: pr_number:
required: true required: true
type: string type: string
description: "The review app ID to deploy code for." description: "The PR number of the review app to deploy code for. Note: this is NOT the ticket number"
permissions: {}
defaults: defaults:
run: run:
shell: bash shell: bash
jobs: jobs:
get_pr_head_sha:
name: Get PR HEAD SHA
runs-on: ubuntu-latest
outputs:
pr_head_sha: ${{ steps.get_sha.outputs.pr_head_sha }}
steps:
- name: Get PR HEAD SHA
id: get_sha
uses: actions/github-script@v7
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: parseInt('${{ inputs.pr_number }}'),
});
core.setOutput('pr_head_sha', pr.head.sha);
code: code:
name: Deploy review app code name: Deploy review app code
needs: [get_pr_head_sha]
uses: ./.github/workflows/aws_deploy.yml uses: ./.github/workflows/aws_deploy.yml
with: with:
aws_account_id: 837698168072 aws_account_id: 837698168072
aws_role_prefix: core-dev aws_role_prefix: core-dev
aws_task_prefix: core-review-${{ inputs.review_app_key }} aws_task_prefix: core-review-${{ inputs.pr_number }}
concurrency_tag: ${{ inputs.review_app_key }} concurrency_tag: ${{ inputs.pr_number }}
environment: review environment: review
ref: ${{ needs.get_pr_head_sha.outputs.pr_head_sha }}
permissions: permissions:
id-token: write id-token: write

1
.github/workflows/production_pipeline.yml

@ -3,7 +3,6 @@ name: Production CI/CD Pipeline
on: on:
release: release:
types: [released] types: [released]
workflow_dispatch:
defaults: defaults:
run: run:

151
.github/workflows/review_pipeline.yml

@ -1,57 +1,162 @@
name: Review app pipeline name: Review app pipeline
concurrency:
group: review-${{ github.event.pull_request.number }}
on: on:
pull_request: issue_comment:
types: types: [created]
- opened
- synchronize
- reopened
workflow_dispatch: 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"
pull_request:
types: [synchronize]
defaults: concurrency:
run: group: deploy-review${{ github.event.pull_request.number || inputs.pr_number || github.event.issue.number }}
shell: bash
permissions: {}
jobs: 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')) || github.event_name == 'pull_request'
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 if (context.eventName === 'pull_request') {
prNumber = context.payload.pull_request.number.toString();
} 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);
check_deployment_started:
name: Check if deployment has been started
if: github.event_name == 'pull_request'
needs: [get_pr_details]
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
started: ${{ steps.check.outputs.started }}
steps:
- name: Check for previous deployment workflow runs
id: check
uses: actions/github-script@v7
with:
script: |
const prNumber = '${{ needs.get_pr_details.outputs.pr_number }}';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
});
const deployComment = comments.find(c => c.body === 'Starting review app deployment...');
core.setOutput('started', deployComment ? 'true' : 'false');
deployment_started_comment:
name: Comment deployment started
if: github.event_name != 'pull_request'
needs: [get_pr_details]
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ needs.get_pr_details.outputs.pr_number }},
body: 'Starting review app deployment...',
});
infra: infra:
name: Deploy review app infrastructure name: Deploy review app infrastructure
if: github.event_name != 'pull_request'
needs: [get_pr_details]
uses: communitiesuk/submit-social-housing-lettings-and-sales-data-infrastructure/.github/workflows/create_review_app_infra.yml@main uses: communitiesuk/submit-social-housing-lettings-and-sales-data-infrastructure/.github/workflows/create_review_app_infra.yml@main
with: 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 app_repo_role: arn:aws:iam::815624722760:role/core-application-repo
permissions: permissions:
id-token: write id-token: write
code: code:
name: Deploy review app code name: Deploy review app code
needs: [infra] if: github.event_name != 'pull_request'
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-${{ 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
auto_update_code:
name: Auto-update review app code
if: github.event_name == 'pull_request' && needs.check_deployment_started.outputs.started == 'true'
needs: [get_pr_details, check_deployment_started]
uses: ./.github/workflows/aws_deploy.yml uses: ./.github/workflows/aws_deploy.yml
with: with:
aws_account_id: 837698168072 aws_account_id: 837698168072
aws_role_prefix: core-dev aws_role_prefix: core-dev
aws_task_prefix: core-review-${{ github.event.pull_request.number }} aws_task_prefix: core-review-${{ needs.get_pr_details.outputs.pr_number }}
concurrency_tag: ${{ github.event.pull_request.number }} concurrency_tag: ${{ needs.get_pr_details.outputs.pr_number }}
environment: review environment: review
ref: ${{ needs.get_pr_details.outputs.pr_head_sha }}
permissions: permissions:
id-token: write id-token: write
comment: comment:
name: Add link to PR name: Add link to PR
needs: [code] if: github.event_name != 'pull_request'
needs: [get_pr_details, code]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
issues: write
pull-requests: write pull-requests: write
steps: steps:
- name: Comment on PR with URL - name: Comment on PR with URL
uses: unsplash/comment-on-pr@v1.3.0 uses: actions/github-script@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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." script: |
check_for_duplicate_msg: true const prNumber = ${{ needs.get_pr_details.outputs.pr_number }};
duplicate_msg_pattern: Created review app at* 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,
});
}

67
.github/workflows/review_teardown_pipeline.yml

@ -1,27 +1,85 @@
name: Review app teardown pipeline name: Review app teardown pipeline
concurrency: concurrency:
group: review-${{ github.event.pull_request.number }} group: deploy-review${{ github.event.pull_request.number || inputs.pr_number }}
on: on:
pull_request: pull_request:
types: types:
- closed - closed
workflow_dispatch: workflow_dispatch:
inputs:
pr_number:
required: true
type: string
description: "The PR number of the review app to tear down. Note: this is NOT the ticket number"
permissions: {}
env: env:
app_repo_role: arn:aws:iam::815624722760:role/core-application-repo app_repo_role: arn:aws:iam::815624722760:role/core-application-repo
aws_account_id: 837698168072 aws_account_id: 837698168072
aws_region: eu-west-2 aws_region: eu-west-2
aws_role_prefix: core-dev aws_role_prefix: core-dev
aws_task_prefix: core-review-${{ github.event.pull_request.number }}
jobs: jobs:
get_pr_number:
name: Get PR number
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.get.outputs.pr_number }}
steps:
- name: Get PR number
id: get
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "pr_number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
else
echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
fi
check_review_app_exists:
name: Check if review app exists
needs: [get_pr_number]
runs-on: ubuntu-latest
permissions:
id-token: write
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ env.aws_region }}
role-to-assume: ${{ env.app_repo_role }}
- name: Configure AWS credentials for review environment
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ env.aws_region }}
role-to-assume: arn:aws:iam::${{ env.aws_account_id }}:role/${{ env.aws_role_prefix }}-deployment
role-chaining: true
- name: Check if ECS service exists
id: check
env:
aws_task_prefix: core-review-${{ needs.get_pr_number.outputs.pr_number }}
run: |
if aws ecs describe-services --cluster ${{ env.aws_task_prefix }}-app --services ${{ env.aws_task_prefix }}-app --query "services[?status=='ACTIVE']" | grep -q 'serviceName'; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
database: database:
name: Drop database name: Drop database
if: needs.check_review_app_exists.outputs.exists == 'true'
needs: [get_pr_number, check_review_app_exists]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
id-token: write id-token: write
env:
aws_task_prefix: core-review-${{ needs.get_pr_number.outputs.pr_number }}
steps: steps:
- name: Configure AWS credentials - name: Configure AWS credentials
@ -55,10 +113,11 @@ jobs:
infra: infra:
name: Teardown review app name: Teardown review app
needs: [database] if: needs.check_review_app_exists.outputs.exists == 'true'
needs: [get_pr_number, check_review_app_exists, database]
uses: communitiesuk/submit-social-housing-lettings-and-sales-data-infrastructure/.github/workflows/destroy_review_app_infra.yml@main uses: communitiesuk/submit-social-housing-lettings-and-sales-data-infrastructure/.github/workflows/destroy_review_app_infra.yml@main
with: with:
key: ${{ github.event.pull_request.number }} key: ${{ needs.get_pr_number.outputs.pr_number }}
app_repo_role: arn:aws:iam::815624722760:role/core-application-repo app_repo_role: arn:aws:iam::815624722760:role/core-application-repo
permissions: permissions:
id-token: write id-token: write

4
app/helpers/bulk_upload/sales_log_to_csv.rb

@ -678,7 +678,9 @@ class BulkUpload::SalesLogToCsv
log.gender_same_as_sex5, log.gender_same_as_sex5,
log.gender_description5, log.gender_description5,
log.gender_same_as_sex6, log.gender_same_as_sex6,
log.gender_description6, # 134 log.gender_description6,
log.hasservicechargeschanged,
log.newservicecharges, # 136
] ]
end 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::MonthlyRentStaircasingOwned.new(nil, nil, self),
Form::Sales::Pages::MonthlyRentStaircasing.new(nil, nil, self), Form::Sales::Pages::MonthlyRentStaircasing.new(nil, nil, self),
(Form::Sales::Pages::ServiceChargeStaircasing.new("service_charge_staircasing", nil, self) if form.start_year_2026_or_later?), (Form::Sales::Pages::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_shared_ownership_value_check", nil, self),
].compact ].compact
end end

4
app/models/sales_log.rb

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

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

@ -139,6 +139,15 @@ module Validations::Sales::FinancialValidations
end end
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 private
def is_relationship_child?(relationship) 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 include CollectionTimeHelper
# TODO: CLDC-4162: Update when 2026 format is known # TODO: CLDC-4162: Update when 2026 format is known
FIELDS = 134 FIELDS = 136
FORM_YEAR = 2026 FORM_YEAR = 2026
attr_reader :path attr_reader :path
@ -27,7 +27,7 @@ class BulkUpload::Sales::Year2026::CsvParser
def cols def cols
# TODO: CLDC-4162: Update when 2026 format is known # TODO: CLDC-4162: Update when 2026 format is known
@cols ||= ("A".."ED").to_a @cols ||= ("A".."EF").to_a
end end
def row_parsers 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_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_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_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 }.freeze
ERROR_BASE_KEY = "validations.sales.2026.bulk_upload".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_133, :integer
attribute :field_134, :string attribute :field_134, :string
attribute :field_135, :integer
attribute :field_136, :decimal
validates :field_1, validates :field_1,
presence: { presence: {
message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "sale completion date (day)."), message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "sale completion date (day)."),
@ -863,6 +868,7 @@ private
sexrab4: %i[field_48], sexrab4: %i[field_48],
sexrab5: %i[field_52], sexrab5: %i[field_52],
sexrab6: %i[field_56], sexrab6: %i[field_56],
buildheightclass: %i[field_122], buildheightclass: %i[field_122],
gender_same_as_sex1: %i[field_123], gender_same_as_sex1: %i[field_123],
@ -877,6 +883,9 @@ private
gender_description5: %i[field_132], gender_description5: %i[field_132],
gender_same_as_sex6: %i[field_133], gender_same_as_sex6: %i[field_133],
gender_description6: %i[field_134], gender_description6: %i[field_134],
hasservicechargeschanged: %i[field_135],
newservicecharges: %i[field_136],
} }
end end
@ -926,6 +935,9 @@ private
attributes["gender_same_as_sex6"] = field_133 attributes["gender_same_as_sex6"] = field_133
attributes["gender_description6"] = field_134 attributes["gender_description6"] = field_134
attributes["hasservicechargeschanged"] = field_135
attributes["newservicecharges"] = field_136
attributes["relat2"] = relationship_from_is_partner(field_34) attributes["relat2"] = relationship_from_is_partner(field_34)
attributes["relat3"] = relationship_from_is_partner(field_42) attributes["relat3"] = relationship_from_is_partner(field_42)
attributes["relat4"] = relationship_from_is_partner(field_46) 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}" YEAR_2025_EXPORT_FIELDS << "SEX#{index}"
end end
YEAR_2026_EXPORT_FIELDS = Set["BUILDHEIGHTCLASS"] YEAR_2026_EXPORT_FIELDS = Set[
"BUILDHEIGHTCLASS",
"HASSERVICECHARGESCHANGED",
"NEWSERVICECHARGES",
]
(1..6).each do |index| (1..6).each do |index|
YEAR_2026_EXPORT_FIELDS << "SEXRAB#{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["hasestatefee"] = sales_log.has_management_fee
attribute_hash["estatefee"] = sales_log.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["stairlastday"] = sales_log.lasttransaction&.day
attribute_hash["stairlastmonth"] = sales_log.lasttransaction&.month attribute_hash["stairlastmonth"] = sales_log.lasttransaction&.month
attribute_hash["stairlastyear"] = sales_log.lasttransaction&.year attribute_hash["stairlastyear"] = sales_log.lasttransaction&.year

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

@ -285,6 +285,19 @@ en:
hint_text: "" hint_text: ""
question_text: "Enter the total monthly charge" 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: purchase_price:
discounted_ownership: discounted_ownership:
page_header: "About the price of the property" page_header: "About the price of the property"

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

@ -48,6 +48,10 @@ en:
mscharge: mscharge:
monthly_leasehold_charges: monthly_leasehold_charges:
not_zero: "Monthly leasehold charges cannot be £0 if the property has monthly 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: resale:
equity_over_max: "The maximum initial equity stake is %{max_equity}%." equity_over_max: "The maximum initial equity stake is %{max_equity}%."

6
db/schema.rb

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_02_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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -824,8 +824,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_25_135309) do
t.string "sexrab4" t.string "sexrab4"
t.string "sexrab5" t.string "sexrab5"
t.string "sexrab6" t.string "sexrab6"
t.integer "mortlen_known"
t.integer "buildheightclass" 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_sex1"
t.integer "gender_same_as_sex2" t.integer "gender_same_as_sex2"
t.integer "gender_same_as_sex3" t.integer "gender_same_as_sex3"

2
spec/fixtures/exports/sales_log_26_27.xml vendored

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

10
spec/fixtures/files/2026_27_sales_bulk_upload.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,3 +17,5 @@ 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_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_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 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?

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?

2
spec/lib/tasks/log_variable_definitions_spec.rb

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

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_owned
monthly_rent_staircasing monthly_rent_staircasing
service_charge_staircasing service_charge_staircasing
service_charge_changed
monthly_charges_shared_ownership_value_check 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 expect(record.errors).to be_empty
end end
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 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_123: "1",
field_125: "2", field_125: "2",
field_126: "Non-binary", field_126: "Non-binary",
field_135: "1",
field_136: "150",
} }
end end

101
spec/services/exports/sales_log_export_service_spec.rb

@ -451,18 +451,12 @@ RSpec.describe Exports::SalesLogExportService do
end end
context "with shared ownership and mscharge" do 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_shared_ownership_values(export_file)
def replace_mscharge_and_shared_ownership_values(export_file)
export_file.sub!("<HASSERVICECHARGES/>", "<HASSERVICECHARGES>1</HASSERVICECHARGES>") export_file.sub!("<HASSERVICECHARGES/>", "<HASSERVICECHARGES>1</HASSERVICECHARGES>")
export_file.sub!("<SERVICECHARGES/>", "<SERVICECHARGES>321.0</SERVICECHARGES>") 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!("<MSCHARGE>100.0</MSCHARGE>", "<MSCHARGE/>")
export_file.sub!("<HASMSCHARGE>1</HASMSCHARGE>", "<HASMSCHARGE/>") 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!("<GRANT>10000.0</GRANT>", "<GRANT/>")
export_file.sub!("<PPCODENK>0</PPCODENK>", "<PPCODENK>1</PPCODENK>") export_file.sub!("<PPCODENK>0</PPCODENK>", "<PPCODENK>1</PPCODENK>")
export_file.sub!("<PPOSTC1>SW1A</PPOSTC1>", "<PPOSTC1/>") export_file.sub!("<PPOSTC1>SW1A</PPOSTC1>", "<PPOSTC1/>")
@ -474,16 +468,93 @@ RSpec.describe Exports::SalesLogExportService do
export_file.sub!("<PREVLOCNAME>Westminster</PREVLOCNAME>", "<PREVLOCNAME/>") export_file.sub!("<PREVLOCNAME>Westminster</PREVLOCNAME>", "<PREVLOCNAME/>")
end end
it "exports mscharge fields as hasmscharge and mscharge" do context "when not staircasing" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read) let!(:sales_log) { FactoryBot.create(:sales_log, :export, ownershipsch: 1, staircase: 2, type: 30, mscharge: 321, has_management_fee: 1, management_fee: 222) }
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| def replace_non_staircasing_values(export_file)
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename) export_file.sub!("<HASESTATEFEE/>", "<HASESTATEFEE>1</HASESTATEFEE>")
expect(entry).not_to be_nil export_file.sub!("<ESTATEFEE/>", "<ESTATEFEE>222.0</ESTATEFEE>")
expect(entry.get_input_stream.read).to have_same_xml_contents_as(expected_content) export_file.sub!("<TYPE>8</TYPE>", "<TYPE>30</TYPE>")
export_file.sub!("<STAIRCASE/>", "<STAIRCASE>2</STAIRCASE>")
end 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 end
end end

Loading…
Cancel
Save