diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/types.ts b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/types.ts index f8c85944bb..176cf87724 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/types.ts +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/types.ts @@ -10,11 +10,12 @@ export interface FeeBreakdown { } export interface PassportFeeFields { - amount: { - "application.fee.calculated": number; - "application.fee.payable": number; - "application.fee.payable.vat": number; - }, - reductions?: Record - exemptions?: Record + "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; }; \ No newline at end of file diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.test.ts b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.test.ts index b0282477b1..6f4158028c 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.test.ts +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.test.ts @@ -69,8 +69,8 @@ describe("useFeeBreakdown() hook", () => { "application.fee.calculated": 1000, "application.fee.payable": 800, "application.fee.payable.vat": 160, - "application.fee.reduction.reasonOne": ["true"], - "application.fee.reduction.reasonTwo": ["true"], + "application.fee.reduction.alternative": ["true"], + "application.fee.reduction.parishCouncil": ["true"], "some.other.fields": ["abc", "xyz"], }; @@ -80,7 +80,7 @@ describe("useFeeBreakdown() hook", () => { expect(result?.reductions).toHaveLength(2); expect(result?.reductions).toEqual( - expect.arrayContaining(["reasonOne", "reasonTwo"]) + expect.arrayContaining(["alternative", "parishCouncil"]) ); }); @@ -89,8 +89,8 @@ describe("useFeeBreakdown() hook", () => { "application.fee.calculated": 1000, "application.fee.payable": 800, "application.fee.payable.vat": 160, - "application.fee.reduction.reasonOne": ["false"], - "application.fee.reduction.reasonTwo": ["false"], + "application.fee.reduction.alternative": ["false"], + "application.fee.reduction.parishCouncil": ["false"], "some.other.fields": ["abc", "xyz"], }; @@ -101,13 +101,36 @@ describe("useFeeBreakdown() hook", () => { 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"], + }; + + vi.mocked(useStore).mockReturnValue([ + mockPassportData, + "test-session", + ]); + + const result = useFeeBreakdown(); + + 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.reasonOne": ["true"], - "application.fee.exemption.reasonTwo": ["true"], + "application.fee.exemption.disability": ["true"], + "application.fee.exemption.resubmission": ["true"], "some.other.fields": ["abc", "xyz"], }; @@ -117,7 +140,7 @@ describe("useFeeBreakdown() hook", () => { expect(result?.exemptions).toHaveLength(2); expect(result?.exemptions).toEqual( - expect.arrayContaining(["reasonOne", "reasonTwo"]) + expect.arrayContaining(["disability", "resubmission"]) ); }); @@ -126,8 +149,8 @@ describe("useFeeBreakdown() hook", () => { "application.fee.calculated": 1000, "application.fee.payable": 800, "application.fee.payable.vat": 160, - "application.fee.exemption.reasonOne": ["false"], - "application.fee.exemption.reasonTwo": ["false"], + "application.fee.exemption.disability": ["false"], + "application.fee.exemption.resubmission": ["false"], "some.other.fields": ["abc", "xyz"], }; @@ -137,6 +160,33 @@ describe("useFeeBreakdown() hook", () => { 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"], + }; + + vi.mocked(useStore).mockReturnValue([ + mockPassportData, + "test-session", + ]); + + const result = useFeeBreakdown(); + + expect(result?.exemption).toEqual( + expect.not.arrayContaining(["someReason"]) + ); + expect(result?.exemption).toEqual( + expect.not.arrayContaining(["someOtherReason"]) + ); + }); }); describe("invalid inputs", () => { diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.tsx index 8ec4eb3aa3..b1e6b10c1d 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.tsx @@ -2,7 +2,7 @@ import { logger } from "airbrake"; import { useStore } from "pages/FlowEditor/lib/store"; import { FeeBreakdown } from "./types"; -import { createPassportSchema, preProcessPassport } from "./utils"; +import { createPassportSchema } from "./utils"; /** * Parses the users's Passport for data variables associated with their fee @@ -19,8 +19,7 @@ export const useFeeBreakdown = (): FeeBreakdown | undefined => { if (!passportData) return const schema = createPassportSchema(); - const processedPassport = preProcessPassport(passportData); - const result = schema.safeParse(processedPassport); + const result = schema.safeParse(passportData); if (!result.success) { logger.notify( diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.test.ts b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.test.ts index 20277c610b..935c8cb066 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.test.ts +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.test.ts @@ -20,11 +20,14 @@ describe("toNumber() helper function", () => { describe("calculateReduction() helper function", () => { it("correctly outputs the reduction when a calculated value is provided", () => { const input: PassportFeeFields = { - amount: { - "application.fee.calculated": 100, - "application.fee.payable": 50, - "application.fee.payable.vat": 0, - } + "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); @@ -33,11 +36,14 @@ describe("calculateReduction() helper function", () => { it("defaults to 0 when calculated is 0", () => { const input: PassportFeeFields = { - amount: { - "application.fee.calculated": 0, - "application.fee.payable": 100, - "application.fee.payable.vat": 0, - } + "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); @@ -48,32 +54,38 @@ describe("calculateReduction() helper function", () => { describe("toFeeBreakdown() helper function", () => { it("correctly maps fields", () => { const input: PassportFeeFields = { - amount: { - "application.fee.calculated": 100, - "application.fee.payable": 50, - "application.fee.payable.vat": 10, - } + "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.applicationFee).toEqual(input.amount["application.fee.calculated"]); - expect(amount.total).toEqual(input.amount["application.fee.payable"]); - expect(amount.vat).toEqual(input.amount["application.fee.payable.vat"]); + expect(amount.applicationFee).toEqual(input["application.fee.calculated"]); + expect(amount.total).toEqual(input["application.fee.payable"]); + expect(amount.vat).toEqual(input["application.fee.payable.vat"]); expect(amount.reduction).toEqual(50); }); it("sets applicationFee to payable amount if no calculated value is provided", () => { const input: PassportFeeFields = { - amount : { - "application.fee.calculated": 0, - "application.fee.payable.vat": 10, - "application.fee.payable": 50, - } + "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.applicationFee).toEqual(input.amount["application.fee.payable"]); + expect(amount.applicationFee).toEqual(input["application.fee.payable"]); }); }); diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.ts b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.ts index 13585428d6..759c5438db 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.ts +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.ts @@ -10,29 +10,31 @@ export const toNumber = (input: number | [number]) => */ const toBoolean = (val: ["true" | "false"]) => val[0] === "true"; -const filterByKeyPrefix = (data: Record, prefix: string) => - Object.fromEntries( - Object.entries(data).filter(([k, _v]) => k.startsWith(prefix)) - ); /** * Iterate over exemptions or reductions to find matches, returning the granular keys */ const getGranularKeys = ( - data: Record | undefined = {}, - prefix: "application.fee.reduction" | "application.fee.exemption" -) => { - const keys = Object.keys(data).filter((key) => data[key]); - const granularKeys = keys.map((key) => key.replace(prefix + ".", "")); - return granularKeys; -}; + 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 = ({ amount }: PassportFeeFields) => - amount["application.fee.calculated"] - ? amount["application.fee.calculated"] - amount["application.fee.payable"] +export const calculateReduction = (data: PassportFeeFields) => + data["application.fee.calculated"] + ? data["application.fee.calculated"] - data["application.fee.payable"] : 0; /** @@ -41,20 +43,14 @@ export const calculateReduction = ({ amount }: PassportFeeFields) => export const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ amount: { applicationFee: - data.amount["application.fee.calculated"] || - data.amount["application.fee.payable"], - total: data.amount["application.fee.payable"], - vat: data.amount["application.fee.payable.vat"], + data["application.fee.calculated"] || + data["application.fee.payable"], + total: data["application.fee.payable"], + vat: data["application.fee.payable.vat"], reduction: calculateReduction(data), }, - reductions: getGranularKeys(data.reductions, "application.fee.reduction"), - exemptions: getGranularKeys(data.exemptions, "application.fee.exemption"), -}); - -export const preProcessPassport = (data: Record) => ({ - amount: filterByKeyPrefix(data, "application.fee"), - reductions: filterByKeyPrefix(data, "application.fee.reduction"), - exemptions: filterByKeyPrefix(data, "application.fee.exemption"), + reductions: getGranularKeys(data, "application.fee.reduction"), + exemptions: getGranularKeys(data, "application.fee.exemption"), }); export const createPassportSchema = () => { @@ -64,31 +60,22 @@ export const createPassportSchema = () => { .union([questionSchema, setValueSchema]) .transform(toNumber); - const amountsSchema = z.object({ - "application.fee.calculated": feeSchema.optional().default(0), - "application.fee.payable": feeSchema, - "application.fee.payable.vat": feeSchema.optional().default(0), - }); - - const reductionsSchema = z - .record( - z.string(), - z.tuple([z.enum(["true", "false"])]).transform(toBoolean) - ) - .optional(); - - const exemptionsSchema = z - .record( - z.string(), - z.tuple([z.enum(["true", "false"])]).transform(toBoolean) - ) - .optional(); + /** 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({ - amount: amountsSchema, - reductions: reductionsSchema, - exemptions: exemptionsSchema, + "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);