Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Optional floor area #288

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/api/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { POST } from "../api/route";
import * as calculationService from "../services/calculationService";
import calculateFairhold from "../models/testClasses";
import calculateFairhold from "../models/calculateFairhold";
import { NextResponse } from "next/server";

// Mock dependencies
jest.mock("../services/calculationService");
jest.mock("../models/testClasses", () => jest.fn()); // Mock calculateFairhold
jest.mock("../models/calculateFairhold", () => jest.fn()); // Mock calculateFairhold
jest.mock("next/server", () => ({
NextResponse: {
json: jest.fn((data) => ({ data })),
Expand Down
13 changes: 5 additions & 8 deletions app/api/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { calculationSchema } from "../schemas/calculationSchema";
import * as calculationService from "../services/calculationService";
import calculateFairhold from "../models/testClasses";
import calculateFairhold from "../models/calculateFairhold";
import { APIError } from "../lib/exceptions";

export async function POST(req: Request) {
Expand All @@ -16,16 +16,13 @@ export async function POST(req: Request) {
console.log("ERROR: API - ", (error as Error).message);

if (error instanceof APIError) {
return NextResponse.json(
{ error },
{ status: error.status },
);
return NextResponse.json({ error }, { status: error.status });
}

const response = {
error: {
message: (error as Error).message,
code: "UNHANDLED_EXCEPTION"
error: {
message: (error as Error).message,
code: "UNHANDLED_EXCEPTION",
},
};
return NextResponse.json(response, { status: 500 });
Expand Down
47 changes: 23 additions & 24 deletions app/models/Property.test.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,48 @@
import { Property } from "./Property";
import { MAINTENANCE_LEVELS } from './constants';
import { createTestProperty } from "./testHelpers";
import { MAINTENANCE_LEVELS } from "./constants";
import { createTestProperty } from "./testHelpers";

let property = createTestProperty();

describe('Property', () => {
describe("Property", () => {
beforeEach(() => {
property = createTestProperty()
property = createTestProperty();
});

it("can be instantiated", () => {
expect(property).toBeInstanceOf(Property);
});

it("correctly calculates the newBuildPrice", () => {
expect(property.newBuildPrice).toBeCloseTo(186560);
});

it("correctly calculates the landPrice", () => {
expect(property.landPrice).toBeCloseTo(313440);
});

it("correctly calculates the landToTotalRatio", () => {
expect(property.landToTotalRatio).toBeCloseTo(0.62688);
});

it("correctly calculates the values even for number of bedroooms exceeding the max ", () => {
property = createTestProperty({
numberOfBedrooms: 20
})
numberOfBedrooms: 20,
});
});

it("correctly returns newBuildPrice if newbuild", () => {
property = createTestProperty({
age: 0
})
age: 0,
});

expect(property.depreciatedBuildPrice).toBe(property.newBuildPrice);
});

describe('depreciation calculations (existing build)', () => {

describe("depreciation calculations (existing build)", () => {
it("correctly calculates newComponentValue for foundations", () => {
const result = property.calculateComponentValue(
'foundations',
"foundations",
property.newBuildPrice,
property.age,
MAINTENANCE_LEVELS[1]
Expand All @@ -55,18 +54,18 @@ describe('Property', () => {

it("correctly calculates depreciationFactor for internal linings", () => {
const result = property.calculateComponentValue(
'internalLinings',
"internalLinings",
property.newBuildPrice,
property.age,
MAINTENANCE_LEVELS[1]
);

expect(result.depreciationFactor).toBe(.968);
expect(result.depreciationFactor).toBe(0.968);
});

it("correctly calculates maintenanceAddition for electrical appliances", () => {
const result = property.calculateComponentValue(
'electricalAppliances',
"electricalAppliances",
property.newBuildPrice,
property.age,
MAINTENANCE_LEVELS[1]
Expand All @@ -77,7 +76,7 @@ describe('Property', () => {

it("correctly calculates depreciatedComponentValue for ventilation services", () => {
const result = property.calculateComponentValue(
'ventilationServices',
"ventilationServices",
property.newBuildPrice,
property.age,
MAINTENANCE_LEVELS[1]
Expand All @@ -88,7 +87,7 @@ describe('Property', () => {

it("ensures depreciatedComponentValue never goes below 0", () => {
const result = property.calculateComponentValue(
'ventilationServices',
"ventilationServices",
property.newBuildPrice,
100, // High age to test possible negative values
MAINTENANCE_LEVELS[1]
Expand All @@ -97,11 +96,11 @@ describe('Property', () => {
expect(result.depreciatedComponentValue).toBeGreaterThanOrEqual(0);
});

it('should calculate correct depreciation for a 10-year-old house', () => {
it("should calculate correct depreciation for a 10-year-old house", () => {
property = createTestProperty({
age: 10
})
age: 10,
});
expect(property.depreciatedBuildPrice).toBeCloseTo(171467.3);
});
});
});
});
65 changes: 37 additions & 28 deletions app/models/Property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ export const HOUSE_TYPES = ["D", "S", "T", "F"] as const;

export type HouseType = (typeof HOUSE_TYPES)[number];

export type MaintenancePercentage = (typeof MAINTENANCE_LEVELS)[number]
export type MaintenancePercentage = (typeof MAINTENANCE_LEVELS)[number];

export type ComponentCalculation = {
newComponentValue: number;
depreciationFactor: number;
maintenanceAddition: number;
depreciatedComponentValue: number;
}
};

export class Property {
postcode: string;
Expand Down Expand Up @@ -68,7 +68,7 @@ export class Property {
this.postcode = params.postcode;
this.houseType = params.houseType;
this.numberOfBedrooms = params.numberOfBedrooms;
this.age = params.age // TODO: update frontend so that newbuild = 0
this.age = params.age; // TODO: update frontend so that newbuild = 0
this.size = params.size;
this.maintenancePercentage = params.maintenancePercentage;
this.newBuildPricePerMetre = params.newBuildPricePerMetre;
Expand All @@ -92,21 +92,25 @@ export class Property {
if (this.age === 0) return this.newBuildPrice;
let depreciatedBuildPrice = 0;

// Calculate for each component using the public method
for (const key of Object.keys(HOUSE_BREAKDOWN_PERCENTAGES) as (keyof houseBreakdownType)[]) {
const result = this.calculateComponentValue(
key,
this.newBuildPrice,
this.age,
this.maintenancePercentage
// Calculate for each component using the public method
for (const key of Object.keys(
HOUSE_BREAKDOWN_PERCENTAGES
) as (keyof houseBreakdownType)[]) {
const result = this.calculateComponentValue(
key,
this.newBuildPrice,
this.age,
this.maintenancePercentage
);

depreciatedBuildPrice += result.depreciatedComponentValue;
}
depreciatedBuildPrice = parseFloat(
depreciatedBuildPrice.toFixed(PRECISION)
);

depreciatedBuildPrice += result.depreciatedComponentValue;
return depreciatedBuildPrice;
}
depreciatedBuildPrice = parseFloat(depreciatedBuildPrice.toFixed(PRECISION))

return depreciatedBuildPrice;
}

public calculateComponentValue(
componentKey: keyof houseBreakdownType,
Expand All @@ -115,30 +119,35 @@ export class Property {
maintenanceLevel: number
): ComponentCalculation {
const component = HOUSE_BREAKDOWN_PERCENTAGES[componentKey];

// Calculate new component value
const newComponentValue = newBuildPrice * component.percentageOfHouse;

// Calculate depreciation
const depreciationFactor = 1 - (component.depreciationPercentageYearly * age);
const depreciationFactor = 1 - component.depreciationPercentageYearly * age;

// Calculate maintenance (0 for foundations and structure)
const maintenanceAddition =
(componentKey === 'foundations' || componentKey === 'structureEnvelope')
? 0
: maintenanceLevel * newBuildPrice * age * component.percentOfMaintenanceYearly;

const maintenanceAddition =
componentKey === "foundations" || componentKey === "structureEnvelope"
? 0
: maintenanceLevel *
newBuildPrice *
age *
component.percentOfMaintenanceYearly;

// Calculate final value
let depreciatedComponentValue =
(newComponentValue * depreciationFactor) + maintenanceAddition;
let depreciatedComponentValue =
newComponentValue * depreciationFactor + maintenanceAddition;

depreciatedComponentValue < 0 ? depreciatedComponentValue = 0 : depreciatedComponentValue
depreciatedComponentValue < 0
? (depreciatedComponentValue = 0)
: depreciatedComponentValue;

return {
newComponentValue,
depreciationFactor,
maintenanceAddition,
depreciatedComponentValue
depreciatedComponentValue,
};
}
}
114 changes: 114 additions & 0 deletions app/models/calculateFairhold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import calculateFairhold from "./calculateFairhold";
import { ResponseData } from "./calculateFairhold";
import { Household } from "./Household";
import { ValidPostcode } from "../schemas/calculationSchema";
import { parse as parsePostcode } from "postcode";
import { socialRentAdjustments } from "./testHelpers";
import { maintenancePercentageSchema } from "../schemas/calculationSchema";
import { z } from "zod";

type MaintenancePercentage = z.infer<typeof maintenancePercentageSchema>;

jest.mock("./Household", () => {
return {
Household: jest.fn().mockImplementation((data) => data),
};
});

jest.mock("./Property", () => {
return {
Property: jest.fn().mockImplementation((data) => data),
};
});

jest.mock("./ForecastParameters", () => {
return {
createForecastParameters: jest.fn((maintenancePercentage) => ({
maintenancePercentage,
})),
};
});

describe("calculateFairhold", () => {
const validResponseData: ResponseData = {
postcode: parsePostcode("SE17 1PE") as ValidPostcode,
houseType: "D",
houseBedrooms: 3,
buildPrice: 2000,
houseAge: 10,
houseSize: 95,
maintenancePercentage: 0.015,
averagePrice: 300000,
itl3: "UKI3",
gdhi: 25000,
averageRentMonthly: 800,
socialRentAverageEarning: 0.8,
socialRentAdjustments: socialRentAdjustments,
hpi: 1.5,
kwhCostPence: 30,
};

it("throws an error if itl3 is missing or empty", () => {
const invalidData = { ...validResponseData, itl3: "" };
expect(() => calculateFairhold(invalidData)).toThrow(
"itl3 data is missing or empty"
);
});

it("throws an error if buildPrice is missing or empty", () => {
const invalidData = { ...validResponseData, buildPrice: NaN };
expect(() => calculateFairhold(invalidData)).toThrow(
"buildPrice data is missing or empty"
);
});

it("throws an error if maintenancePercentage is missing or empty", () => {
const invalidData = {
...validResponseData,
maintenancePercentage: "" as unknown as MaintenancePercentage,
};
expect(() => calculateFairhold(invalidData)).toThrow(
"maintenancePercentage data is missing or empty"
);
});

it("creates a Household instance with correct parameters", () => {
const household = calculateFairhold(validResponseData);
expect(household).toBeDefined();
});

it("calculates houseSize if it is undefined", () => {
const responseDataWithUndefinedSize = {
...validResponseData,
houseSize: undefined,
};

calculateFairhold(responseDataWithUndefinedSize);

expect(Household).toHaveBeenCalledWith(
expect.objectContaining({
property: expect.objectContaining({
size: 95,
}),
})
);
});

it("assigns max size for bedrooms > 6", () => {
const responseDataWithLargeBedrooms = {
...validResponseData,
houseBedrooms: 7,
houseSize: undefined,
};

calculateFairhold(responseDataWithLargeBedrooms);

expect(Household).toHaveBeenCalledWith(
expect.objectContaining({
property: expect.objectContaining({
size: 135,
}),
})
);
});
});
Loading
Loading