Browse Source

Merge branch 'main' into DuplicateIds

pull/2573/head
Rachael Booth 2 years ago committed by GitHub
parent
commit
b11b8ba388
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      .github/workflows/aws_deploy.yml
  2. 19
      .prettierignore
  3. 1
      .prettierrc
  4. 12
      .rubocop.yml
  5. 8
      Gemfile.lock
  6. 14
      README.md
  7. 8
      app/components/bulk_upload_error_row_component.html.erb
  8. 2
      app/components/bulk_upload_error_summary_table_component.html.erb
  9. 8
      app/controllers/users_controller.rb
  10. 10
      app/frontend/styles/_button.scss
  11. 2
      app/frontend/styles/_feedback.scss
  12. 4
      app/frontend/styles/_filter-layout.scss
  13. 8
      app/frontend/styles/_filter.scss
  14. 4
      app/frontend/styles/_panel.scss
  15. 2
      app/frontend/styles/_primary-navigation.scss
  16. 2
      app/frontend/styles/_sub-navigation.scss
  17. 2
      app/frontend/styles/_table-group.scss
  18. 2
      app/frontend/styles/_task-list.scss
  19. 4
      app/frontend/styles/application.scss
  20. 11
      app/helpers/guidance_helper.rb
  21. 26
      app/models/form/sales/pages/about_deposit_without_discount.rb
  22. 22
      app/models/form/sales/pages/deposit.rb
  23. 5
      app/models/form/sales/pages/deposit_discount.rb
  24. 5
      app/models/form/sales/pages/discount.rb
  25. 5
      app/models/form/sales/pages/equity.rb
  26. 5
      app/models/form/sales/pages/grant.rb
  27. 1
      app/models/form/sales/pages/mortgageused.rb
  28. 18
      app/models/form/sales/pages/purchase_price.rb
  29. 2
      app/models/form/sales/pages/purchase_price_outright_ownership.rb
  30. 13
      app/models/form/sales/pages/value_shared_ownership.rb
  31. 7
      app/models/form/sales/questions/deposit_amount.rb
  32. 1
      app/models/form/sales/questions/deposit_discount.rb
  33. 1
      app/models/form/sales/questions/discount.rb
  34. 1
      app/models/form/sales/questions/equity.rb
  35. 1
      app/models/form/sales/questions/grant.rb
  36. 7
      app/models/form/sales/questions/mortgage_amount.rb
  37. 7
      app/models/form/sales/questions/mortgageused.rb
  38. 6
      app/models/form/sales/questions/purchase_price.rb
  39. 1
      app/models/form/sales/questions/staircase_bought.rb
  40. 1
      app/models/form/sales/questions/value.rb
  41. 7
      app/models/form/sales/subsections/discounted_ownership_scheme.rb
  42. 2
      app/models/form/sales/subsections/outright_sale.rb
  43. 11
      app/models/form/sales/subsections/shared_ownership_scheme.rb
  44. 4
      app/models/sales_log.rb
  45. 15
      app/models/user.rb
  46. 3
      app/models/validations/sales/financial_validations.rb
  47. 139
      app/models/validations/sales/sale_information_validations.rb
  48. 2
      app/services/bulk_upload/lettings/year2023/row_parser.rb
  49. 13
      app/views/content/privacy_notice.md
  50. 4
      app/views/form/_numeric_question.html.erb
  51. 2
      app/views/form/_radio_question.html.erb
  52. 29
      app/views/form/guidance/_financial_calculations_discounted_ownership.html.erb
  53. 13
      app/views/form/guidance/_financial_calculations_outright_sale.html.erb
  54. 30
      app/views/form/guidance/_financial_calculations_shared_ownership.html.erb
  55. 7
      app/views/users/edit.html.erb
  56. 8
      app/views/users/new.html.erb
  57. 2
      app/views/users/show.html.erb
  58. 8
      babel.config.js
  59. 376
      config/forms/2021_2022.json
  60. 1008
      config/forms/2022_2023.json
  61. 16
      config/forms/schema/2021_2022.json
  62. 47
      config/forms/schema/generic.json
  63. 20
      config/locales/en.yml
  64. 1
      config/storage.yml
  65. 5
      db/migrate/20240819143150_add_phone_extension_to_users.rb
  66. 3
      db/schema.rb
  67. 2
      docker-compose.yml
  68. 2
      docs/adr/adr-006-saving-values.md
  69. 7
      docs/adr/adr-015-asset-pipeline.md
  70. 2
      docs/adr/adr-016-hotwire.md
  71. 3
      docs/adr/adr-018-form-setup.md
  72. 1
      docs/adr/adr-019-form-end-dates.md
  73. 31
      docs/api/v1.json
  74. 10
      docs/app_api.md
  75. 8
      docs/documentation_website.md
  76. 2
      docs/form/builder.md
  77. 4
      docs/form/definition.md
  78. 4
      docs/form/question.md
  79. BIN
      docs/images/service.jpeg
  80. BIN
      docs/images/service.png
  81. 41
      docs/infrastructure.md
  82. 19
      docs/monitoring.md
  83. 238
      docs/setup.md
  84. 17
      docs/testing.md
  85. 4
      lib/tasks/clear_unconfirmed_emails.rake
  86. 7
      lib/tasks/lint.rake
  87. 5
      package.json
  88. 4
      spec/features/user_spec.rb
  89. 88
      spec/fixtures/forms/2021_2022.json
  90. 4
      spec/fixtures/forms/2022_2023.json
  91. 21
      spec/helpers/guidance_helper_spec.rb
  92. 36
      spec/lib/tasks/clear_unconfirmed_emails_spec.rb
  93. 2
      spec/mailers/resend_invitation_mailer_spec.rb
  94. 79
      spec/models/form/sales/pages/about_deposit_without_discount_spec.rb
  95. 8
      spec/models/form/sales/pages/deposit_discount_spec.rb
  96. 235
      spec/models/form/sales/pages/deposit_spec.rb
  97. 6
      spec/models/form/sales/pages/discount_spec.rb
  98. 6
      spec/models/form/sales/pages/equity_spec.rb
  99. 6
      spec/models/form/sales/pages/grant_spec.rb
  100. 2
      spec/models/form/sales/pages/purchase_price_outright_ownership_spec.rb
  101. Some files were not shown because too many files have changed in this diff Show More

4
.github/workflows/aws_deploy.yml

@ -53,7 +53,7 @@ jobs:
id: ecr-login
uses: aws-actions/amazon-ecr-login@v1
with:
mask-password: 'true'
mask-password: "true"
- name: Check if image with tag already exists
run: |
@ -90,7 +90,7 @@ jobs:
id: ecr-login
uses: aws-actions/amazon-ecr-login@v1
with:
mask-password: 'true'
mask-password: "true"
- name: Get timestamp
id: timestamp

19
.prettierignore

@ -0,0 +1,19 @@
# Ignore everything except as negated below
*
!*.scss
!*.md
!*.yml
!*.json
# Ensures subdirectories are checked for files matching the above
!*/
config/locales/
app/views/content/data_sharing_agreement.md
/node_modules
/vendor
/coverage
/public/assets
/app/assets/builds/*

1
.prettierrc

@ -0,0 +1 @@
{}

12
.rubocop.yml

@ -11,12 +11,12 @@ inherit_gem:
AllCops:
Exclude:
- 'bin/*'
- 'db/schema.rb'
- 'node_modules/**/*'
- 'config/application.rb'
- 'config/puma.rb'
- 'vendor/**/*'
- "bin/*"
- "db/schema.rb"
- "node_modules/**/*"
- "config/application.rb"
- "config/puma.rb"
- "vendor/**/*"
Style/Documentation:
Enabled: false

8
Gemfile.lock

@ -141,7 +141,7 @@ GEM
coderay (1.1.3)
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
concurrent-ruby (1.3.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@ -180,7 +180,7 @@ GEM
rubocop
smart_properties
erubi (1.13.0)
et-orbi (1.2.7)
et-orbi (1.2.11)
tzinfo
event_stream_parser (1.0.0)
excon (0.109.0)
@ -198,8 +198,8 @@ GEM
faraday-net_http (3.1.0)
net-http
ffi (1.16.3)
fugit (1.10.0)
et-orbi (~> 1, >= 1.2.7)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)

14
README.md

@ -3,16 +3,16 @@
[![Production CI/CD Pipeline](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/production_pipeline.yml/badge.svg)](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/production_pipeline.yml)
[![Staging CI/CD Pipeline](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/staging_pipeline.yml/badge.svg)](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/staging_pipeline.yml)
Ruby on Rails app that handles the submission of lettings and sales of social housing data in England. Currently in private beta.
Ruby on Rails app that handles the submission of lettings and sales of social housing data in England. Currently in public beta.
## Domain documentation
* [Domain and technical documentation](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data)
* [Local development setup](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/setup)
* [Architecture decision records](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/adr)
* [API browser](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/api) (using this [OpenAPI specification](docs/api/v1.json))
* [Design history](https://core-design-history.herokuapp.com)
- [Domain and technical documentation](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data)
- [Local development setup](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/setup)
- [Architecture decision records](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/adr)
- [API browser](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/api) (using this [OpenAPI specification](docs/api/v1.json))
- [Design history](https://core-design-history.herokuapp.com)
## User interface
![View of the logs list](docs/images/service.png)
![View of the logs list](docs/images/service.jpeg)

8
app/components/bulk_upload_error_row_component.html.erb

@ -24,8 +24,8 @@
<% critical_errors.each do |error| %>
<% body.with_row do |row| %>
<% row.with_cell(text: error.cell) %>
<% row.with_cell(text: question_for_field(error.field)) %>
<% row.with_cell(text: error.error, html_attributes: { class: "govuk-!-font-weight-bold" }) %>
<% row.with_cell(text: question_for_field(error.field), html_attributes: { class: "govuk-!-width-one-half" }) %>
<% row.with_cell(text: error.error.html_safe, html_attributes: { class: "govuk-!-font-weight-bold govuk-!-width-one-half" }) %>
<% row.with_cell(text: error.field.humanize) %>
<% end %>
<% end %>
@ -54,9 +54,9 @@
<% row_class += " last-row" if index == errors.size - 1 %>
<% body.with_row(html_attributes: { class: row_class }) do |row| %>
<% row.with_cell(text: error.cell) %>
<% row.with_cell(text: question_for_field(error.field)) %>
<% row.with_cell(text: question_for_field(error.field), html_attributes: { class: "govuk-!-width-one-half" }) %>
<% if index == 0 %>
<% row.with_cell(text: error_message, rowspan: errors.size, html_attributes: { class: "govuk-!-font-weight-bold grouped-multirow-cell" }) %>
<% row.with_cell(text: error_message.html_safe, rowspan: errors.size, html_attributes: { class: "govuk-!-font-weight-bold govuk-!-width-one-half grouped-multirow-cell" }) %>
<% end %>
<% row.with_cell(text: error.field.humanize) %>
<% end %>

2
app/components/bulk_upload_error_summary_table_component.html.erb

@ -12,7 +12,7 @@
<%= table.with_body do |body| %>
<% body.with_row do |row| %>
<% row.with_cell(text: error[0][2]) %>
<% row.with_cell(text: error[0][2].html_safe) %>
<% row.with_cell(text: pluralize(error[1], "error"), numeric: true) %>
<% end %>
<% end %>

8
app/controllers/users_controller.rb

@ -192,14 +192,14 @@ private
def user_params
if @user == current_user
if current_user.data_coordinator? || current_user.support?
params.require(:user).permit(:email, :phone, :name, :password, :password_confirmation, :role, :is_dpo, :is_key_contact, :initial_confirmation_sent)
params.require(:user).permit(:email, :phone, :phone_extension, :name, :password, :password_confirmation, :role, :is_dpo, :is_key_contact, :initial_confirmation_sent)
else
params.require(:user).permit(:email, :phone, :name, :password, :password_confirmation, :initial_confirmation_sent)
params.require(:user).permit(:email, :phone, :phone_extension, :name, :password, :password_confirmation, :initial_confirmation_sent)
end
elsif current_user.data_coordinator?
params.require(:user).permit(:email, :phone, :name, :role, :is_dpo, :is_key_contact, :active, :initial_confirmation_sent)
params.require(:user).permit(:email, :phone, :phone_extension, :name, :role, :is_dpo, :is_key_contact, :active, :initial_confirmation_sent)
elsif current_user.support?
params.require(:user).permit(:email, :phone, :name, :role, :is_dpo, :is_key_contact, :organisation_id, :active, :initial_confirmation_sent)
params.require(:user).permit(:email, :phone, :phone_extension, :name, :role, :is_dpo, :is_key_contact, :organisation_id, :active, :initial_confirmation_sent)
end
end

10
app/frontend/styles/_button.scss

@ -1,8 +1,14 @@
$app-button-shadow-size: $govuk-border-width-form-element;
$app-button-inverse-background-colour: govuk-colour("white");
$app-button-inverse-foreground-colour: $govuk-brand-colour;
$app-button-inverse-shadow-colour: govuk-shade($app-button-inverse-foreground-colour, 30%);
$app-button-inverse-hover-background-colour: govuk-tint($app-button-inverse-foreground-colour, 90%);
$app-button-inverse-shadow-colour: govuk-shade(
$app-button-inverse-foreground-colour,
30%
);
$app-button-inverse-hover-background-colour: govuk-tint(
$app-button-inverse-foreground-colour,
90%
);
.app-button--inverse,
.app-button--inverse:link,

2
app/frontend/styles/_feedback.scss

@ -88,7 +88,7 @@
&:hover {
// backup style for browsers that don't support rgba
background: govuk-colour("mid-grey");
background: rgba(govuk-colour("black"), .2);
background: rgba(govuk-colour("black"), 0.2);
color: govuk-colour("black");
}

4
app/frontend/styles/_filter-layout.scss

@ -34,7 +34,9 @@
.app-filter-layout__content {
@include govuk-media-query(wide) {
float: right;
max-width: calc(#{govuk-grid-width("three-quarters")} - #{govuk-spacing(6)});
max-width: calc(
#{govuk-grid-width("three-quarters")} - #{govuk-spacing(6)}
);
width: 100%;
}
}

8
app/frontend/styles/_filter.scss

@ -5,7 +5,7 @@
.govuk-checkboxes__label,
.govuk-radios__label {
&:before {
&::before {
background-color: govuk-colour("white");
}
}
@ -47,7 +47,9 @@
&:focus {
background-color: $govuk-focus-colour;
color: $govuk-focus-text-colour;
box-shadow: 0 -2px $govuk-focus-colour, 0 4px $govuk-focus-text-colour;
box-shadow:
0 -2px $govuk-focus-colour,
0 4px $govuk-focus-text-colour;
outline: none;
}
@ -57,7 +59,7 @@
border: 0;
}
&:before {
&::before {
background-image: url("../assets/images/icon-cross.svg");
content: "";
display: inline-block;

4
app/frontend/styles/_panel.scss

@ -19,8 +19,8 @@
margin-bottom: 0;
}
.govuk-radios__label:before,
& :after {
.govuk-radios__label::before,
& ::after {
color: govuk-colour("black");
border-color: govuk-colour("black");
background-color: govuk-colour("white");

2
app/frontend/styles/_primary-navigation.scss

@ -54,7 +54,7 @@
@include govuk-typography-weight-bold;
// Extend the touch area of the link to the list
&:after {
&::after {
bottom: 0;
content: "";
left: 0;

2
app/frontend/styles/_sub-navigation.scss

@ -64,7 +64,7 @@
position: relative;
// Extend the touch area of the link to the list
&:after {
&::after {
bottom: 0;
content: "";
left: 0;

2
app/frontend/styles/_table-group.scss

@ -2,7 +2,7 @@
overflow-x: auto;
overflow-y: hidden;
margin: govuk-spacing(-3) govuk-spacing(-3) govuk-spacing(3);
padding: govuk-spacing(3) govuk-spacing(3);
padding: govuk-spacing(3);
scrollbar-color: $govuk-text-colour govuk-colour("light-grey");
.govuk-table {

2
app/frontend/styles/_task-list.scss

@ -10,7 +10,7 @@
}
.app-task-list__section-heading {
@include govuk-font($size:24, $weight: bold);
@include govuk-font($size: 24, $weight: bold);
display: table;
margin-top: govuk-spacing(0);
margin-bottom: govuk-spacing(4);

4
app/frontend/styles/application.scss

@ -13,8 +13,8 @@ $govuk-new-link-styles: true;
// Add additional breakpoint named `wide`
$govuk-breakpoints: (
mobile: 320px,
tablet: 641px,
mobile: 320px,
tablet: 641px,
desktop: 769px,
wide: 921px,
);

11
app/helpers/guidance_helper.rb

@ -0,0 +1,11 @@
module GuidanceHelper
include GovukLinkHelper
include GovukVisuallyHiddenHelper
def question_link(question_id, log, user)
question = log.form.get_question(question_id, log)
return "" unless question.page.routed_to?(log, user)
"(#{govuk_link_to "Q#{question.question_number}", send("#{log.class.name.underscore}_#{question.page.id}_path", log)})".html_safe
end
end

26
app/models/form/sales/pages/about_deposit_without_discount.rb

@ -1,26 +0,0 @@
class Form::Sales::Pages::AboutDepositWithoutDiscount < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:, optional:)
super(id, hsh, subsection)
@header = "About the deposit"
@ownershipsch = ownershipsch
@optional = optional
end
def questions
@questions ||= [
Form::Sales::Questions::DepositAmount.new(nil, nil, self, ownershipsch: @ownershipsch, optional: @optional),
]
end
def depends_on
if form.start_year_after_2024?
[{ "social_homebuy?" => false, "ownershipsch" => 1, "stairowned_100?" => @optional },
{ "ownershipsch" => 2 },
{ "ownershipsch" => 3, "mortgageused" => 1 }]
else
[{ "social_homebuy?" => false, "ownershipsch" => 1 },
{ "ownershipsch" => 2 },
{ "ownershipsch" => 3, "mortgageused" => 1 }]
end
end
end

22
app/models/form/sales/pages/deposit.rb

@ -0,0 +1,22 @@
class Form::Sales::Pages::Deposit < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:, optional:)
super(id, hsh, subsection)
@ownershipsch = ownershipsch
@optional = optional
@header = "About the deposit"
end
def questions
@questions ||= [
Form::Sales::Questions::DepositAmount.new(nil, nil, self, ownershipsch: @ownershipsch, optional: @optional),
]
end
def routed_to?(log, _user)
return false unless super
return true if log.ownershipsch == 2 || (log.ownershipsch == 3 && log.mortgageused == 1)
return false if log.stairowned_100? != @optional && form.start_year_after_2024?
log.ownershipsch == 1
end
end

5
app/models/form/sales/pages/about_deposit_with_discount.rb → app/models/form/sales/pages/deposit_discount.rb

@ -1,13 +1,12 @@
class Form::Sales::Pages::AboutDepositWithDiscount < ::Form::Page
class Form::Sales::Pages::DepositDiscount < ::Form::Page
def initialize(id, hsh, subsection, optional:)
super(id, hsh, subsection)
@header = "About the deposit"
@optional = optional
@header = "About the deposit"
end
def questions
@questions ||= [
Form::Sales::Questions::DepositAmount.new(nil, nil, self, ownershipsch: 1, optional: @optional),
Form::Sales::Questions::DepositDiscount.new(nil, nil, self),
]
end

5
app/models/form/sales/pages/about_price_rtb.rb → app/models/form/sales/pages/discount.rb

@ -1,7 +1,7 @@
class Form::Sales::Pages::AboutPriceRtb < ::Form::Page
class Form::Sales::Pages::Discount < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "about_price_rtb"
@id = "discount"
@header = "About the price of the property"
@depends_on = [{
"right_to_buy?" => true,
@ -10,7 +10,6 @@ class Form::Sales::Pages::AboutPriceRtb < ::Form::Page
def questions
@questions ||= [
Form::Sales::Questions::PurchasePrice.new(nil, nil, self, ownershipsch: 2),
Form::Sales::Questions::Discount.new(nil, nil, self),
]
end

5
app/models/form/sales/pages/about_price_shared_ownership.rb → app/models/form/sales/pages/equity.rb

@ -1,13 +1,12 @@
class Form::Sales::Pages::AboutPriceSharedOwnership < ::Form::Page
class Form::Sales::Pages::Equity < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "about_price_shared_ownership"
@id = "equity"
@header = "About the price of the property"
end
def questions
@questions ||= [
Form::Sales::Questions::Value.new(nil, nil, self),
Form::Sales::Questions::Equity.new(nil, nil, self),
]
end

5
app/models/form/sales/pages/about_price_not_rtb.rb → app/models/form/sales/pages/grant.rb

@ -1,7 +1,7 @@
class Form::Sales::Pages::AboutPriceNotRtb < ::Form::Page
class Form::Sales::Pages::Grant < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "about_price_not_rtb"
@id = "grant"
@header = "About the price of the property"
@depends_on = [{
"right_to_buy?" => false,
@ -11,7 +11,6 @@ class Form::Sales::Pages::AboutPriceNotRtb < ::Form::Page
def questions
@questions ||= [
Form::Sales::Questions::PurchasePrice.new(nil, nil, self, ownershipsch: 2),
Form::Sales::Questions::Grant.new(nil, nil, self),
]
end

1
app/models/form/sales/pages/mortgageused.rb

@ -1,6 +1,7 @@
class Form::Sales::Pages::Mortgageused < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@header = "Mortgage Amount"
@ownershipsch = ownershipsch
end

18
app/models/form/sales/pages/purchase_price.rb

@ -0,0 +1,18 @@
class Form::Sales::Pages::PurchasePrice < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "purchase_price"
@header = "About the price of the property"
@depends_on = [{ "right_to_buy?" => true },
{
"right_to_buy?" => false,
"rent_to_buy_full_ownership?" => false,
}]
end
def questions
@questions ||= [
Form::Sales::Questions::PurchasePrice.new(nil, nil, self, ownershipsch: 2),
]
end
end

2
app/models/form/sales/pages/purchase_price_outright_ownership.rb

@ -4,6 +4,8 @@ class Form::Sales::Pages::PurchasePriceOutrightOwnership < ::Form::Page
@depends_on = [
{ "outright_sale_or_discounted_with_full_ownership?" => true },
]
@header = "About the price of the property"
@top_guidance_partial = "financial_calculations_outright_sale"
@ownershipsch = ownershipsch
end

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

@ -0,0 +1,13 @@
class Form::Sales::Pages::ValueSharedOwnership < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "value_shared_ownership"
@header = "About the price of the property"
end
def questions
@questions ||= [
Form::Sales::Questions::Value.new(nil, nil, self),
]
end
end

7
app/models/form/sales/questions/deposit_amount.rb

@ -13,6 +13,7 @@ class Form::Sales::Questions::DepositAmount < ::Form::Question
@ownershipsch = ownershipsch
@question_number = QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.fetch(form.start_date.year, QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.max_by { |k, _v| k }.last)[ownershipsch]
@optional = optional
@top_guidance_partial = top_guidance_partial
end
def derived?(log)
@ -31,4 +32,10 @@ class Form::Sales::Questions::DepositAmount < ::Form::Question
"Enter the total cash sum paid by the buyer towards the property that was not funded by the mortgage. This excludes any grant or loan"
end
end
def top_guidance_partial
return "financial_calculations_shared_ownership" if @ownershipsch == 1
return "financial_calculations_discounted_ownership" if @ownershipsch == 2
return "financial_calculations_outright_sale" if @ownershipsch == 3
end
end

1
app/models/form/sales/questions/deposit_discount.rb

@ -12,6 +12,7 @@ class Form::Sales::Questions::DepositDiscount < ::Form::Question
@prefix = "£"
@hint_text = "Enter the total cash discount given on the property being purchased through the Social HomeBuy scheme"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_shared_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 96, 2024 => 97 }.freeze

1
app/models/form/sales/questions/discount.rb

@ -14,6 +14,7 @@ class Form::Sales::Questions::Discount < ::Form::Question
If discount capped, enter capped %</br></br>
If the property is being sold to an existing tenant under the RTB, PRTB, or VRTB schemes, enter the % discount from the full market value that is being given."
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_discounted_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 102, 2024 => 103 }.freeze

1
app/models/form/sales/questions/equity.rb

@ -12,6 +12,7 @@ class Form::Sales::Questions::Equity < ::Form::Question
@suffix = "%"
@hint_text = "Enter the amount of initial equity held by the purchaser (for example, 25% or 50%)"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_shared_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 89, 2024 => 90 }.freeze

1
app/models/form/sales/questions/grant.rb

@ -12,6 +12,7 @@ class Form::Sales::Questions::Grant < ::Form::Question
@prefix = "£"
@hint_text = "For all schemes except Right to Buy (RTB), Preserved Right to Buy (PRTB), Voluntary Right to Buy (VRTB) and Rent to Buy"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_discounted_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 101, 2024 => 102 }.freeze

7
app/models/form/sales/questions/mortgage_amount.rb

@ -12,6 +12,7 @@ class Form::Sales::Questions::MortgageAmount < ::Form::Question
@hint_text = "Enter the amount of mortgage agreed with the mortgage lender. Exclude any deposits or cash payments. Numeric in pounds. Rounded to the nearest pound."
@ownershipsch = ownershipsch
@question_number = QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.fetch(form.start_date.year, QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.max_by { |k, _v| k }.last)[ownershipsch]
@top_guidance_partial = top_guidance_partial
end
QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP = {
@ -22,4 +23,10 @@ class Form::Sales::Questions::MortgageAmount < ::Form::Question
def derived?(log)
log&.mortgage_not_used?
end
def top_guidance_partial
return "financial_calculations_shared_ownership" if @ownershipsch == 1
return "financial_calculations_discounted_ownership" if @ownershipsch == 2
return "financial_calculations_outright_sale" if @ownershipsch == 3
end
end

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

@ -8,6 +8,7 @@ class Form::Sales::Questions::Mortgageused < ::Form::Question
@answer_options = ANSWER_OPTIONS
@ownershipsch = ownershipsch
@question_number = QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.fetch(form.start_date.year, QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.max_by { |k, _v| k }.last)[ownershipsch]
@top_guidance_partial = top_guidance_partial
end
def displayed_answer_options(log, _user = nil)
@ -34,4 +35,10 @@ class Form::Sales::Questions::Mortgageused < ::Form::Question
2023 => { 1 => 90, 2 => 103, 3 => 111 },
2024 => { 1 => 91, 2 => 104, 3 => 112 },
}.freeze
def top_guidance_partial
return "financial_calculations_shared_ownership" if @ownershipsch == 1
return "financial_calculations_discounted_ownership" if @ownershipsch == 2
return "financial_calculations_outright_sale" if @ownershipsch == 3
end
end

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

@ -12,6 +12,7 @@ class Form::Sales::Questions::PurchasePrice < ::Form::Question
@hint_text = hint_text
@ownership_sch = ownershipsch
@question_number = QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.fetch(form.start_date.year, QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.max_by { |k, _v| k }.last)[ownershipsch]
@top_guidance_partial = top_guidance_partial
end
QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP = {
@ -24,4 +25,9 @@ class Form::Sales::Questions::PurchasePrice < ::Form::Question
"For all schemes, including Right to Acquire (RTA), Right to Buy (RTB), Voluntary Right to Buy (VRTB) or Preserved Right to Buy (PRTB) sales, enter the full price of the property without any discount"
end
def top_guidance_partial
return "financial_calculations_discounted_ownership" if @ownership_sch == 2
return "financial_calculations_outright_sale" if @ownership_sch == 3
end
end

1
app/models/form/sales/questions/staircase_bought.rb

@ -11,6 +11,7 @@ class Form::Sales::Questions::StaircaseBought < ::Form::Question
@step = 1
@suffix = "%"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_shared_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 77, 2024 => 79 }.freeze

1
app/models/form/sales/questions/value.rb

@ -11,6 +11,7 @@ class Form::Sales::Questions::Value < ::Form::Question
@prefix = "£"
@hint_text = "Enter the full purchase price of the property before any discounts are applied. For shared ownership, enter the full purchase price paid for 100% equity (this is equal to the value of the share owned by the PRP plus the value bought by the purchaser)"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_shared_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 88, 2024 => 89 }.freeze

7
app/models/form/sales/subsections/discounted_ownership_scheme.rb

@ -10,10 +10,11 @@ class Form::Sales::Subsections::DiscountedOwnershipScheme < ::Form::Subsection
@pages ||= [
Form::Sales::Pages::LivingBeforePurchase.new("living_before_purchase_discounted_ownership_joint_purchase", nil, self, ownershipsch: 2, joint_purchase: true),
Form::Sales::Pages::LivingBeforePurchase.new("living_before_purchase_discounted_ownership", nil, self, ownershipsch: 2, joint_purchase: false),
Form::Sales::Pages::AboutPriceRtb.new(nil, nil, self),
Form::Sales::Pages::PurchasePrice.new(nil, nil, self),
Form::Sales::Pages::Discount.new(nil, nil, self),
Form::Sales::Pages::ExtraBorrowingValueCheck.new("extra_borrowing_price_value_check", nil, self),
Form::Sales::Pages::PercentageDiscountValueCheck.new("percentage_discount_value_check", nil, self),
Form::Sales::Pages::AboutPriceNotRtb.new(nil, nil, self),
Form::Sales::Pages::Grant.new(nil, nil, self),
Form::Sales::Pages::GrantValueCheck.new(nil, nil, self),
Form::Sales::Pages::PurchasePriceOutrightOwnership.new("purchase_price_discounted_ownership", nil, self, ownershipsch: 2),
Form::Sales::Pages::DiscountedSaleValueCheck.new("discounted_sale_grant_value_check", nil, self),
@ -32,7 +33,7 @@ class Form::Sales::Subsections::DiscountedOwnershipScheme < ::Form::Subsection
Form::Sales::Pages::MortgageLength.new("mortgage_length_discounted_ownership", nil, self, ownershipsch: 2),
Form::Sales::Pages::ExtraBorrowing.new("extra_borrowing_discounted_ownership", nil, self, ownershipsch: 2),
Form::Sales::Pages::ExtraBorrowingValueCheck.new("extra_borrowing_value_check", nil, self),
Form::Sales::Pages::AboutDepositWithoutDiscount.new("about_deposit_discounted_ownership", nil, self, ownershipsch: 2, optional: false),
Form::Sales::Pages::Deposit.new("deposit_discounted_ownership", nil, self, ownershipsch: 2, optional: false),
Form::Sales::Pages::ExtraBorrowingValueCheck.new("extra_borrowing_deposit_value_check", nil, self),
Form::Sales::Pages::DepositValueCheck.new("discounted_ownership_deposit_joint_purchase_value_check", nil, self, joint_purchase: true),
Form::Sales::Pages::DepositValueCheck.new("discounted_ownership_deposit_value_check", nil, self, joint_purchase: false),

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

@ -18,7 +18,7 @@ class Form::Sales::Subsections::OutrightSale < ::Form::Subsection
(Form::Sales::Pages::MortgageLenderOther.new("mortgage_lender_other_outright_sale", nil, self, ownershipsch: 3) unless form.start_year_after_2024?),
Form::Sales::Pages::MortgageLength.new("mortgage_length_outright_sale", nil, self, ownershipsch: 3),
Form::Sales::Pages::ExtraBorrowing.new("extra_borrowing_outright_sale", nil, self, ownershipsch: 3),
Form::Sales::Pages::AboutDepositWithoutDiscount.new("about_deposit_outright_sale", nil, self, ownershipsch: 3, optional: false),
Form::Sales::Pages::Deposit.new("deposit_outright_sale", nil, self, ownershipsch: 3, optional: false),
Form::Sales::Pages::DepositValueCheck.new("outright_sale_deposit_joint_purchase_value_check", nil, self, joint_purchase: true),
Form::Sales::Pages::DepositValueCheck.new("outright_sale_deposit_value_check", nil, self, joint_purchase: false),
leasehold_charge_pages,

11
app/models/form/sales/subsections/shared_ownership_scheme.rb

@ -26,8 +26,9 @@ class Form::Sales::Subsections::SharedOwnershipScheme < ::Form::Subsection
Form::Sales::Pages::PreviousBedrooms.new(nil, nil, self),
Form::Sales::Pages::PreviousPropertyType.new(nil, nil, self),
Form::Sales::Pages::PreviousTenure.new(nil, nil, self),
Form::Sales::Pages::AboutPriceSharedOwnership.new(nil, nil, self),
Form::Sales::Pages::ValueSharedOwnership.new(nil, nil, self),
Form::Sales::Pages::AboutPriceValueCheck.new("about_price_shared_ownership_value_check", nil, self),
Form::Sales::Pages::Equity.new(nil, nil, self),
Form::Sales::Pages::SharedOwnershipDepositValueCheck.new("shared_ownership_equity_value_check", nil, self),
Form::Sales::Pages::Mortgageused.new("mortgage_used_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::MortgageValueCheck.new("mortgage_used_mortgage_value_check", nil, self),
@ -38,12 +39,12 @@ class Form::Sales::Subsections::SharedOwnershipScheme < ::Form::Subsection
Form::Sales::Pages::MortgageLenderOther.new("mortgage_lender_other_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::MortgageLength.new("mortgage_length_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::ExtraBorrowing.new("extra_borrowing_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::AboutDepositWithDiscount.new("about_deposit_with_discount", nil, self, optional: false),
(Form::Sales::Pages::AboutDepositWithDiscount.new("about_deposit_with_discount_optional", nil, self, optional: true) if form.start_year_after_2024?),
Form::Sales::Pages::AboutDepositWithoutDiscount.new("about_deposit_shared_ownership", nil, self, ownershipsch: 1, optional: false),
(Form::Sales::Pages::AboutDepositWithoutDiscount.new("about_deposit_shared_ownership_optional", nil, self, ownershipsch: 1, optional: true) if form.start_year_after_2024?),
Form::Sales::Pages::Deposit.new("deposit_shared_ownership", nil, self, ownershipsch: 1, optional: false),
(Form::Sales::Pages::Deposit.new("deposit_shared_ownership_optional", nil, self, ownershipsch: 1, optional: true) if form.start_year_after_2024?),
Form::Sales::Pages::DepositValueCheck.new("deposit_joint_purchase_value_check", nil, self, joint_purchase: true),
Form::Sales::Pages::DepositValueCheck.new("deposit_value_check", nil, self, joint_purchase: false),
Form::Sales::Pages::DepositDiscount.new("deposit_discount", nil, self, optional: false),
(Form::Sales::Pages::DepositDiscount.new("deposit_discount_optional", nil, self, optional: true) if form.start_year_after_2024?),
Form::Sales::Pages::SharedOwnershipDepositValueCheck.new("shared_ownership_deposit_value_check", nil, self),
Form::Sales::Pages::MonthlyRent.new(nil, nil, self),
Form::Sales::Pages::LeaseholdCharges.new("leasehold_charges_shared_ownership", nil, self, ownershipsch: 1),

4
app/models/sales_log.rb

@ -544,4 +544,8 @@ class SalesLog < Log
def address_search_given?
address_line1_input.present? && postcode_full_input.present?
end
def is_resale?
resale == 1
end
end

15
app/models/user.rb

@ -19,6 +19,7 @@ class User < ApplicationRecord
validates :password, presence: { if: :password_required? }
validates :password, length: { within: Devise.password_length, allow_blank: true }
validates :password, confirmation: { if: :password_required? }
validates :phone_extension, format: { with: /\A\d+\z/, allow_blank: true, message: I18n.t("validations.numeric.format", field: "") }
after_validation :send_data_protection_confirmation_reminder, if: :is_dpo_changed?
@ -142,6 +143,7 @@ class User < ApplicationRecord
sign_in_count: 0,
initial_confirmation_sent: false,
reactivate_with_organisation:,
unconfirmed_email: nil,
)
end
@ -156,7 +158,6 @@ class User < ApplicationRecord
RESET_PASSWORD_TEMPLATE_ID = "2c410c19-80a7-481c-a531-2bcb3264f8e6".freeze
CONFIRMABLE_TEMPLATE_ID = "3fc2e3a7-0835-4b84-ab7a-ce51629eb614".freeze
RECONFIRMABLE_TEMPLATE_ID = "bcdec787-f0a7-46e9-8d63-b3e0a06ee455".freeze
BETA_ONBOARDING_TEMPLATE_ID = "b48bc2cd-5887-4611-8296-d0ab3ed0e7fd".freeze
USER_REACTIVATED_TEMPLATE_ID = "ac45a899-490e-4f59-ae8d-1256fc0001f9".freeze
FOR_OLD_EMAIL_CHANGED_BY_OTHER_USER_TEMPLATE_ID = "3eb80517-1051-4dfc-b4cc-cb18228a3829".freeze
FOR_NEW_EMAIL_CHANGED_BY_OTHER_USER_TEMPLATE_ID = "0cdd0be1-7fa5-4808-8225-ae4c5a002352".freeze
@ -168,8 +169,6 @@ class User < ApplicationRecord
def confirmable_template
if last_sign_in_at.present? && (unconfirmed_email.blank? || unconfirmed_email == email)
USER_REACTIVATED_TEMPLATE_ID
elsif was_migrated_from_softwire? && last_sign_in_at.blank?
BETA_ONBOARDING_TEMPLATE_ID
elsif initial_confirmation_sent && !confirmed?
RECONFIRMABLE_TEMPLATE_ID
else
@ -177,10 +176,6 @@ class User < ApplicationRecord
end
end
def was_migrated_from_softwire?
legacy_users.any? || old_user_id.present?
end
def send_confirmation_instructions
return unless active?
@ -269,6 +264,12 @@ class User < ApplicationRecord
save!(validate: false)
end
def phone_with_extension
return phone if phone_extension.blank?
"#{phone}, Ext. #{phone_extension}"
end
protected
# Checks whether a password is needed or not. For validations only.

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

@ -96,9 +96,10 @@ module Validations::Sales::FinancialValidations
if record.equity < range.min
record.errors.add :type, I18n.t("validations.financial.equity.under_min", min_equity: range.min)
record.errors.add :equity, :under_min, message: I18n.t("validations.financial.equity.under_min", min_equity: range.min)
elsif record.equity > range.max
elsif !record.is_resale? && record.equity > range.max
record.errors.add :type, I18n.t("validations.financial.equity.over_max", max_equity: range.max)
record.errors.add :equity, :over_max, message: I18n.t("validations.financial.equity.over_max", max_equity: range.max)
record.errors.add :resale, I18n.t("validations.financial.equity.over_max", max_equity: range.max)
end
end

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

@ -51,8 +51,15 @@ module Validations::Sales::SaleInformationValidations
tolerance = record.discount ? record.value * 0.05 / 100 : 1
if over_tolerance?(record.mortgage_deposit_and_grant_total, record.value_with_discount, tolerance, strict: !record.discount.nil?) && record.discounted_ownership_sale?
deposit_and_grant_sentence = record.grant.present? ? ", cash deposit (#{record.field_formatted_as_currency('deposit')}), and grant (#{record.field_formatted_as_currency('grant')})" : " and cash deposit (#{record.field_formatted_as_currency('deposit')})"
discount_sentence = record.discount.present? ? " (#{record.field_formatted_as_currency('value')}) subtracted by the sum of the full purchase price (#{record.field_formatted_as_currency('value')}) multiplied by the percentage discount (#{record.discount}%)" : ""
%i[mortgageused mortgage value deposit ownershipsch discount grant].each do |field|
record.errors.add field, I18n.t("validations.sale_information.discounted_ownership_value", mortgage_deposit_and_grant_total: record.field_formatted_as_currency("mortgage_deposit_and_grant_total"), value_with_discount: record.field_formatted_as_currency("value_with_discount"))
record.errors.add field, I18n.t("validations.sale_information.discounted_ownership_value",
mortgage: record.mortgage&.positive? ? " (#{record.field_formatted_as_currency('mortgage')})" : "",
deposit_and_grant_sentence:,
mortgage_deposit_and_grant_total: record.field_formatted_as_currency("mortgage_deposit_and_grant_total"),
discount_sentence:,
value_with_discount: record.field_formatted_as_currency("value_with_discount")).html_safe
end
end
end
@ -65,9 +72,17 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_and_deposit_total, record.value, 1)
%i[mortgageused mortgage value deposit].each do |field|
record.errors.add field, I18n.t("validations.sale_information.outright_sale_value", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), value: record.field_formatted_as_currency("value"))
record.errors.add field, I18n.t("validations.sale_information.outright_sale_value",
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
mortgage: record.mortgage&.positive? ? " (#{record.field_formatted_as_currency('mortgage')})" : "",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value")).html_safe
end
record.errors.add :ownershipsch, :skip_bu_error, message: I18n.t("validations.sale_information.outright_sale_value", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), value: record.field_formatted_as_currency("value"))
record.errors.add :ownershipsch, :skip_bu_error, message: I18n.t("validations.sale_information.outright_sale_value",
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
mortgage: record.mortgage&.positive? ? " (#{record.field_formatted_as_currency('mortgage')})" : "",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value")).html_safe
end
end
@ -155,16 +170,42 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_deposit_and_discount_total, record.expected_shared_ownership_deposit_value, 1)
%i[mortgage value deposit cashdis equity].each do |field|
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used_socialhomebuy", mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used_socialhomebuy",
mortgage: record.field_formatted_as_currency("mortgage"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
equity: "#{record.equity}%",
mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value")).html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used_socialhomebuy", mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used_socialhomebuy",
mortgage: record.field_formatted_as_currency("mortgage"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
equity: "#{record.equity}%",
mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value")).html_safe
end
elsif record.mortgage_not_used?
if over_tolerance?(record.deposit_and_discount_total, record.expected_shared_ownership_deposit_value, 1)
%i[mortgageused value deposit cashdis equity].each do |field|
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used_socialhomebuy", deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used_socialhomebuy",
deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
equity: "#{record.equity}%").html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used_socialhomebuy", deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used_socialhomebuy",
deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
equity: "#{record.equity}%").html_safe
end
end
end
@ -175,16 +216,34 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_and_deposit_total, record.expected_shared_ownership_deposit_value, 1)
%i[mortgage value deposit equity].each do |field|
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used",
mortgage: record.field_formatted_as_currency("mortgage"),
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
equity: "#{record.equity}%",
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used",
mortgage: record.field_formatted_as_currency("mortgage"),
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
equity: "#{record.equity}%",
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
end
elsif record.mortgage_not_used?
if over_tolerance?(record.deposit, record.expected_shared_ownership_deposit_value, 1)
%i[mortgageused value deposit equity].each do |field|
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used", deposit: record.field_formatted_as_currency("deposit"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value")).html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used", deposit: record.field_formatted_as_currency("deposit"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value")).html_safe
end
end
end
@ -197,15 +256,41 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_deposit_and_discount_total, record.stairbought_part_of_value, 1)
%i[mortgage value deposit cashdis stairbought].each do |field|
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used_socialhomebuy", mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used_socialhomebuy",
mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"),
mortgage: record.field_formatted_as_currency("mortgage"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
stairbought: "#{record.stairbought}%").html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used_socialhomebuy", mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used_socialhomebuy",
mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"),
mortgage: record.field_formatted_as_currency("mortgage"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
stairbought: "#{record.stairbought}%").html_safe
end
elsif over_tolerance?(record.deposit_and_discount_total, record.stairbought_part_of_value, 1)
%i[mortgageused value deposit cashdis stairbought].each do |field|
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used_socialhomebuy", deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used_socialhomebuy",
deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
stairbought: "#{record.stairbought}%").html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used_socialhomebuy", deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used_socialhomebuy",
deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
stairbought: "#{record.stairbought}%").html_safe
end
end
@ -215,15 +300,31 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_and_deposit_total, record.stairbought_part_of_value, 1)
%i[mortgage value deposit stairbought type].each do |field|
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used",
mortgage: record.field_formatted_as_currency("mortgage"),
deposit: record.field_formatted_as_currency("deposit"),
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
value: record.field_formatted_as_currency("value"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value")).html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used",
mortgage: record.field_formatted_as_currency("mortgage"),
deposit: record.field_formatted_as_currency("deposit"),
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
value: record.field_formatted_as_currency("value"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value")).html_safe
end
elsif over_tolerance?(record.deposit, record.stairbought_part_of_value, 1)
%i[mortgageused value deposit stairbought type].each do |field|
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used", deposit: record.field_formatted_as_currency("deposit"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value")).html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used", deposit: record.field_formatted_as_currency("deposit"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value")).html_safe
end
end

2
app/services/bulk_upload/lettings/year2023/row_parser.rb

@ -48,7 +48,7 @@ class BulkUpload::Lettings::Year2023::RowParser
field_42: "If 'Other', what is the type of tenancy?",
field_43: "What is the length of the fixed-term tenancy to the nearest year?",
field_44: "Is this letting sheltered accommodation?",
field_45: "Has tenant seen the DLUHC privacy notice?",
field_45: "Has tenant seen the MHCLG privacy notice?",
field_46: "What is the lead tenant's age?",
field_47: "Which of these best describes the lead tenant's gender identity?",
field_48: "Which of these best describes the lead tenant's ethnic background?",

13
app/views/content/privacy_notice.md

@ -6,7 +6,6 @@ If your household enters a new social housing tenancy or purchases a social hous
The information is provided via ‘<%= t('service_name') %>’, a service funded and managed by MHCLG. It collects information on the tenants or residents, tenancy or sale, and the dwelling itself. Some of this data is personal and sensitive, so MHCLG is responsible for ensuring it’s processed in line with data protection legislation.
## Why do we share this information?
Information collected via CORE is shared with other government departments and agencies. It’s shared with the Greater London Authority and the Regulator of Social Housing. Data providers can also access data for their organisations via CORE. Data is only shared for research and statistical purposes.
@ -27,16 +26,16 @@ Information collected via CORE relates to your tenancy, the dwelling you are liv
Collected data will be held for as long as necessary for research and statistical purposes. When no longer needed, data will be deleted in a safe manner. We’re aware some collected data is particularly sensitive. For example:
* ethnic group
* if previous tenure is a hospital, prison or approved probation hostel support
* if household left last settled home because discharged from prison, a long stay hospital or other institution
* if referral source is probation or prison, youth offending or community mental health team, or health service
- ethnic group
- if previous tenure is a hospital, prison or approved probation hostel support
- if household left last settled home because discharged from prison, a long stay hospital or other institution
- if referral source is probation or prison, youth offending or community mental health team, or health service
MHCLG publishes data annually, in aggregate form, as part of a report and complementary tables.
* For annual lettings data, visit: [https://www.gov.uk/government/collections/rents-lettings-and-tenancies](https://www.gov.uk/government/collections/rents-lettings-and-tenancies)
- For annual lettings data, visit: [https://www.gov.uk/government/collections/rents-lettings-and-tenancies](https://www.gov.uk/government/collections/rents-lettings-and-tenancies)
* For annual sales data, visit: [https://www.gov.uk/government/collections/social-housing-sales-including-right-to-buy-and-transfers](https://www.gov.uk/government/collections/social-housing-sales-including-right-to-buy-and-transfers)
- For annual sales data, visit: [https://www.gov.uk/government/collections/social-housing-sales-including-right-to-buy-and-transfers](https://www.gov.uk/government/collections/social-housing-sales-including-right-to-buy-and-transfers)
Detail-level data is anonymised and protected, minimising identification risk. It's held with the UK Data Archive.

4
app/views/form/_numeric_question.html.erb

@ -1,4 +1,4 @@
<%= render partial: "form/guidance/#{question.top_guidance_partial}" if question.top_guidance? %>
<%= render partial: "form/guidance/#{question.top_guidance_partial}", locals: { log: @log } if question.top_guidance? %>
<%= f.govuk_text_field(
question.id.to_sym,
@ -14,7 +14,7 @@
suffix_text: question.suffix_label(@log),
value: format_money_input(log: @log, question:),
inputmode: "numeric",
pattern: "\d*\.?\d*",
pattern: "\\d*\\.?\\d*",
**stimulus_html_attributes(question),
) %>

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

@ -1,4 +1,4 @@
<%= render partial: "form/guidance/#{question.top_guidance_partial}" if question.top_guidance? %>
<%= render partial: "form/guidance/#{question.top_guidance_partial}", locals: { log: @log } if question.top_guidance? %>
<% banner = question.notification_banner(@log) %>
<% if banner %>
<%= govuk_notification_banner(

29
app/views/form/guidance/_financial_calculations_discounted_ownership.html.erb

@ -0,0 +1,29 @@
<% discount_question_link = question_link("discount", log, current_user) %>
<% grant_question_link = question_link("grant", log, current_user) %>
<% value_question_link = question_link("value", log, current_user) %>
<%= govuk_details(summary_text: "How the financial values are calculated") do %>
<p class="govuk-body">
<% if log.mortgage_used? || log.mortgageused.blank? %>
<% mortgage_question_link = log.mortgageused.blank? ? question_link("mortgageused", log, current_user) : question_link("mortgage", log, current_user) %>
The mortgage amount <%= mortgage_question_link %><% if grant_question_link.blank? %>
and cash deposit <%= question_link("deposit", log, current_user) %>
<% else %>, cash deposit <%= question_link("deposit", log, current_user) %>
and grant <%= grant_question_link %>
<% end %>
added together must equal
<% else %>
<% if grant_question_link.blank? %>
Cash deposit <%= question_link("deposit", log, current_user) %> must equal
<% else %>
Cash deposit <%= question_link("deposit", log, current_user) %>
and grant <%= grant_question_link %>
added together must equal
<% end %>
<% end %>
the purchase price <%= value_question_link %>
<% if discount_question_link.present? %>
subtracted by the sum of the purchase price <%= value_question_link %>
multiplied by the discount <%= discount_question_link %>
<% end %>
</p>
<% end %>

13
app/views/form/guidance/_financial_calculations_outright_sale.html.erb

@ -0,0 +1,13 @@
<%= govuk_details(summary_text: "How the financial values are calculated") do %>
<p class="govuk-body">
<% if log.mortgage_used? || log.mortgageused.blank? %>
<% mortgage_question_link = log.mortgageused.blank? ? question_link("mortgageused", log, current_user) : question_link("mortgage", log, current_user) %>
The mortgage amount <%= mortgage_question_link %>
and cash deposit <%= question_link("deposit", log, current_user) %>
added together must equal
<% else %>
Cash deposit <%= question_link("deposit", log, current_user) %> must equal
<% end %>
the purchase price <%= question_link("value", log, current_user) %>
</p>
<% end %>

30
app/views/form/guidance/_financial_calculations_shared_ownership.html.erb

@ -0,0 +1,30 @@
<%= govuk_details(summary_text: "How the financial values are calculated") do %>
<p class="govuk-body">
<% if log.mortgage_used? || log.mortgageused.blank? %>
<% mortgage_question_link = log.mortgageused.blank? ? question_link("mortgageused", log, current_user) : question_link("mortgage", log, current_user) %>
The mortgage amount <%= mortgage_question_link %><% if log.type == 18 %>, cash deposit <%= question_link("deposit", log, current_user) %>,
and cash discount <%= question_link("cashdis", log, current_user) %>
added together
<% else %>
and cash deposit <%= question_link("deposit", log, current_user) %>
added together
<% end %>
<% elsif log.mortgage_not_used? || log.mortgage_use_unknown? %>
<% if log.type == 18 %>
The cash deposit <%= question_link("deposit", log, current_user) %>,
and cash discount <%= question_link("cashdis", log, current_user) %>
added together
<% else %>
Cash deposit <%= question_link("deposit", log, current_user) %>
<% end %>
<% end %>
must equal
the purchase price <%= question_link("value", log, current_user) %>
<% stairbought_page = log.form.get_question("stairbought", log).page %>
<% if stairbought_page.routed_to?(log, current_user) %>
multiplied by the percentage bought <%= question_link("stairbought", log, current_user) %>
<% else %>
multiplied by the percentage equity stake <%= question_link("equity", log, current_user) %>
<% end %>
</p>
<% end %>

7
app/views/users/edit.html.erb

@ -24,7 +24,12 @@
<%= f.govuk_phone_field :phone,
label: { text: "Telephone number", size: "m" },
autocomplete: "phone",
autocomplete: "tel-national",
spellcheck: "false" %>
<%= f.govuk_phone_field :phone_extension,
label: { text: "Extension number (optional)", size: "m" },
autocomplete: "tel-extension",
spellcheck: "false" %>
<% if current_user.data_coordinator? || current_user.support? %>

8
app/views/users/new.html.erb

@ -25,10 +25,16 @@
<%= f.govuk_phone_field :phone,
label: { text: "Telephone number", size: "m" },
autocomplete: "phone",
autocomplete: "tel-national",
spellcheck: "false",
value: @user.phone %>
<%= f.govuk_phone_field :phone_extension,
label: { text: "Extension number (optional)", size: "m" },
autocomplete: "tel-extension",
spellcheck: "false",
value: @user.phone_extension %>
<% if current_user.support? %>
<% null_option = [OpenStruct.new(id: "", name: "Select an option")] %>
<% organisations = Organisation.filter_by_active.map { |org| OpenStruct.new(id: org.id, name: org.name) } %>

2
app/views/users/show.html.erb

@ -43,7 +43,7 @@
<%= summary_list.with_row do |row|
row.with_key { "Telephone number" }
row.with_value { @user.phone }
row.with_value { @user.phone_with_extension }
if UserPolicy.new(current_user, @user).edit_telephone_numbers?
row.with_action(visually_hidden_text: "telephone number", href: aliased_user_edit(@user, current_user), html_attributes: { "data-qa": "change-telephone-number" })
else

8
babel.config.js

@ -35,10 +35,10 @@ module.exports = function (api) {
'babel-plugin-macros',
'@babel/plugin-syntax-dynamic-import',
isTestEnv && 'babel-plugin-dynamic-import-node',
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-private-methods',
'@babel/plugin-proposal-private-property-in-object',
'@babel/plugin-transform-class-properties',
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-transform-private-methods',
'@babel/plugin-transform-private-property-in-object',
'@babel/plugin-transform-regenerator',
'@babel/plugin-transform-runtime',
[

376
config/forms/2021_2022.json

@ -34,9 +34,7 @@
}
},
"conditional_for": {
"postcode_full": [
1
]
"postcode_full": [1]
},
"hidden_in_check_answers": {
"depends_on": [
@ -61,12 +59,14 @@
"is_la_inferred": true
}
},
"inferred_check_answers_value": [{
"condition": {
"postcode_known": 0
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"postcode_known": 0
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -851,9 +851,7 @@
}
},
"conditional_for": {
"mrcdate": [
1
]
"mrcdate": [1]
}
},
"mrcdate": {
@ -999,9 +997,7 @@
}
},
"conditional_for": {
"tenancyother": [
3
]
"tenancyother": [3]
}
},
"tenancyother": {
@ -1043,9 +1039,7 @@
}
},
"conditional_for": {
"tenancyother": [
3
]
"tenancyother": [3]
}
},
"tenancyother": {
@ -1277,9 +1271,7 @@
}
},
"conditional_for": {
"age1": [
0
]
"age1": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -1301,12 +1293,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age1_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age1_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -2056,9 +2050,7 @@
}
},
"conditional_for": {
"age2": [
0
]
"age2": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -2080,12 +2072,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age2_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age2_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -2591,9 +2585,7 @@
}
},
"conditional_for": {
"age3": [
0
]
"age3": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -2615,12 +2607,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age3_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age3_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -3123,9 +3117,7 @@
}
},
"conditional_for": {
"age4": [
0
]
"age4": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -3147,12 +3139,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age4_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age4_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -3652,9 +3646,7 @@
}
},
"conditional_for": {
"age5": [
0
]
"age5": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -3676,12 +3668,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age5_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age5_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -4178,9 +4172,7 @@
}
},
"conditional_for": {
"age6": [
0
]
"age6": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -4202,12 +4194,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age6_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age6_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -4701,9 +4695,7 @@
}
},
"conditional_for": {
"age7": [
0
]
"age7": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -4725,12 +4717,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age7_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age7_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -5221,9 +5215,7 @@
}
},
"conditional_for": {
"age8": [
0
]
"age8": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -5245,12 +5237,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age8_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age8_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -6251,7 +6245,7 @@
"value": "Other"
},
"47": {
"value":"Tenant prefers not to say"
"value": "Tenant prefers not to say"
},
"divider": {
"value": true
@ -6261,9 +6255,7 @@
}
},
"conditional_for": {
"reasonother": [
20
]
"reasonother": [20]
}
},
"reasonother": {
@ -6495,9 +6487,7 @@
}
},
"conditional_for": {
"ppostcode_full": [
0
]
"ppostcode_full": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -6522,12 +6512,14 @@
"is_previous_la_inferred": true
}
},
"inferred_check_answers_value": [{
"condition": {
"ppcodenk": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"ppcodenk": 1
},
"value": "Not known"
}
]
}
}
},
@ -6560,9 +6552,7 @@
}
},
"conditional_for": {
"prevloc": [
1
]
"prevloc": [1]
}
},
"prevloc": {
@ -6957,12 +6947,14 @@
"W92000004": "Wales",
"9300000XX": "Outside UK"
},
"inferred_check_answers_value": [{
"condition": {
"previous_la_known": 0
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"previous_la_known": 0
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -7618,9 +7610,7 @@
}
},
"conditional_for": {
"chcharge": [
1
]
"chcharge": [1]
}
},
"chcharge": {
@ -7715,9 +7705,7 @@
}
},
"conditional_for": {
"chcharge": [
1
]
"chcharge": [1]
}
},
"chcharge": {
@ -7762,9 +7750,7 @@
}
},
"conditional_for": {
"chcharge": [
1
]
"chcharge": [1]
}
},
"chcharge": {
@ -7809,9 +7795,7 @@
}
},
"conditional_for": {
"chcharge": [
1
]
"chcharge": [1]
}
},
"chcharge": {
@ -7888,12 +7872,7 @@
"width": 5,
"prefix": "£",
"suffix": " every week",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -7907,12 +7886,7 @@
"width": 5,
"prefix": "£",
"suffix": " every week",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -7926,12 +7900,7 @@
"width": 5,
"prefix": "£",
"suffix": " every week",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -7945,12 +7914,7 @@
"width": 5,
"prefix": "£",
"suffix": " every week",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -7966,12 +7930,7 @@
"suffix": " every week",
"readonly": true,
"requires_js": true,
"fields_added": [
"brent",
"scharge",
"pscharge",
"supcharg"
]
"fields_added": ["brent", "scharge", "pscharge", "supcharg"]
}
},
"depends_on": [
@ -8111,12 +8070,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 2 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8130,12 +8084,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 2 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8149,12 +8098,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 2 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8168,12 +8112,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 2 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8189,12 +8128,7 @@
"suffix": " every 2 weeks",
"readonly": true,
"requires_js": true,
"fields_added": [
"brent",
"scharge",
"pscharge",
"supcharg"
]
"fields_added": ["brent", "scharge", "pscharge", "supcharg"]
}
},
"depends_on": [
@ -8234,12 +8168,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 4 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8253,12 +8182,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 4 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8272,12 +8196,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 4 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8291,12 +8210,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 4 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8312,12 +8226,7 @@
"suffix": " every 4 weeks",
"readonly": true,
"requires_js": true,
"fields_added": [
"brent",
"scharge",
"pscharge",
"supcharg"
]
"fields_added": ["brent", "scharge", "pscharge", "supcharg"]
}
},
"depends_on": [
@ -8357,12 +8266,7 @@
"width": 5,
"prefix": "£",
"suffix": " every month",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8376,12 +8280,7 @@
"width": 5,
"prefix": "£",
"suffix": " every month",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8395,12 +8294,7 @@
"width": 5,
"prefix": "£",
"suffix": " every month",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8414,12 +8308,7 @@
"width": 5,
"prefix": "£",
"suffix": " every month",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8435,12 +8324,7 @@
"suffix": " every month",
"readonly": true,
"requires_js": true,
"fields_added": [
"brent",
"scharge",
"pscharge",
"supcharg"
]
"fields_added": ["brent", "scharge", "pscharge", "supcharg"]
}
},
"depends_on": [
@ -8637,9 +8521,7 @@
}
},
"conditional_for": {
"tshortfall": [
0
]
"tshortfall": [0]
}
},
"tshortfall": {

1008
config/forms/2022_2023.json

File diff suppressed because it is too large Load Diff

16
config/forms/schema/2021_2022.json

@ -4,12 +4,7 @@
"title": "Form",
"description": "A form",
"type": "object",
"required": [
"form_type",
"start_year",
"end_year",
"sections"
],
"required": ["form_type", "start_year", "end_year", "sections"],
"properties": {
"form_type": {
"description": "",
@ -40,9 +35,7 @@
"[a-z_]+": {
"description": "",
"type": "object",
"required": [
"label"
],
"required": ["label"],
"properties": {
"label": {
"description": "",
@ -69,10 +62,7 @@
"[a-z_]+": {
"description": "",
"type": "object",
"required": [
"header",
"check_answer_label"
],
"required": ["header", "check_answer_label"],
"properties": {
"check_answer_label": {
"description": "",

47
config/forms/schema/generic.json

@ -4,12 +4,7 @@
"title": "Form",
"description": "A form",
"type": "object",
"required": [
"form_type",
"start_year",
"end_year",
"sections"
],
"required": ["form_type", "start_year", "end_year", "sections"],
"properties": {
"form_type": {
"description": "",
@ -40,9 +35,7 @@
"[a-z_]+": {
"description": "SubSection Name",
"type": "object",
"required": [
"label"
],
"required": ["label"],
"properties": {
"label": {
"description": "",
@ -54,10 +47,7 @@
"^(?!(depends_on))[a-z_]+$": {
"description": "Page Name",
"type": "object",
"required": [
"header",
"questions"
],
"required": ["header", "questions"],
"properties": {
"header": {
"description": "",
@ -73,10 +63,7 @@
"[a-z_]+": {
"description": "Question Name",
"type": "object",
"required": [
"header",
"type"
],
"required": ["header", "type"],
"properties": {
"header": {
"description": "",
@ -115,20 +102,22 @@
"description": "fields that get inferred based on the value of the current field",
"type": "object"
},
"inferred_check_answers_value": [{
"description": "value that gets displayed in the check answers for this field if the given condition is met",
"type": "object",
"properties": {
"condition": {
"description": "",
"type": "object"
},
"value": {
"description": "",
"type": "object"
"inferred_check_answers_value": [
{
"description": "value that gets displayed in the check answers for this field if the given condition is met",
"type": "object",
"properties": {
"condition": {
"description": "",
"type": "object"
},
"value": {
"description": "",
"type": "object"
}
}
}
}]
]
},
"minProperties": 1
}

20
config/locales/en.yml

@ -644,8 +644,8 @@ en:
must_be_after_hodate: "Sale completion date must be after practical completion or handover date"
previous_property_type:
property_type_bedsit: "A bedsit cannot have more than 1 bedroom"
discounted_ownership_value: "The mortgage, deposit, and grant when added together is %{mortgage_deposit_and_grant_total}, and the purchase price times by the discount is %{value_with_discount}. These figures should be the same"
outright_sale_value: "The mortgage and deposit when added together is %{mortgage_and_deposit_total}, and the purchase price is %{value}. These figures should be the same."
discounted_ownership_value: "The mortgage%{mortgage}%{deposit_and_grant_sentence} added together is %{mortgage_deposit_and_grant_total}.</br></br>The full purchase price%{discount_sentence} is %{value_with_discount}.</br></br>These two amounts should be the same."
outright_sale_value: "The mortgage%{mortgage} and cash deposit (%{deposit}) when added together is %{mortgage_and_deposit_total}.</br></br>The full purchase price is %{value}.</br></br>These two amounts should be the same."
monthly_rent:
higher_than_expected: "Basic monthly rent must be between £0.00 and £9,999.00"
grant:
@ -656,15 +656,15 @@ en:
over_discounted_london_max: "The percentage discount multiplied by the purchase price is %{discount_value}. This figure should not be more than £136,400 for properties in London."
over_discounted_max: "The percentage discount multiplied by the purchase price is %{discount_value}. This figure should not be more than £102,400 for properties outside of London."
non_staircasing_mortgage:
mortgage_used: "The mortgage and deposit added together is %{mortgage_and_deposit_total}. The value multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}. These figures should be the same."
mortgage_not_used: "The deposit is %{deposit} and the value multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}. These figures should be the same."
mortgage_used_socialhomebuy: "The mortgage, deposit, and cash discount added together is %{mortgage_deposit_and_discount_total}. The value multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}. These figures should be the same."
mortgage_not_used_socialhomebuy: "The deposit and cash discount added together is %{deposit_and_discount_total}. The value multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}. These figures should be the same."
mortgage_used: "The mortgage (%{mortgage}) and cash deposit (%{deposit}) added together is %{mortgage_and_deposit_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage equity stake purchased (%{equity}) is %{expected_shared_ownership_deposit_value}.</br></br>These two amounts should be the same."
mortgage_not_used: "The cash deposit is %{deposit}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}.</br></br>These two amounts should be the same."
mortgage_used_socialhomebuy: "The mortgage amount (%{mortgage}), cash deposit (%{deposit}), and cash discount (%{cashdis}) added together is %{mortgage_deposit_and_discount_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage equity stake purchased (%{equity}) is %{expected_shared_ownership_deposit_value}.</br></br>These two amounts should be the same."
mortgage_not_used_socialhomebuy: "The cash deposit (%{deposit}) and cash discount (%{cashdis}) added together is %{deposit_and_discount_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought (%{equity}) is %{expected_shared_ownership_deposit_value}.</br></br>These two amounts should be the same."
staircasing_mortgage:
mortgage_used: "The mortgage and deposit added together is %{mortgage_and_deposit_total}. The value multiplied by the percentage bought is %{stairbought_part_of_value}. These figures should be the same."
mortgage_not_used: "The deposit is %{deposit} and the value multiplied by the percentage bought is %{stairbought_part_of_value}. These figures should be the same."
mortgage_used_socialhomebuy: "The mortgage, deposit, and cash discount added together is %{mortgage_deposit_and_discount_total}. The value multiplied by the percentage bought is %{stairbought_part_of_value}. These figures should be the same."
mortgage_not_used_socialhomebuy: "The deposit and cash discount added together is %{deposit_and_discount_total}. The value multiplied by the percentage bought is %{stairbought_part_of_value}. These figures should be the same."
mortgage_used: "The mortgage (%{mortgage}) and cash deposit (%{deposit}) added together is %{mortgage_and_deposit_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought is %{stairbought_part_of_value}.</br></br>These two amounts should be the same."
mortgage_not_used: "The cash deposit is %{deposit}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought is %{stairbought_part_of_value}.</br></br>These two amounts should be the same."
mortgage_used_socialhomebuy: "The mortgage amount (%{mortgage}), cash deposit (%{deposit}), and cash discount (%{cashdis}) added together is %{mortgage_deposit_and_discount_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought (%{stairbought}) is %{stairbought_part_of_value}.</br></br>These two amounts should be the same."
mortgage_not_used_socialhomebuy: "The cash deposit (%{deposit}) and cash discount (%{cashdis}) added together is %{deposit_and_discount_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought (%{stairbought}) is %{stairbought_part_of_value}.</br></br>These two amounts should be the same."
stairowned:
mortgageused_dont_know: "The percentage owned has to be 100% if the mortgage used is 'Don’t know'"
merge_request:

1
config/storage.yml

@ -5,7 +5,6 @@ test:
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3

5
db/migrate/20240819143150_add_phone_extension_to_users.rb

@ -0,0 +1,5 @@
class AddPhoneExtensionToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :phone_extension, :string
end
end

3
db/schema.rb

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_07_15_082338) do
ActiveRecord::Schema[7.0].define(version: 2024_08_19_143150) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -788,6 +788,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_07_15_082338) do
t.boolean "initial_confirmation_sent"
t.boolean "reactivate_with_organisation"
t.datetime "discarded_at"
t.string "phone_extension"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["encrypted_otp_secret_key"], name: "index_users_on_encrypted_otp_secret_key", unique: true

2
docker-compose.yml

@ -1,4 +1,4 @@
version: '3.6'
version: "3.6"
volumes:
dbdata:

2
docs/adr/adr-006-saving-values.md

@ -18,6 +18,6 @@ There are a few reasons we have opted to save the values directly, they are as f
- Changing the wording/casing of the answers could result in discrepancies in the database.
- There is a small risk that if the database is accessed by someone unauthorised they would have access to personally identifiable information if we were to collect Any. We will be mitigating this risk by encrypting the production database.
- There is a small risk that if the database is accessed by someone unauthorised they would have access to personally identifiable information if we were to collect Any. We will be mitigating this risk by encrypting the production database.
This decision is not too difficult to change and can be revisited in the future if there is sufficient reason to switch to storing keys/numbers and using enums and active record to convert those to the appropriate values.

7
docs/adr/adr-015-asset-pipeline.md

@ -12,12 +12,11 @@ However, since Rails 7, it's been deprecated by the Rails CORE team and it's jav
The primary options considered were:
1. [Import maps](https://github.com/rails/importmap-rails) - Rails 7 default. Serve JS directly but do no transpiling so not suitable
2. [JSBundling](https://github.com/rails/jsbundling-rails) - Rails recommended
- With [ESBuild](https://esbuild.github.io/) - fast and does some transpiling but [doesn't support ES5/IE11](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/203)
- With [Rollup](https://www.rollupjs.org/guide/en/) - similar to ESBuild, node rather than Go based, doesn't have the big speed benefits
- With [Webpack](https://webpack.js.org/) - rather than the old approach of using Webpacker as a opinionated wrapper around webpack, this approach uses webpack directly.
- With [ESBuild](https://esbuild.github.io/) - fast and does some transpiling but [doesn't support ES5/IE11](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/203)
- With [Rollup](https://www.rollupjs.org/guide/en/) - similar to ESBuild, node rather than Go based, doesn't have the big speed benefits
- With [Webpack](https://webpack.js.org/) - rather than the old approach of using Webpacker as a opinionated wrapper around webpack, this approach uses webpack directly.
3. [Shakapacker](https://github.com/shakacode/shakapacker) - the "official" community maintained fork of Webpacker 6 RC. Requires upgrading current install since breaking changes happened between Webpacker 5 & 6
4. [Vite](https://vite-ruby.netlify.app/) - Webpack alternative

2
docs/adr/adr-016-hotwire.md

@ -14,4 +14,4 @@ For **Stimulus** we were able to do this and are continuing to use it. This is a
- Adding the StimulusJS NPM package path to our [webpack config](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/blob/main/webpack.config.js#L23) rules to be transpiled
- Adding the required Babel plugins to our [Babel config](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/blob/main/babel.config.js#L34)
For **Turbo** the same approach was attempted but proved [unsuccessful](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/430). As a result we decided to [remove Turbo](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/406) until we can drop support for Internet Explorer. This does have a perceptible impact on UX/speed but provides the most browser compatibility.
For **Turbo** the same approach was attempted but proved [unsuccessful](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/430). As a result we decided to [remove Turbo](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/406) until we can drop support for Internet Explorer. This does have a perceptible impact on UX/speed but provides the most browser compatibility.

3
docs/adr/adr-018-form-setup.md

@ -18,7 +18,8 @@ The amount of application context needed to make it work is what ultimately drov
Instead the setup section is now composed of coded Ruby class in `app/models/form/setup`.
It still has all the same components as before:
- Section
- Section
- Subsection
- Pages
- Questions

1
docs/adr/adr-019-form-end-dates.md

@ -12,6 +12,7 @@ There might be short extensions to the deadline, so shortly after the last day t
Also, if incorrect data is found during QA process, data providers might be asked to correct it. Once the data has been through its first QA processes and is as present and correct as possible, the ability to edit and delete logs is closed. This is typically in late summer/autumn, but it depends on the statistical analysis.
To accommodate the different end dates, we will now store 3 different dates on the form definition:
- Submission deadline (submission_deadline) - this is the date displayed at the top of a completed log in lettings and sales - "You can review and make changes to this log until 9 June 2024.". Nothing happens on this date
- New logs end date (new_logs_end_date) - no new logs for that collection year can be submitted, but logs can be edited
- Edit and delete logs end date (edit_end_date) - logs can no longer be edited or deleted. Completed logs can still be viewed. Materials / references to the collection year are removed.

31
docs/api/v1.json

@ -54,9 +54,7 @@
{
"schema": {
"type": "string",
"enum": [
"application/json"
]
"enum": ["application/json"]
},
"in": "header",
"name": "Accept",
@ -109,9 +107,7 @@
"Invalid Age": {
"value": {
"errors": {
"age1": [
"Tenant age must be between 16 and 120"
]
"age1": ["Tenant age must be between 16 and 120"]
}
}
}
@ -136,9 +132,7 @@
{
"schema": {
"type": "string",
"enum": [
"application/json"
]
"enum": ["application/json"]
},
"in": "header",
"name": "Accept",
@ -177,9 +171,7 @@
{
"schema": {
"type": "string",
"enum": [
"application/json"
]
"enum": ["application/json"]
},
"in": "header",
"name": "Accept",
@ -219,9 +211,7 @@
"reasonable_preference_reason": [
"If reasonable preference is Yes, a reason must be given"
],
"age1": [
"Tenant age must be between 16 and 120"
]
"age1": ["Tenant age must be between 16 and 120"]
}
}
}
@ -247,9 +237,7 @@
"schema": {
"type": "string",
"pattern": "application/json",
"enum": [
"application/json"
]
"enum": ["application/json"]
},
"in": "header",
"name": "Accept",
@ -430,12 +418,7 @@
"sex1": {
"type": "string",
"minLength": 1,
"enum": [
"F: Female",
"M:Male",
"X:Non-binary",
"R:Refused"
],
"enum": ["F: Female", "M:Male", "X:Non-binary", "R:Refused"],
"maxLength": 1
},
"ethnic": {

10
docs/app_api.md

@ -6,10 +6,10 @@ nav_order: 8
In order to use the app as an API, you will need to configure requests to the API as so:
* Configure your request with Basic Auth. Set the username to be the same as `API_USER` and password as the `API_KEY` (`API_USER` and `API_KEY` are environment variables that should be set for the application)
* Check the endpoint you are calling is an action that is `create`, `show` or `update`
* Check you are setting the following request headers:
* `Content-Type = application/json`
* `Action = application/json` N.B. If you use `*/*` instead, the request won't be recognised as an API request`
- Configure your request with Basic Auth. Set the username to be the same as `API_USER` and password as the `API_KEY` (`API_USER` and `API_KEY` are environment variables that should be set for the application)
- Check the endpoint you are calling is an action that is `create`, `show` or `update`
- Check you are setting the following request headers:
- `Content-Type = application/json`
- `Action = application/json` N.B. If you use `*/*` instead, the request won't be recognised as an API request`
Currently only the logs controller is configured to accept and authenticate API requests, when the above API environment variables are set.

8
docs/documentation_website.md

@ -7,13 +7,13 @@ nav_order: 11
The documentation website can be generated and run locally using Jekyll.
1. Change into the `/docs/` directory:\
`cd docs`
`cd docs`
2. Install Jekyll and its dependencies:\
`bundle install`
`bundle install`
3. Start the Jekyll server:\
`bundle exec jekyll serve`
`bundle exec jekyll serve`
4. View the website:\
<http://localhost:4000>
<http://localhost:4000>

2
docs/form/builder.md

@ -115,7 +115,7 @@ Assumptions made by the format:
- For conditionally shown questions, conditions that have been implemented and can be used are:
- Radio question answer option selected matches one of conditional e.g.\
`["answer-options-1-string", "answer-option-3-string"]`
`["answer-options-1-string", "answer-option-3-string"]`
- Numeric question value matches condition e.g. [">2"], ["<7"] or ["== 6"]

4
docs/form/definition.md

@ -16,12 +16,12 @@ The current system is built around a form definition written in JSON. At the top
An example of this might look like the following:
```json
{
{
"form_type": "lettings",
"start_date": "2021-04-01T00:00:00.000+01:00",
"end_date": "2022-07-01T00:00:00.000+01:00",
"sections": {
...
...
}
}
```

4
docs/form/question.md

@ -31,7 +31,7 @@ An example question might look something like this:
}
```
In the above example the the question has the id `postcode_known`.
In the above example the the question has the id `postcode_known`.
The `check_answer_label` contains the text that will be displayed in the label of the table on the check answers page.
@ -85,6 +85,6 @@ The answer the data inputter provides to some questions allows us to infer the v
In the above example the width is an optional attribute and can be provided for text type questions to determine the width of the text box on the page when when the question is displayed to a user (this allows you to match the width of the text box on the page to that of the design for a question).
The above example links to the first example as both of these questions would be on the same page. The `inferred_check_answers_value` is what should be displayed on the check answers page for this question if we infer it. If the value of `postcode_known` was given as `0` (which is a no), as seen in the condition part of `inferred_check_answers_value` then we can infer that the data inputter does not know the postcode and so we would display the value of `Not known` on the check answers page for the postcode.
The above example links to the first example as both of these questions would be on the same page. The `inferred_check_answers_value` is what should be displayed on the check answers page for this question if we infer it. If the value of `postcode_known` was given as `0` (which is a no), as seen in the condition part of `inferred_check_answers_value` then we can infer that the data inputter does not know the postcode and so we would display the value of `Not known` on the check answers page for the postcode.
In the above example the `inferred_answers` refers to a question where we can infer the answer based on the answer of this question. In this case the `la` question can be inferred from the postcode value given by the data inputter as we are able to lookup the local authority based on the postcode given. We then set a property on the lettings log `is_la_inferred` to true to indicate that this is an answer we've inferred.

BIN
docs/images/service.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
docs/images/service.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

41
docs/infrastructure.md

@ -7,32 +7,38 @@ nav_order: 5
## Current infrastructure
Currently, there are four environments with infrastructure:
- Meta
- Development (Review Apps)
- Staging
- Production
### Meta
This holds the Terraform “backend” and the ECR(s).
The Terraform “backend” consists of:
- S3 buckets - for storing Terraform state files. One for all non-production environments (including the meta environment itself), and another just for production.
- DynamoDB - for managing access and locking of all state files.
The ECR(s) are:
- core - holds the application Docker images.
- db-migration - holds the Docker images curated to help migrate a DB from PaaS to AWS.
- s3-migration - holds the Docker images curated to help migrate S3 files from PaaS to AWS.
N.B. the migration ECRs may or may not be present, depending on if the Terraform has been configured to create migration infrastructure. The migration infrastructure is only used to help migrate the DB and S3 from PaaS to AWS, so is usually therefore only temporarily present.
N.B. the migration ECRs may or may not be present, depending on if the Terraform has been configured to create migration infrastructure. The migration infrastructure is only used to help migrate the DB and S3 from PaaS to AWS, so is usually therefore only temporarily present.
### Development / Staging / Production
These are the main environments holding the “application” infrastructure.
These are the main environments holding the “application” infrastructure.
Though not exhaustive, each of them will generally contain the following key components:
- ECS Fargate cluster
- RDS (PostgreSQL database)
- ElastiCache (Redis data store)
- S3 buckets
- One for Bulk upload (sometimes also to referred to as the CSV bucket)
- One for CDS Export
- One for Bulk upload (sometimes also to referred to as the CSV bucket)
- One for CDS Export
- VPC
- Private subnets
- Public subnets
@ -43,11 +49,12 @@ Though not exhaustive, each of them will generally contain the following key com
- WAF (Firewall)
### Development / Review Apps
The development environment is used for Review Apps, and has some infrastructure that is created per-review-app and some that is shared by all apps.
The development environment is used for Review Apps, and has some infrastructure that is created per-review-app and some that is shared by all apps.
In general, each review app has its own ECS Fargate cluster and Redis instances (plus any infrastructure to enable this), while the rest is shared.
Where to find the Infrastructure?
The infrastructure is managed as code.
The infrastructure is managed as code.
In the terraform folder of the codebase, there will be dedicated sub-folders for each of the aforementioned environments, where all the infrastructure for them is defined.
## Deployment (Pipeline — Recommended)
@ -64,7 +71,6 @@ To deploy you need to:
6. Post success message on Slack.
7. Tag tickets as ‘Released’ and move tickets to done on JIRA.
## CI/CD
When a commit is made to `main` the following GitHub action jobs are triggered:
@ -88,26 +94,27 @@ After a sucessful deployment a comment will be added to the pull request with th
Once a pull request has been closed the review app infrastructure will be tore down to save on any costs. Should you wish to re-open a closed pull request the review app will be spun up again.
### Review app deployment failures
### Review app deployment failures
One reason a review app deployment might fail is that it is attempting to run migrations which conflict with data in the database. For example you might have introduced a unique constraint, but the database associated with the review app has duplicate data in it that would violate this constraint, and so the migration cannot be run.
## Destroying/recreating infrastructure
Things to watch out for when destroying/creating infra:
- All resources
- The lifecycle meta-argument prevent_destroy will stop you destroying things. Best to set this to false before trying to destroy!
- The lifecycle meta-argument prevent_destroy will stop you destroying things. Best to set this to false before trying to destroy!
- Database
- skip_final_snapshot being false will prevent you from destroying the db without creating a final snapshot.
- skip_final_snapshot being false will prevent you from destroying the db without creating a final snapshot.
- Load Balancer
- Sometimes when creating infra, you may see the error message: failure configuring LB attributes: InvalidConfigurationRequest: Access Denied for bucket: <load-balancer-access-log-bucket-name>. Please check S3bucket permission during a terraform apply. To get around this you may have wait a few minutes and try applying again to ensure everything is fully updated (the error shouldn’t appear on the second attempt). It’s unclear what the exact cause is, but as this is related to infra that enables load balancer access logging, it is suspected there might be a delay with the S3 bucket permissions being realised or the load balancer recognising it can access the bucket.
- Sometimes when creating infra, you may see the error message: failure configuring LB attributes: InvalidConfigurationRequest: Access Denied for bucket: <load-balancer-access-log-bucket-name>. Please check S3bucket permission during a terraform apply. To get around this you may have wait a few minutes and try applying again to ensure everything is fully updated (the error shouldn’t appear on the second attempt). It’s unclear what the exact cause is, but as this is related to infra that enables load balancer access logging, it is suspected there might be a delay with the S3 bucket permissions being realised or the load balancer recognising it can access the bucket.
- S3
- Terraform won’t let you delete buckets that have objects in them.
- Terraform won’t let you delete buckets that have objects in them.
- Secrets
- If you destroy secrets, they will actually be marked as ‘scheduled to delete’ which will take effect after a minimum of 7 days. You can’t recreate secrets with the same name during this period. If you want to destroy immediately, you need to do it from the command line (using your staging developer role, rather than your MHCLG-wide role used to apply Terraform) with this command: aws secretsmanager delete-secret --force-delete-without-recovery --secret-id <secret-arn>. (Note that if a secret is marked as scheduled to delete, you can undo this in the console to make it an ‘active’ secret again.)
- You may need to manually re-enter secret values into Secrets Manager at some point. When you do, just paste the secret value as plain text (don’t enter a key name, or format it as JSON).
- If you destroy secrets, they will actually be marked as ‘scheduled to delete’ which will take effect after a minimum of 7 days. You can’t recreate secrets with the same name during this period. If you want to destroy immediately, you need to do it from the command line (using your staging developer role, rather than your MHCLG-wide role used to apply Terraform) with this command: aws secretsmanager delete-secret --force-delete-without-recovery --secret-id <secret-arn>. (Note that if a secret is marked as scheduled to delete, you can undo this in the console to make it an ‘active’ secret again.)
- You may need to manually re-enter secret values into Secrets Manager at some point. When you do, just paste the secret value as plain text (don’t enter a key name, or format it as JSON).
- ECS
- Sometimes task definitions don’t get deleted. You may need to manually delete them.
- After destroying the db, you’ll need to make sure the ad hoc ECS task which seeds the database gets run in order to set up the database correctly.
- Sometimes task definitions don’t get deleted. You may need to manually delete them.
- After destroying the db, you’ll need to make sure the ad hoc ECS task which seeds the database gets run in order to set up the database correctly.
- SNS
- When creating an email subscription in an environment, Terraform will look up the email to use as the subscription endpoint from Secrets Manager. If you haven’t already created this (e.g. by running terraform apply -target="module.monitoring" -var="create_secrets_first=true") then this will lead to the subscription creation erroring, because it can’t retrieve the value of the secret (because it doesn’t exist yet). If this happens, remember you’ll need to go to Secrets Manager in the console and enter the desired email (as plaintext, no quotation marks or anything else required) as the value of the secret (which is most likely called MONITORING_EMAIL). Then run another apply with Terraform and this time it should succeed.
- When creating an email subscription in an environment, Terraform will look up the email to use as the subscription endpoint from Secrets Manager. If you haven’t already created this (e.g. by running terraform apply -target="module.monitoring" -var="create_secrets_first=true") then this will lead to the subscription creation erroring, because it can’t retrieve the value of the secret (because it doesn’t exist yet). If this happens, remember you’ll need to go to Secrets Manager in the console and enter the desired email (as plaintext, no quotation marks or anything else required) as the value of the secret (which is most likely called MONITORING_EMAIL). Then run another apply with Terraform and this time it should succeed.

19
docs/monitoring.md

@ -3,36 +3,46 @@ nav_order: 6
---
# Logs and Debugging
## Logs
Logs can be found in two locations:
- AWS CloudWatch (for general application / infrastructure logging)
- Sentry (for application error logging)
### CloudWatch
The CloudWatch service can be accessed from the AWS Console. You should authenticate onto the infrastructure environment whose logs you want to check.
From CloudWatch, navigate to the desired log group (e.g. for the app task running on ECS) and open the desired log stream, in order to read its log “events”.
Alternatively, you can also navigate to a specific AWS service / resource in question (e.g. ECS tasks), selecting the instance of interest (e.g. a specific ECS task), and finding the “logs” tab (or similar) to view the log “events”.
### Sentry
To access Sentry, ensure you have been added to the MHCLG account.
Generally error logs in Sentry will also be present somewhere in the CloudWatch logs, but they will be easier to assess here (e.g. number of occurrences over a time period). The logs in Sentry are created by the application when it makes Rails.logger.error calls.
## Debugging
### Application infrastructure
For debugging / investigating infrastructure issues you can use the AWS CloudWatch automatic dashboards. (e.g. is there a lack of physical space on the database, how long has the ECS had very high compute usage for etc.)
They can be found in the CloudWatch service on AWS console, by going to dashboards → automatic dashboards, and selecting the desired dashboard (e.g. Elastic Container Service).
They can be found in the CloudWatch service on AWS console, by going to dashboards → automatic dashboards, and selecting the desired dashboard (e.g. Elastic Container Service).
Alternatively, you can also navigate to the AWS resource in question (e.g. RDS database), selecting the instance of interest, and selecting the “monitoring” / ”metrics” tab (or similar), as this can provide alternate useful information also.
### Exec into a container
You can open a terminal directly on a running container / app, in order to run some commands that may help with debugging an issue.
You can open a terminal directly on a running container / app, in order to run some commands that may help with debugging an issue.
To do this, you will need to “exec” into the container.
#### Prerequisites
- AWS CLI
- AWS Session manager plugin Install the Session Manager plugin for the AWS CLI - AWS Systems Manager
- AWS Session manager plugin Install the Session Manager plugin for the AWS CLI - AWS Systems Manager
- AWS access
#### Accessing the rails console
Prerequisite:
Configure AWS auth following the [documentation in the infra repo](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data-infrastructure/blob/main/docs/development_setup.md). This also details how to enter a subshell with suitable AWS credentials.
@ -51,8 +61,9 @@ env=staging
taskArns=$(aws ecs list-tasks --cluster "core-$env-app" --query "taskArns[*]")
aws ecs describe-tasks --cluster "core-$env-app" --tasks "${taskArns[@]}" --query "tasks[*].{arn:taskArn, status:lastStatus, startedAt:startedAt, group:group, image:containers[0].image}" --output text
```
You can then use `aws ecs execute-command --cluster "core-$env-app" --task <taskid> --interactive --command <command>` to run the relevant command on a specific task.
You can then use `aws ecs execute-command --cluster "core-$env-app" --task <taskid> --interactive --command <command>` to run the relevant command on a specific task.
### Database
In order to investigate or look more closely at the database, you can exec into a container as above, and use the rails console to query the database.

238
docs/setup.md

@ -18,87 +18,104 @@ We recommend using [RBenv](https://github.com/rbenv/rbenv) to manage Ruby versio
We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage NodeJS versions.
## Pre-setup installation
1. Install PostgreSQL
macOS:
macOS:
```bash
brew install postgresql
brew services start postgresql
```
```bash
brew install postgresql
brew services start postgresql
```
Linux (Debian):
Linux (Debian):
```bash
sudo apt install -y postgresql postgresql-contrib libpq-dev
sudo systemctl start postgresql
```
```bash
sudo apt install -y postgresql postgresql-contrib libpq-dev
sudo systemctl start postgresql
```
2. Create a Postgres user
```bash
sudo su - postgres -c "createuser <username> -s -P"
```
```bash
sudo su - postgres -c "createuser <username> -s -P"
```
3. Install RBenv and Ruby-build
macOS:
macOS:
```bash
brew install rbenv
rbenv init
mkdir -p ~/.rbenv/plugins
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
```
```bash
brew install rbenv
rbenv init
mkdir -p ~/.rbenv/plugins
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
```
Linux (Debian):
Linux (Debian):
```bash
sudo apt install -y rbenv git
rbenv init
echo 'eval "$(rbenv init -)"' >> ~/.bashrc
mkdir -p ~/.rbenv/plugins
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
```
```bash
sudo apt install -y rbenv git
rbenv init
echo 'eval "$(rbenv init -)"' >> ~/.bashrc
mkdir -p ~/.rbenv/plugins
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
```
4. Install Ruby and Bundler
```bash
rbenv install 3.1.4
rbenv global 3.1.4
source ~/.bashrc
gem install bundler
```
```bash
rbenv install 3.1.4
rbenv global 3.1.4
source ~/.bashrc
gem install bundler
```
5. Install JavaScript dependencies
Note that we currently use node v16, which is no longer the latest LTS version so you will need to specify the version number when installing
macOS (using nvm):
macOS (using nvm):
```bash
nvm install 16
nvm use 16
brew install yarn
```
Linux (Debian):
Linux (Debian):
```bash
curl -sL https://deb.nodesource.com/setup_16.x | sudo bash -
sudo apt -y install nodejs
mkdir -p ~/.npm-packages
npm config set prefix ~/.npm-packages
echo 'NPM_PACKAGES="~/.npm-packages"' >> ~/.bashrc
echo 'export PATH="$PATH:$NPM_PACKAGES/bin"' >> ~/.bashrc
source ~/.bashrc
npm install --location=global yarn
```
6. (For running tests) Install Gecko Driver
Linux (Debian):
```bash
wget https://github.com/mozilla/geckodriver/releases/download/v0.31.0/geckodriver-v0.31.0-linux64.tar.gz
tar -xvzf geckodriver-v0.31.0-linux64.tar.gz
rm geckodriver-v0.31.0-linux64.tar.gz
chmod +x geckodriver
sudo mv geckodriver /usr/local/bin/
```
```bash
curl -sL https://deb.nodesource.com/setup_16.x | sudo bash -
sudo apt -y install nodejs
mkdir -p ~/.npm-packages
npm config set prefix ~/.npm-packages
echo 'NPM_PACKAGES="~/.npm-packages"' >> ~/.bashrc
echo 'export PATH="$PATH:$NPM_PACKAGES/bin"' >> ~/.bashrc
source ~/.bashrc
npm install --location=global yarn
```
Also ensure you have firefox installed
6. Clone the repo
7. Clone the repo
```bash
git clone https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data.git
```
```bash
git clone https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data.git
```
## Application setup
@ -106,99 +123,102 @@ We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage NodeJS version
2. Install the dependencies:
```bash
bundle install && yarn install
```
```bash
bundle install && yarn install
```
3. Create the database & run migrations:
```bash
bundle exec rake db:create db:migrate
```
```bash
bundle exec rake db:create db:migrate
```
4. Seed the database if required:
```bash
bundle exec rake db:seed
```
```bash
bundle exec rake db:seed
```
## Running Locally
### Application
Start the dev servers
5. Start the dev servers
a. Using Foreman:
a. Using Foreman:
```bash
./bin/dev
```
```bash
./bin/dev
```
b. Individually:
b. Individually:
Rails:
Rails:
```bash
bundle exec rails s
```
```bash
bundle exec rails s
```
JavaScript (for hot reloading):
JavaScript (for hot reloading):
```bash
yarn build --mode=development --watch
```
```bash
yarn build --mode=development --watch
```
If you’re not modifying front end assets you can bundle them as a one off task:
If you’re not modifying front end assets you can bundle them as a one off task:
```bash
yarn build --mode=development
```
```bash
yarn build --mode=development
```
Development mode will target the latest versions of Chrome, Firefox and Safari for transpilation while production mode will target older browsers.
Development mode will target the latest versions of Chrome, Firefox and Safari for transpilation while production mode will target older browsers.
The Rails server will start on <http://localhost:3000>.
The Rails server will start on <http://localhost:3000>.
### Tests
6. Install Gecko Driver
```bash
bundle exec rspec
```
Linux (Debian):
Or to run individual tests / files use your IDE
```bash
wget https://github.com/mozilla/geckodriver/releases/download/v0.31.0/geckodriver-v0.31.0-linux64.tar.gz
tar -xvzf geckodriver-v0.31.0-linux64.tar.gz
rm geckodriver-v0.31.0-linux64.tar.gz
chmod +x geckodriver
sudo mv geckodriver /usr/local/bin/
```
### Formatting
Running the test suite (front end assets need to be built or server needs to be running):
- `yarn prettier . --write` for scss, yml, md, and json files
- `yarn standard --fix` for js files
```bash
bundle exec rspec
```
Note that these tests assume you have firefox installed.
### Linting
```bash
bundle exec rake lint
```
## Using Docker
1. Build the image:
```bash
docker-compose build
```
```bash
docker-compose build
```
2. Run the database migrations:
```bash
docker-compose run --rm app /bin/bash -c 'rake db:migrate'
```
```bash
docker-compose run --rm app /bin/bash -c 'rake db:migrate'
```
3. Seed the database if required:
```bash
docker-compose run --rm app /bin/bash -c 'rake db:seed'
```
```bash
docker-compose run --rm app /bin/bash -c 'rake db:seed'
```
4. To be able to debug with Pry run the app using:
```bash
docker-compose run --service-ports app
```
```bash
docker-compose run --service-ports app
```
If this is not needed you can run `docker-compose up` as normal
@ -206,6 +226,6 @@ The Rails server will start on <http://localhost:8080>.
5. To run the test suite in docker:
```bash
docker-compose run --rm app /bin/bash -c ' RAILS_ENV=test rspec'
```
```bash
docker-compose run --rm app /bin/bash -c ' RAILS_ENV=test rspec'
```

17
docs/testing.md

@ -32,51 +32,64 @@ bundle exec rake parallel:setup
RAILS_ENV=test bundle exec rake parallel:spec
```
## Factories for Lettings Log, Sales Log, Organisation, and User
## Factories for Lettings Log, Sales Log, Organisation, and User
Each of these factories has nested relationships and callbacks that ensure associated objects are created and linked properly. For instance, creating a `lettings_log` involves creating or associating with a `user`, which in turn is linked to an `organisation`, potentially leading to creating `organisation_rent_periods` and a `data_protection_confirmation`.
This documentation outlines the objects that are created and/or persisted to the database when using FactoryBot to create or build models for LettingsLog, SalesLog, Organisation, and User. There are other factories, but they are simpler, less frequently used and don't have as much resource hierarchy.
### Lettings Log
Objects Created/Persisted:
- **User**: The `assigned_to` user is created.
- **Organisation**: The `assigned_to` user’s organisation created by `User` factory.
- **DataProtectionConfirmation**: If `organisation` does not have DSA signed, `DataProtectionConfirmation` gets created with `assigned_to` user as a `data_protection_officer`
- **OrganisationRentPeriod**: If `log.period` is present and the `managing_organisation` does not have an `OrganisationRentPeriod` for that period, a new `OrganisationRentPeriod` is created and associated with `managing_organisation`.
Example Usage:
```
let(:lettings_log) { create(:lettings_log) }
```
### Sales Log
Objects Created/Persisted:
- **User**: The `assigned_to` user is created.
- **Organisation**: The `assigned_to` user’s organisation created by `User` factory.
- **DataProtectionConfirmation**: If `organisation` does not have DSA signed, `DataProtectionConfirmation` gets created with `assigned_to` user as a `data_protection_officer`
Example Usage:
```
let(:sales_log) { create(:sales_log) }
```
### Organisation
Objects Created/Persisted:
- **OrganisationRentPeriod**: For each rent period in transient attribute `rent_periods`, an `OrganisationRentPeriod` is created.
- **DataProtectionConfirmation**: If `with_dsa` is `true` (default), a `DataProtectionConfirmation` is created with a `data_protection_officer`
- **User**: Data protection officer that signs the data protection confirmation
Example Usage:
```
let(:organisation) { create(:organisation, rent_periods: [1, 2])}
```
### User
Objects Created/Persisted:
- **Organisation**: User’s organisation.
- **DataProtectionConfirmation**: If `organisation` does not have DSA signed, `DataProtectionConfirmation` gets created with this user as a `data_protection_officer`
Example Usage:
```
let(:user) { create(:user) }
```
```

4
lib/tasks/clear_unconfirmed_emails.rake

@ -0,0 +1,4 @@
desc "Clear unconfimed emails for deactivated users"
task clear_unconfirmed_emails: :environment do
User.deactivated.where.not(unconfirmed_email: nil).update(unconfirmed_email: nil)
end

7
lib/tasks/lint.rake

@ -18,5 +18,10 @@ task stylelint: :environment do
sh "yarn stylelint app/frontend/styles"
end
desc "Run Prettier"
task prettier: :environment do
sh "yarn prettier . --check"
end
desc "Run all the linters"
task lint: %i[rubocop erblint standard stylelint]
task lint: %i[rubocop erblint standard stylelint prettier]

5
package.json

@ -36,9 +36,10 @@
"version": "0.1.0",
"devDependencies": {
"are-you-es5": "^2.1.2",
"prettier": "3.3.3",
"standard": "^17.0.0",
"stylelint": "^15.10.1",
"stylelint-config-gds": "^0.2.0"
"stylelint": "^16.8.2",
"stylelint-config-gds": "^2.0.0"
},
"browserslist": {
"production": [

4
spec/features/user_spec.rb

@ -600,8 +600,8 @@ RSpec.describe "User Features" do
visit(user_path(other_user))
end
it "sends beta onboarding email to be sent when user is legacy" do
expect(notify_client).to receive(:send_email).with(email_address: "new_user@example.com", template_id: User::BETA_ONBOARDING_TEMPLATE_ID, personalisation:).once
it "sends initial confirmable template email when user is legacy" do
expect(notify_client).to receive(:send_email).with(email_address: "new_user@example.com", template_id: User::CONFIRMABLE_TEMPLATE_ID, personalisation:).once
click_button("Resend invite link")
end
end

88
spec/fixtures/forms/2021_2022.json vendored

@ -338,16 +338,16 @@
}
},
"conditional_for": {
"leftreg": [
1
]
"leftreg": [1]
},
"inferred_check_answers_value": [{
"condition": {
"armedforces": 3
},
"value": "Prefers not to say"
}]
"inferred_check_answers_value": [
{
"condition": {
"armedforces": 3
},
"value": "Prefers not to say"
}
]
},
"leftreg": {
"header": "Are they still serving?",
@ -512,9 +512,7 @@
}
},
"conditional_for": {
"postcode_full": [
1
]
"postcode_full": [1]
},
"hidden_in_check_answers": true
},
@ -530,12 +528,14 @@
"is_la_inferred": true
}
},
"inferred_check_answers_value": [{
"condition": {
"postcode_known": 0
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"postcode_known": 0
},
"value": "Not known"
}
]
}
}
},
@ -769,13 +769,13 @@
"check_answer_label": "Net income soft validation",
"hidden_in_check_answers": {
"depends_on": [
{
"net_income_value_check": 0
},
{
"net_income_value_check": 1
}
]
{
"net_income_value_check": 0
},
{
"net_income_value_check": 1
}
]
},
"header": "Are you sure this is correct?",
"type": "interruption_screen",
@ -789,7 +789,11 @@
}
}
},
"interruption_screen_question_ids": ["ecstat1", "incfreq", "earnings"]
"interruption_screen_question_ids": [
"ecstat1",
"incfreq",
"earnings"
]
},
"net_income_uc_proportion": {
"questions": {
@ -838,9 +842,7 @@
}
},
"conditional_for": {
"conditional_question": [
0
]
"conditional_question": [0]
}
},
"conditional_question": {
@ -932,12 +934,7 @@
"min": 0,
"step": 0.01,
"width": 4,
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge"
},
"scharge": {
@ -948,12 +945,7 @@
"min": 0,
"step": 0.01,
"width": 4,
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge"
},
"pscharge": {
@ -964,12 +956,7 @@
"min": 0,
"step": 0.01,
"width": 4,
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge"
},
"supcharg": {
@ -981,12 +968,7 @@
"max": 300,
"step": 0.01,
"width": 4,
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge"
},
"tcharge": {

4
spec/fixtures/forms/2022_2023.json vendored

@ -41,9 +41,7 @@
}
}
},
"depends_on": [
false
]
"depends_on": [false]
}
},
"depends_on": [

21
spec/helpers/guidance_helper_spec.rb

@ -0,0 +1,21 @@
require "rails_helper"
RSpec.describe GuidanceHelper do
describe "#question_link" do
context "when question page is routed to" do
let(:log) { create(:sales_log, :shared_ownership_setup_complete, mortgageused: 2) }
it "returns an empty string if question is not routed to" do
expect(question_link("mortgage", log, log.assigned_to)).to eq("")
end
end
context "when question page is not routed to" do
let(:log) { create(:sales_log, :shared_ownership_setup_complete, mortgageused: 1) }
it "returns a link to the question with correct question number in brakets" do
expect(question_link("mortgage", log, log.assigned_to)).to eq("(<a class=\"govuk-link\" href=\"/sales-logs/#{log.id}/mortgage-amount-shared-ownership\">Q92</a>)")
end
end
end
end

36
spec/lib/tasks/clear_unconfirmed_emails_spec.rb

@ -0,0 +1,36 @@
require "rails_helper"
require "rake"
RSpec.describe "clear_unconfirmed_emails" do
describe ":clear_unconfirmed_emails", type: :task do
subject(:task) { Rake::Task["clear_unconfirmed_emails"] }
before do
Rake.application.rake_require("tasks/clear_unconfirmed_emails")
Rake::Task.define_task(:environment)
task.reenable
end
context "when the rake task is run" do
context "and there are deactivated users with unconfirmed emails" do
let!(:user) { create(:user, active: false, unconfirmed_email: "some_email@example.com") }
it "clears unconfirmed_email" do
task.invoke
expect(user.reload.unconfirmed_email).to eq(nil)
end
end
context "and there are active users with unconfirmed emails" do
let!(:user) { create(:user, active: true, unconfirmed_email: "some_email@example.com") }
it "does not clear unconfirmed_email" do
task.invoke
expect(user.reload.unconfirmed_email).not_to eq(nil)
end
end
end
end
end

2
spec/mailers/resend_invitation_mailer_spec.rb

@ -42,7 +42,7 @@ RSpec.describe ResendInvitationMailer do
it "sends an initial invitation" do
FactoryBot.create(:legacy_user, old_user_id: new_active_migrated_user.old_user_id, user: new_active_migrated_user)
expect(notify_client).to receive(:send_email).with(email_address: "new_active_migrated_user@example.com", template_id: User::BETA_ONBOARDING_TEMPLATE_ID, personalisation:).once
expect(notify_client).to receive(:send_email).with(email_address: "new_active_migrated_user@example.com", template_id: User::CONFIRMABLE_TEMPLATE_ID, personalisation:).once
described_class.new.resend_invitation_email(new_active_migrated_user)
end
end

79
spec/models/form/sales/pages/about_deposit_without_discount_spec.rb

@ -1,79 +0,0 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::AboutDepositWithoutDiscount, type: :model do
subject(:page) { described_class.new(page_id, page_definition, subsection, ownershipsch: 1, optional: false) }
let(:page_id) { nil }
let(:page_definition) { nil }
let(:subsection) { instance_double(Form::Subsection) }
before do
allow(subsection).to receive(:form).and_return(instance_double(Form, start_year_after_2024?: false, start_date: Time.zone.local(2023, 4, 1)))
end
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[deposit])
end
it "has the correct id" do
expect(page.id).to eq(nil)
end
it "has the correct header" do
expect(page.header).to eq("About the deposit")
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 eq(
[{ "social_homebuy?" => false, "ownershipsch" => 1 },
{ "ownershipsch" => 2 },
{ "ownershipsch" => 3, "mortgageused" => 1 }],
)
end
context "when optional is true" do
subject(:page) { described_class.new(page_id, page_definition, subsection, ownershipsch: 1, optional: true) }
it "has correct depends_on" do
expect(page.depends_on).to eq(
[{ "social_homebuy?" => false, "ownershipsch" => 1 },
{ "ownershipsch" => 2 },
{ "ownershipsch" => 3, "mortgageused" => 1 }],
)
end
end
context "when it's a 2024 form" do
before do
allow(subsection).to receive(:form).and_return(instance_double(Form, start_year_after_2024?: true, start_date: Time.zone.local(2024, 4, 1)))
end
it "has correct depends_on" do
expect(page.depends_on).to eq(
[{ "social_homebuy?" => false, "ownershipsch" => 1, "stairowned_100?" => false },
{ "ownershipsch" => 2 },
{ "ownershipsch" => 3, "mortgageused" => 1 }],
)
end
context "and optional is true" do
subject(:page) { described_class.new(page_id, page_definition, subsection, ownershipsch: 1, optional: true) }
it "has correct depends_on" do
expect(page.depends_on).to eq(
[{ "social_homebuy?" => false, "ownershipsch" => 1, "stairowned_100?" => true },
{ "ownershipsch" => 2 },
{ "ownershipsch" => 3, "mortgageused" => 1 }],
)
end
end
end
end

8
spec/models/form/sales/pages/about_deposit_with_discount_spec.rb → spec/models/form/sales/pages/deposit_discount_spec.rb

@ -1,9 +1,9 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::AboutDepositWithDiscount, type: :model do
RSpec.describe Form::Sales::Pages::DepositDiscount, type: :model do
subject(:page) { described_class.new(page_id, page_definition, subsection, optional: false) }
let(:page_id) { "about_deposit_with_discount" }
let(:page_id) { "discount" }
let(:page_definition) { nil }
let(:subsection) { instance_double(Form::Subsection) }
@ -16,11 +16,11 @@ RSpec.describe Form::Sales::Pages::AboutDepositWithDiscount, type: :model do
end
it "has correct questions" do
expect(page.questions.map(&:id)).to eq(%w[deposit cashdis])
expect(page.questions.map(&:id)).to eq(%w[cashdis])
end
it "has the correct id" do
expect(page.id).to eq("about_deposit_with_discount")
expect(page.id).to eq("discount")
end
it "has the correct header" do

235
spec/models/form/sales/pages/deposit_spec.rb

@ -0,0 +1,235 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::Deposit, type: :model do
subject(:page) { described_class.new(page_id, page_definition, subsection, ownershipsch: 1, optional:) }
let(:page_id) { nil }
let(:page_definition) { nil }
let(:subsection) { instance_double(Form::Subsection, enabled?: true, depends_on: true) }
let(:form) { instance_double(Form, start_year_after_2024?: false, start_date: Time.zone.local(2023, 4, 1), depends_on_met: true) }
let(:optional) { false }
before do
allow(subsection).to receive(:form).and_return(form)
end
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[deposit])
end
it "has the correct id" do
expect(page.id).to eq(nil)
end
it "has the correct header" do
expect(page.header).to eq("About the deposit")
end
it "has the correct description" do
expect(page.description).to be_nil
end
context "when routing with start year after 2024" do
before do
allow(form).to receive(:start_year_after_2024?).and_return(true)
end
context "and optional is false" do
context "and the log is shared ownership, not social homembuy and stairowned is not 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 16, stairowned: 70) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, not social homembuy and stairowned is 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 16, stairowned: 100) }
it "does not route to the page" do
expect(page).not_to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, social homebuy and stairowned is not 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 18, stairowned: 80) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, social homebuy and stairowned is 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 18, stairowned: 100) }
it "does not route to the page" do
expect(page).not_to be_routed_to(log, nil)
end
end
context "and the log is discounted ownership" do
let(:log) { build(:sales_log, ownershipsch: 2, type: 18) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is outright ownership and mortgage used is yes" do
let(:log) { build(:sales_log, ownershipsch: 3, mortgageused: 1) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and ownership is outright sale and mortgage used is not yes" do
let(:log) { build(:sales_log, ownershipsch: 3, mortgageused: 2) }
it "doesn't route to the page" do
expect(page).not_to be_routed_to(log, nil)
end
end
end
context "and optional is true" do
let(:optional) { true }
context "and the log is shared ownership, not social homembuy and stairowned is not 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 16, stairowned: 70) }
it "does not route to the page" do
expect(page).not_to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, not social homembuy and stairowned is 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 16, stairowned: 100) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, social homebuy and stairowned is not 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 18, stairowned: 80) }
it "does not route to the page" do
expect(page).not_to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, social homebuy and stairowned is 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 18, stairowned: 100) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
end
end
context "when routing with start year before 2024" do
before do
allow(form).to receive(:start_year_after_2024?).and_return(false)
end
context "and optional is false" do
context "and the log is shared ownership, not social homembuy and stairowned is not 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 16, stairowned: 70) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, not social homembuy and stairowned is 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 16, stairowned: 100) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, social homebuy and stairowned is not 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 18, stairowned: 80) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, social homebuy and stairowned is 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 18, stairowned: 100) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is discounted ownership" do
let(:log) { build(:sales_log, ownershipsch: 2, type: 18) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is outright ownership and mortgage used is yes" do
let(:log) { build(:sales_log, ownershipsch: 3, mortgageused: 1) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and ownership is outright sale and mortgage used is not yes" do
let(:log) { build(:sales_log, ownershipsch: 3, mortgageused: 2) }
it "doesn't route to the page" do
expect(page).not_to be_routed_to(log, nil)
end
end
end
context "and optional is true" do
let(:optional) { true }
context "and the log is shared ownership, not social homembuy and stairowned is not 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 16, stairowned: 70) }
it "does routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, not social homembuy and stairowned is 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 16, stairowned: 100) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, social homebuy and stairowned is not 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 18, stairowned: 80) }
it "does routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
context "and the log is shared ownership, social homebuy and stairowned is 100" do
let(:log) { build(:sales_log, ownershipsch: 1, type: 18, stairowned: 100) }
it "routes to the page" do
expect(page).to be_routed_to(log, nil)
end
end
end
end
end

6
spec/models/form/sales/pages/about_price_rtb_spec.rb → spec/models/form/sales/pages/discount_spec.rb

@ -1,6 +1,6 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::AboutPriceRtb, type: :model do
RSpec.describe Form::Sales::Pages::Discount, type: :model do
subject(:page) { described_class.new(page_id, page_definition, subsection) }
let(:page_id) { nil }
@ -16,11 +16,11 @@ RSpec.describe Form::Sales::Pages::AboutPriceRtb, type: :model do
end
it "has correct questions" do
expect(page.questions.map(&:id)).to eq(%w[value discount])
expect(page.questions.map(&:id)).to eq(%w[discount])
end
it "has the correct id" do
expect(page.id).to eq("about_price_rtb")
expect(page.id).to eq("discount")
end
it "has the correct header" do

6
spec/models/form/sales/pages/about_price_shared_ownership_spec.rb → spec/models/form/sales/pages/equity_spec.rb

@ -1,6 +1,6 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::AboutPriceSharedOwnership, type: :model do
RSpec.describe Form::Sales::Pages::Equity, type: :model do
subject(:page) { described_class.new(page_id, page_definition, subsection) }
let(:page_id) { nil }
@ -12,11 +12,11 @@ RSpec.describe Form::Sales::Pages::AboutPriceSharedOwnership, type: :model do
end
it "has correct questions" do
expect(page.questions.map(&:id)).to eq(%w[value equity])
expect(page.questions.map(&:id)).to eq(%w[equity])
end
it "has the correct id" do
expect(page.id).to eq("about_price_shared_ownership")
expect(page.id).to eq("equity")
end
it "has the correct header" do

6
spec/models/form/sales/pages/about_price_not_rtb_spec.rb → spec/models/form/sales/pages/grant_spec.rb

@ -1,6 +1,6 @@
require "rails_helper"
RSpec.describe Form::Sales::Pages::AboutPriceNotRtb, type: :model do
RSpec.describe Form::Sales::Pages::Grant, type: :model do
subject(:page) { described_class.new(page_id, page_definition, subsection) }
let(:page_id) { nil }
@ -12,11 +12,11 @@ RSpec.describe Form::Sales::Pages::AboutPriceNotRtb, type: :model do
end
it "has correct questions" do
expect(page.questions.map(&:id)).to eq(%w[value grant])
expect(page.questions.map(&:id)).to eq(%w[grant])
end
it "has the correct id" do
expect(page.id).to eq("about_price_not_rtb")
expect(page.id).to eq("grant")
end
it "has the correct header" do

2
spec/models/form/sales/pages/purchase_price_outright_ownership_spec.rb

@ -20,7 +20,7 @@ RSpec.describe Form::Sales::Pages::PurchasePriceOutrightOwnership, type: :model
end
it "has the correct header" do
expect(page.header).to be_nil
expect(page.header).to eq("About the price of the property")
end
it "has the correct description" do

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save