diff --git a/src/types/feeBreakdown.ts b/src/types/feeBreakdown.ts new file mode 100644 index 00000000..c617b046 --- /dev/null +++ b/src/types/feeBreakdown.ts @@ -0,0 +1,21 @@ +export interface FeeBreakdown { + amount: { + calculated: number; + payable: number; + reduction: number; + vat: number; + }; + reductions: string[]; + exemptions: string[]; +} + +export interface PassportFeeFields { + "application.fee.calculated": number; + "application.fee.payable": number; + "application.fee.payable.vat": number; + "application.fee.reduction.alternative": boolean; + "application.fee.reduction.parishCouncil": boolean; + "application.fee.reduction.sports": boolean; + "application.fee.exemption.disability": boolean; + "application.fee.exemption.resubmission": boolean; +} diff --git a/src/types/index.ts b/src/types/index.ts index 37a41cef..85dbfe88 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,7 @@ export * from "./bops"; export * from "./component"; export * from "./data"; export * from "./export"; +export * from "./feeBreakdown"; export * from "./flags"; export * from "./flow"; export * from "./gov-uk-payment"; diff --git a/src/utils/feeBreakdown.test.ts b/src/utils/feeBreakdown.test.ts new file mode 100644 index 00000000..481dc7a1 --- /dev/null +++ b/src/utils/feeBreakdown.test.ts @@ -0,0 +1,285 @@ +import { PassportFeeFields } from "../types"; +import { + calculateReduction, + getFeeBreakdown, + toFeeBreakdown, + toNumber, +} from "./feeBreakdown"; + +describe("toNumber() helper function", () => { + it("outputs a number when passed a number", () => { + const input = 12; + const output = toNumber(input); + + expect(output).toEqual(input); + }); + + it("outputs a number when passed a number tuple", () => { + const input: [number] = [12]; + const output = toNumber(input); + + expect(output).toEqual(12); + }); +}); + +describe("calculateReduction() helper function", () => { + it("correctly outputs the reduction when a calculated value is provided", () => { + const input: PassportFeeFields = { + "application.fee.calculated": 100, + "application.fee.payable": 50, + "application.fee.payable.vat": 0, + "application.fee.reduction.alternative": false, + "application.fee.reduction.parishCouncil": false, + "application.fee.reduction.sports": false, + "application.fee.exemption.disability": false, + "application.fee.exemption.resubmission": false, + }; + const reduction = calculateReduction(input); + + expect(reduction).toEqual(50); + }); + + it("defaults to 0 when calculated is 0", () => { + const input: PassportFeeFields = { + "application.fee.calculated": 0, + "application.fee.payable": 100, + "application.fee.payable.vat": 0, + "application.fee.reduction.alternative": false, + "application.fee.reduction.parishCouncil": false, + "application.fee.reduction.sports": false, + "application.fee.exemption.disability": false, + "application.fee.exemption.resubmission": false, + }; + const reduction = calculateReduction(input); + + expect(reduction).toEqual(0); + }); +}); + +describe("toFeeBreakdown() helper function", () => { + it("correctly maps fields", () => { + const input: PassportFeeFields = { + "application.fee.calculated": 100, + "application.fee.payable": 50, + "application.fee.payable.vat": 10, + "application.fee.reduction.alternative": false, + "application.fee.reduction.parishCouncil": false, + "application.fee.reduction.sports": false, + "application.fee.exemption.disability": false, + "application.fee.exemption.resubmission": false, + }; + + const { amount } = toFeeBreakdown(input); + + expect(amount.calculated).toEqual(input["application.fee.calculated"]); + expect(amount.payable).toEqual(input["application.fee.payable"]); + expect(amount.vat).toEqual(input["application.fee.payable.vat"]); + expect(amount.reduction).toEqual(50); + }); + + it("sets calculated to payable amount if no calculated value is provided", () => { + const input: PassportFeeFields = { + "application.fee.calculated": 0, + "application.fee.payable.vat": 10, + "application.fee.payable": 50, + "application.fee.reduction.alternative": false, + "application.fee.reduction.parishCouncil": false, + "application.fee.reduction.sports": false, + "application.fee.exemption.disability": false, + "application.fee.exemption.resubmission": false, + }; + + const { amount } = toFeeBreakdown(input); + + expect(amount.calculated).toEqual(input["application.fee.payable"]); + }); +}); + +describe("getFeeBreakdown() function", () => { + describe("valid data", () => { + it("returns a fee breakdown for number inputs", () => { + const mockPassportData = { + "application.fee.calculated": 1000, + "application.fee.payable": 800, + "application.fee.payable.vat": 160, + "some.other.fields": ["abc", "xyz"], + }; + + const result = getFeeBreakdown(mockPassportData); + + expect(result).toEqual({ + amount: { + calculated: 1000, + payable: 800, + reduction: 200, + vat: 160, + }, + exemptions: [], + reductions: [], + }); + }); + + it("returns a fee breakdown for number tuple inputs", () => { + const mockPassportData = { + "application.fee.calculated": [1000], + "application.fee.payable": [800], + "application.fee.payable.vat": [160], + "some.other.fields": ["abc", "xyz"], + }; + + const result = getFeeBreakdown(mockPassportData); + + expect(result).toEqual({ + amount: { + calculated: 1000, + payable: 800, + reduction: 200, + vat: 160, + }, + exemptions: [], + reductions: [], + }); + }); + + it("parses 'true' reduction values to a list of keys", () => { + const mockPassportData = { + "application.fee.calculated": 1000, + "application.fee.payable": 800, + "application.fee.payable.vat": 160, + "application.fee.reduction.alternative": ["true"], + "application.fee.reduction.parishCouncil": ["true"], + "some.other.fields": ["abc", "xyz"], + }; + + const result = getFeeBreakdown(mockPassportData); + + expect(result?.reductions).toHaveLength(2); + expect(result?.reductions).toEqual( + expect.arrayContaining(["alternative", "parishCouncil"]), + ); + }); + + it("does not parse 'false' reduction values to a list of keys", () => { + const mockPassportData = { + "application.fee.calculated": 1000, + "application.fee.payable": 800, + "application.fee.payable.vat": 160, + "application.fee.reduction.alternative": ["false"], + "application.fee.reduction.parishCouncil": ["false"], + "some.other.fields": ["abc", "xyz"], + }; + + const result = getFeeBreakdown(mockPassportData); + + expect(result?.reductions).toHaveLength(0); + }); + + it("does not parse non-schema reduction values", () => { + const mockPassportData = { + "application.fee.calculated": 1000, + "application.fee.payable": 800, + "application.fee.payable.vat": 160, + "application.fee.reduction.alternative": ["true"], + "application.fee.reduction.parishCouncil": ["false"], + "application.fee.reduction.someReason": ["true"], + "application.fee.reduction.someOtherReason": ["false"], + "some.other.fields": ["abc", "xyz"], + }; + + const result = getFeeBreakdown(mockPassportData); + + expect(result?.reductions).toEqual( + expect.not.arrayContaining(["someReason"]), + ); + expect(result?.reductions).toEqual( + expect.not.arrayContaining(["someOtherReason"]), + ); + }); + + it("parses 'true' exemption values to a list of keys", () => { + const mockPassportData = { + "application.fee.calculated": 1000, + "application.fee.payable": 800, + "application.fee.payable.vat": 160, + "application.fee.exemption.disability": ["true"], + "application.fee.exemption.resubmission": ["true"], + "some.other.fields": ["abc", "xyz"], + }; + + const result = getFeeBreakdown(mockPassportData); + + expect(result?.exemptions).toHaveLength(2); + expect(result?.exemptions).toEqual( + expect.arrayContaining(["disability", "resubmission"]), + ); + }); + + it("does not parse 'false' exemption values to a list of keys", () => { + const mockPassportData = { + "application.fee.calculated": 1000, + "application.fee.payable": 800, + "application.fee.payable.vat": 160, + "application.fee.exemption.disability": ["false"], + "application.fee.exemption.resubmission": ["false"], + "some.other.fields": ["abc", "xyz"], + }; + + const result = getFeeBreakdown(mockPassportData); + + expect(result?.exemptions).toHaveLength(0); + }); + + it("does not parse non-schema exemption values", () => { + const mockPassportData = { + "application.fee.calculated": 1000, + "application.fee.payable": 800, + "application.fee.payable.vat": 160, + "application.fee.exemption.disability": ["false"], + "application.fee.exemption.resubmission": ["false"], + "application.fee.exemption.someReason": ["true"], + "application.fee.exemption.someOtherReason": ["false"], + "some.other.fields": ["abc", "xyz"], + }; + + const result = getFeeBreakdown(mockPassportData); + + expect(result?.exemptions).toEqual( + expect.not.arrayContaining(["someReason"]), + ); + expect(result?.exemptions).toEqual( + expect.not.arrayContaining(["someOtherReason"]), + ); + }); + }); + + describe("invalid inputs", () => { + it("returns undefined for missing data", () => { + const mockPassportData = { + "some.other.fields": ["abc", "xyz"], + }; + + expect(() => getFeeBreakdown(mockPassportData)).toThrow(); + }); + + it("returns undefined for partial data", () => { + const mockPassportData = { + "application.fee.calculated": [1000], + "application.fee.payable.vat": [160], + "some.other.fields": ["abc", "xyz"], + }; + + expect(() => getFeeBreakdown(mockPassportData)).toThrow(); + }); + + it("returns undefined for incorrect data", () => { + const mockPassportData = { + "application.fee.calculated": "some string", + "application.fee.payable": [800, 700], + "application.fee.payable.vat": false, + "some.other.fields": ["abc", "xyz"], + }; + + expect(() => getFeeBreakdown(mockPassportData)).toThrow(); + }); + }); +}); diff --git a/src/utils/feeBreakdown.ts b/src/utils/feeBreakdown.ts new file mode 100644 index 00000000..4602dbe2 --- /dev/null +++ b/src/utils/feeBreakdown.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +import { FeeBreakdown, PassportFeeFields } from "../types"; + +export const toNumber = (input: number | [number]) => + Array.isArray(input) ? input[0] : input; + +/** + * Convert a Passport value to an actual boolean + */ +const toBoolean = (val: ["true" | "false"]) => val[0] === "true"; + +/** + * Iterate over exemptions or reductions to find matches, returning the granular keys + */ +const getGranularKeys = ( + data: PassportFeeFields, + prefix: "application.fee.reduction" | "application.fee.exemption", +) => { + const keys = Object.keys(data) as (keyof PassportFeeFields)[]; + const intersectingKeys = keys.filter( + (key) => key.startsWith(prefix) && Boolean(data[key]), + ); + const granularKeys = intersectingKeys.map((key) => + key.replace(prefix + ".", ""), + ); + + return granularKeys; +}; + +/** + * A "reduction" is the sum of the difference between calculated and payable + */ +export const calculateReduction = (data: PassportFeeFields) => + data["application.fee.calculated"] + ? data["application.fee.calculated"] - data["application.fee.payable"] + : 0; + +/** + * Transform Passport data to a FeeBreakdown + */ +export const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ + amount: { + calculated: + data["application.fee.calculated"] || data["application.fee.payable"], + payable: data["application.fee.payable"], + vat: data["application.fee.payable.vat"], + reduction: calculateReduction(data), + }, + reductions: getGranularKeys(data, "application.fee.reduction"), + exemptions: getGranularKeys(data, "application.fee.exemption"), +}); + +export const createPassportSchema = () => { + const questionSchema = z.number().nonnegative(); + const setValueSchema = z.tuple([z.coerce.number().nonnegative()]); + const feeSchema = z + .union([questionSchema, setValueSchema]) + .transform(toNumber); + + /** Describes how boolean values are set via PlanX components */ + const booleanSchema = z + .tuple([z.enum(["true", "false"])]) + .default(["false"]) + .transform(toBoolean); + + const schema = z + .object({ + "application.fee.calculated": feeSchema.optional().default(0), + "application.fee.payable": feeSchema, + "application.fee.payable.vat": feeSchema.optional().default(0), + "application.fee.reduction.alternative": booleanSchema, + "application.fee.reduction.parishCouncil": booleanSchema, + "application.fee.reduction.sports": booleanSchema, + "application.fee.exemption.disability": booleanSchema, + "application.fee.exemption.resubmission": booleanSchema, + }) + .transform(toFeeBreakdown); + + return schema; +}; + +export const getFeeBreakdown = (passportData: unknown): FeeBreakdown => { + const schema = createPassportSchema(); + const result = schema.parse(passportData); + return result; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 376a0f86..2e090575 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./digitalPlanningSchema"; export * from "./encryption"; +export { getFeeBreakdown } from "./feeBreakdown"; export * from "./govPayMetadata"; export * from "./projectTypes";