From d2927f3e7a78f7de33e268137ff33633b7c759ae Mon Sep 17 00:00:00 2001 From: Samuel Young Date: Thu, 12 Mar 2026 09:46:08 +0000 Subject: [PATCH] CLDC-4212, CLDC-4213, CLDC-4214: Add 0.1% tolerance to all calculations that involve %s of a value (#3214) * CLDC-4212: Add 0.1% tolerance to % calculations this is to specifically allow 0.1% more or less than the calculation expects, for those that contain %s in them this removes difficulties on adding 66.6% equity (or should it be 66.7%?) * CLDC-4212: Remove discount soft validation was replaced by a hard validation in CLDC-1899 so no longer serves a purpose * CLDC-4212: Add tests * fixup! CLDC-4212: Add 0.1% tolerance to % calculations calculate a tolerance instead * fixup! CLDC-4212: Add 0.1% tolerance to % calculations update comment --- app/models/sales_log.rb | 32 +++++ .../sales/sale_information_validations.rb | 17 +-- .../validations/sales/soft_validations.rb | 1 + .../sale_information_validations_spec.rb | 116 ++++++++++++++++++ 4 files changed, 159 insertions(+), 7 deletions(-) diff --git a/app/models/sales_log.rb b/app/models/sales_log.rb index a76460e87..271057505 100644 --- a/app/models/sales_log.rb +++ b/app/models/sales_log.rb @@ -268,6 +268,22 @@ class SalesLog < Log value * equity / 100 end + def expected_shared_ownership_deposit_value_tolerance + return 1 unless value && equity + + # we found that a fixed tolerance was not quite what we wanted here. + # CORE wants it so if a user say, has a 66.666% equity they can enter either 66.6% or 66.7% (or 66.5%) + # so in 2026 we base our tolerance off of a discount 0.1% higher or lower + if form.start_year_2026_or_later? + lower_bound = value * ((equity - 0.1) / 100) + upper_bound = value * ((equity + 0.1) / 100) + + (upper_bound - lower_bound) / 2 + else + 1 + end + end + def stairbought_part_of_value return unless value && stairbought @@ -468,6 +484,22 @@ class SalesLog < Log value - discount_amount end + def value_with_discount_tolerance + return 1 if value.blank? || discount.nil? + + # we found that a simple tolerance was not quite what we wanted here. + # CORE wants it so if a user say, has a 66.6% discount then can enter either 66.6% or 66.7% + # so in 2026 we base our tolerance off of a discount 0.1% higher or lower + if form.start_year_2026_or_later? + discount_amount_lower_bound = value * (discount - 0.1) / 100 + discount_amount_upper_bound = value * (discount + 0.1) / 100 + + (discount_amount_upper_bound - discount_amount_lower_bound) / 2 + else + discount ? value * 0.05 / 100 : 1 + end + end + def mortgage_deposit_and_grant_total return if deposit.blank? diff --git a/app/models/validations/sales/sale_information_validations.rb b/app/models/validations/sales/sale_information_validations.rb index 063c16599..df6b90a4a 100644 --- a/app/models/validations/sales/sale_information_validations.rb +++ b/app/models/validations/sales/sale_information_validations.rb @@ -80,10 +80,9 @@ module Validations::Sales::SaleInformationValidations return unless record.mortgage || record.mortgageused == 2 || record.mortgageused == 3 return unless record.discount || record.grant || record.type == 29 - # When a percentage discount is used, a percentage tolerance is needed to account for rounding errors - tolerance = record.discount ? record.value * 0.05 / 100 : 1 + tolerance = record.value_with_discount_tolerance - if over_tolerance?(record.mortgage_deposit_and_grant_total, record.value_with_discount, tolerance, strict: !record.discount.nil?) && record.discounted_ownership_sale? + if over_tolerance?(record.mortgage_deposit_and_grant_total, record.value_with_discount, tolerance, strict: !record.discount.nil? || record.form.start_year_2026_or_later?) && 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 discount grant].each do |field| @@ -204,10 +203,12 @@ module Validations::Sales::SaleInformationValidations def check_non_staircasing_socialhomebuy_mortgage(record) return unless record.cashdis + tolerance = record.expected_shared_ownership_deposit_value_tolerance + if record.mortgage_used? return unless record.mortgage - if over_tolerance?(record.mortgage_deposit_and_discount_total, record.expected_shared_ownership_deposit_value, 1) + if over_tolerance?(record.mortgage_deposit_and_discount_total, record.expected_shared_ownership_deposit_value, tolerance, strict: record.form.start_year_2026_or_later?) %i[mortgage value deposit cashdis equity].each do |field| record.errors.add field, I18n.t("validations.sales.sale_information.#{field}.non_staircasing_mortgage.mortgage_used_socialhomebuy", mortgage: record.field_formatted_as_currency("mortgage"), @@ -228,7 +229,7 @@ module Validations::Sales::SaleInformationValidations 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) + if over_tolerance?(record.deposit_and_discount_total, record.expected_shared_ownership_deposit_value, tolerance, strict: record.form.start_year_2026_or_later?) %i[mortgageused value deposit cashdis equity].each do |field| record.errors.add field, I18n.t("validations.sales.sale_information.#{field}.non_staircasing_mortgage.mortgage_not_used_socialhomebuy", deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"), @@ -250,10 +251,12 @@ module Validations::Sales::SaleInformationValidations end def check_non_staircasing_non_socialhomebuy_mortgage(record) + tolerance = record.expected_shared_ownership_deposit_value_tolerance + if record.mortgage_used? return unless record.mortgage - if over_tolerance?(record.mortgage_and_deposit_total, record.expected_shared_ownership_deposit_value, 1) + if over_tolerance?(record.mortgage_and_deposit_total, record.expected_shared_ownership_deposit_value, tolerance, strict: record.form.start_year_2026_or_later?) %i[mortgage value deposit equity].each do |field| record.errors.add field, I18n.t("validations.sales.sale_information.#{field}.non_staircasing_mortgage.mortgage_used", mortgage: record.field_formatted_as_currency("mortgage"), @@ -272,7 +275,7 @@ module Validations::Sales::SaleInformationValidations 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, record.expected_shared_ownership_deposit_value, 1) + if over_tolerance?(record.deposit, record.expected_shared_ownership_deposit_value, tolerance, strict: record.form.start_year_2026_or_later?) %i[mortgageused value deposit equity].each do |field| record.errors.add field, I18n.t("validations.sales.sale_information.#{field}.non_staircasing_mortgage.mortgage_not_used", deposit: record.field_formatted_as_currency("deposit"), diff --git a/app/models/validations/sales/soft_validations.rb b/app/models/validations/sales/soft_validations.rb index 3569c379f..ce7a812d2 100644 --- a/app/models/validations/sales/soft_validations.rb +++ b/app/models/validations/sales/soft_validations.rb @@ -117,6 +117,7 @@ module Validations::Sales::SoftValidations def mortgage_plus_deposit_less_than_discounted_value? return unless mortgage && deposit && value && discount + return if form.start_year_2026_or_later? discounted_value = value * (100 - discount) / 100 mortgage + deposit < discounted_value diff --git a/spec/models/validations/sales/sale_information_validations_spec.rb b/spec/models/validations/sales/sale_information_validations_spec.rb index 1731f19ac..1c36ee5e1 100644 --- a/spec/models/validations/sales/sale_information_validations_spec.rb +++ b/spec/models/validations/sales/sale_information_validations_spec.rb @@ -663,6 +663,46 @@ RSpec.describe Validations::Sales::SaleInformationValidations do expect(record.errors["grant"]).to be_empty end end + + context "with year 2026", :aggregate_failures do + let(:saledate) { Time.zone.local(2026, 4, 1) } + + context "when mortgage and deposit is exact" do + let(:record) { FactoryBot.build(:sales_log, saledate:, mortgage: 85_000, deposit: 5_000, value: 100_000, discount: 10, ownershipsch: 2, type: 9) } + + it "does not add an error" do + sale_information_validator.validate_discounted_ownership_value(record) + expect(record.errors["mortgage"]).to be_empty + expect(record.errors["value"]).to be_empty + expect(record.errors["deposit"]).to be_empty + expect(record.errors["discount"]).to be_empty + end + end + + context "when mortgage and deposit is within 0.1% discount tolerance" do + let(:record) { FactoryBot.build(:sales_log, saledate:, mortgage: 85_000, deposit: 5_000, value: 100_000, discount: 10.1, ownershipsch: 2, type: 9) } + + it "does not add an error" do + sale_information_validator.validate_discounted_ownership_value(record) + expect(record.errors["mortgage"]).to be_empty + expect(record.errors["value"]).to be_empty + expect(record.errors["deposit"]).to be_empty + expect(record.errors["discount"]).to be_empty + end + end + + context "when mortgage and deposit is outside 0.1% discount tolerance" do + let(:record) { FactoryBot.build(:sales_log, saledate:, mortgage: 85_000, deposit: 5_000, value: 100_000, discount: 10.2, ownershipsch: 2, type: 9) } + + it "adds an error" do + sale_information_validator.validate_discounted_ownership_value(record) + expect(record.errors["mortgage"]).not_to be_empty + expect(record.errors["value"]).not_to be_empty + expect(record.errors["deposit"]).not_to be_empty + expect(record.errors["discount"]).not_to be_empty + end + end + end end describe "#validate_outright_sale_value_matches_mortgage_plus_deposit" do @@ -1171,6 +1211,82 @@ RSpec.describe Validations::Sales::SaleInformationValidations do end end end + + context "with year 2026", :aggregate_failures do + let(:saledate) { Time.zone.local(2026, 4, 1) } + + context "when mortgage and deposit is exact" do + let(:record) { FactoryBot.build(:sales_log, mortgageused: 1, mortgage: 10_000, staircase: 2, deposit: 5_000, value: 100_000, equity: 15, ownershipsch: 1, type: 30, saledate:) } + + it "does not add an error" do + sale_information_validator.validate_non_staircasing_mortgage(record) + expect(record.errors["mortgage"]).to be_empty + expect(record.errors["value"]).to be_empty + expect(record.errors["deposit"]).to be_empty + expect(record.errors["equity"]).to be_empty + end + end + + context "when mortgage and deposit is within 0.1% equity tolerance" do + let(:record) { FactoryBot.build(:sales_log, mortgageused: 1, mortgage: 10_000, staircase: 2, deposit: 5_000, value: 100_000, equity: 15.1, ownershipsch: 1, type: 30, saledate:) } + + it "does not add an error" do + sale_information_validator.validate_non_staircasing_mortgage(record) + expect(record.errors["mortgage"]).to be_empty + expect(record.errors["value"]).to be_empty + expect(record.errors["deposit"]).to be_empty + expect(record.errors["equity"]).to be_empty + end + end + + context "when mortgage and deposit is outside 0.1% equity tolerance" do + let(:record) { FactoryBot.build(:sales_log, mortgageused: 1, mortgage: 10_000, staircase: 2, deposit: 5_000, value: 100_000, equity: 15.2, ownershipsch: 1, type: 30, saledate:) } + + it "adds an error" do + sale_information_validator.validate_non_staircasing_mortgage(record) + expect(record.errors["mortgage"]).not_to be_empty + expect(record.errors["value"]).not_to be_empty + expect(record.errors["deposit"]).not_to be_empty + expect(record.errors["equity"]).not_to be_empty + end + end + + context "when deposit (no mortgage) is exact" do + let(:record) { FactoryBot.build(:sales_log, mortgageused: 2, staircase: 2, deposit: 15_000, value: 100_000, equity: 15, ownershipsch: 1, type: 30, saledate:) } + + it "does not add an error" do + sale_information_validator.validate_non_staircasing_mortgage(record) + expect(record.errors["mortgageused"]).to be_empty + expect(record.errors["value"]).to be_empty + expect(record.errors["deposit"]).to be_empty + expect(record.errors["equity"]).to be_empty + end + end + + context "when deposit (no mortgage) is within 0.1% equity tolerance" do + let(:record) { FactoryBot.build(:sales_log, mortgageused: 2, staircase: 2, deposit: 15_000, value: 100_000, equity: 15.1, ownershipsch: 1, type: 30, saledate:) } + + it "does not add an error" do + sale_information_validator.validate_non_staircasing_mortgage(record) + expect(record.errors["mortgageused"]).to be_empty + expect(record.errors["value"]).to be_empty + expect(record.errors["deposit"]).to be_empty + expect(record.errors["equity"]).to be_empty + end + end + + context "when deposit (no mortgage) is outside 0.1% equity tolerance" do + let(:record) { FactoryBot.build(:sales_log, mortgageused: 2, staircase: 2, deposit: 15_000, value: 100_000, equity: 15.2, ownershipsch: 1, type: 30, saledate:) } + + it "adds an error" do + sale_information_validator.validate_non_staircasing_mortgage(record) + expect(record.errors["mortgageused"]).not_to be_empty + expect(record.errors["value"]).not_to be_empty + expect(record.errors["deposit"]).not_to be_empty + expect(record.errors["equity"]).not_to be_empty + end + end + end end describe "#validate_staircasing_mortgage" do