From 6f7f8b6f58fb1960f6ffc0dec8c71730ce72eee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Thu, 5 Dec 2024 13:53:21 +0000 Subject: [PATCH] feat(fee-breakdown): Parse and display discrete list of reductions and exemptions (#4040) --- .../@planx/components/Pay/Editor/Editor.tsx | 6 +- .../Pay/Editor/FeeBreakdownSection.tsx | 2 +- .../Pay/Public/FeeBreakdown/FeeBreakdown.tsx | 49 +++++- .../Pay/Public/FeeBreakdown/types.ts | 19 ++- .../FeeBreakdown/useFeeBreakdown.test.ts | 160 ++++++++++++++++-- .../Public/FeeBreakdown/useFeeBreakdown.tsx | 2 + .../Pay/Public/FeeBreakdown/utils.test.ts | 36 +++- .../Pay/Public/FeeBreakdown/utils.ts | 53 +++++- 8 files changed, 288 insertions(+), 39 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Editor/Editor.tsx b/editor.planx.uk/src/@planx/components/Pay/Editor/Editor.tsx index 450c13a4d5..82f05da942 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Editor/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Editor/Editor.tsx @@ -102,9 +102,9 @@ const Component: React.FC = (props: Props) => { - - - + + + = ({ amount }) => ( ); -const Reductions: React.FC<{ amount?: number }> = ({ amount }) => { +const Reductions: React.FC<{ amount?: number, reductions: string[] }> = ({ amount, reductions }) => { if (!amount) return null; return ( + <> Reductions {formattedPriceWithCurrencySymbol(-amount)} + { + reductions.map((reduction) => ( + + + {reduction} + + + )) + } + + ); +}; + +// TODO: This won't show as if a fee is 0, we hide the whole Pay component from the user +const Exemptions: React.FC<{ amount: number, exemptions: string[] }> = ({ amount, exemptions }) => { + if (!exemptions.length) return null; + + return ( + <> + + Exemptions + + {formattedPriceWithCurrencySymbol(-amount)} + + + { + exemptions.map((exemption) => ( + + + {exemption} + + + )) + } + ); }; @@ -90,6 +126,8 @@ export const FeeBreakdown: React.FC = () => { const breakdown = useFeeBreakdown(); if (!breakdown) return null; + const { amount, reductions, exemptions } = breakdown; + return ( @@ -102,10 +140,11 @@ export const FeeBreakdown: React.FC = () => {
- - - - + + + + + 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 e0967d4bc3..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 @@ -1,12 +1,21 @@ export interface FeeBreakdown { - applicationFee: number; - total: number; - reduction: number; - vat: number | undefined; + amount: { + applicationFee: number; + total: number; + reduction: number; + vat: number | undefined; + }; + 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; +}; \ 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 4e4c042d5a..5312cb12c2 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 @@ -21,6 +21,7 @@ describe("useFeeBreakdown() hook", () => { "application.fee.calculated": 1000, "application.fee.payable": 800, "application.fee.payable.vat": 160, + "some.other.fields": ["abc", "xyz"], }; vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); @@ -28,10 +29,14 @@ describe("useFeeBreakdown() hook", () => { const result = useFeeBreakdown(); expect(result).toEqual({ - applicationFee: 1000, - total: 800, - reduction: 200, - vat: 160, + amount: { + applicationFee: 1000, + total: 800, + reduction: 200, + vat: 160, + }, + exemptions: [], + reductions: [], }); }); @@ -40,6 +45,7 @@ describe("useFeeBreakdown() hook", () => { "application.fee.calculated": [1000], "application.fee.payable": [800], "application.fee.payable.vat": [160], + "some.other.fields": ["abc", "xyz"], }; vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); @@ -47,17 +53,147 @@ describe("useFeeBreakdown() hook", () => { const result = useFeeBreakdown(); expect(result).toEqual({ - applicationFee: 1000, - total: 800, - reduction: 200, - vat: 160, + amount: { + applicationFee: 1000, + total: 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"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + 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"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + 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.disability": ["true"], + "application.fee.exemption.resubmission": ["true"], + "some.other.fields": ["abc", "xyz"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + 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"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + 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?.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 = {}; + const mockPassportData = { + "some.other.fields": ["abc", "xyz"], + }; vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); @@ -70,6 +206,7 @@ describe("useFeeBreakdown() hook", () => { const mockPassportData = { "application.fee.calculated": [1000], "application.fee.payable.vat": [160], + "some.other.fields": ["abc", "xyz"], }; vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); @@ -84,6 +221,7 @@ describe("useFeeBreakdown() hook", () => { "application.fee.calculated": "some string", "application.fee.payable": [800, 700], "application.fee.payable.vat": false, + "some.other.fields": ["abc", "xyz"], }; vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); @@ -94,7 +232,9 @@ describe("useFeeBreakdown() hook", () => { }); it("calls Airbrake if invalid inputs are provided", () => { - const mockPassportData = {}; + const mockPassportData = { + "some.other.fields": ["abc", "xyz"], + }; const mockSessionId = "test-session"; vi.mocked(useStore).mockReturnValue([mockPassportData, mockSessionId]); 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 216ce36f53..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 @@ -16,6 +16,8 @@ export const useFeeBreakdown = (): FeeBreakdown | undefined => { state.computePassport().data, state.sessionId, ]); + if (!passportData) return + const schema = createPassportSchema(); const result = schema.safeParse(passportData); 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 25e52596b9..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 @@ -23,6 +23,11 @@ describe("calculateReduction() helper function", () => { "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); @@ -34,6 +39,11 @@ describe("calculateReduction() helper function", () => { "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); @@ -47,25 +57,35 @@ describe("toFeeBreakdown() helper function", () => { "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 output = toFeeBreakdown(input); + const { amount } = toFeeBreakdown(input); - expect(output.applicationFee).toEqual(input["application.fee.calculated"]); - expect(output.total).toEqual(input["application.fee.payable"]); - expect(output.vat).toEqual(input["application.fee.payable.vat"]); - expect(output.reduction).toEqual(50); + 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 = { "application.fee.calculated": 0, - "application.fee.payable": 50, "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 output = toFeeBreakdown(input); + const { amount } = toFeeBreakdown(input); - expect(output.applicationFee).toEqual(input["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 6afa520dfc..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 @@ -5,9 +5,32 @@ 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 - * This is not currently broken down further into component parts, or as exemptions or reductions */ export const calculateReduction = (data: PassportFeeFields) => data["application.fee.calculated"] @@ -15,14 +38,19 @@ export const calculateReduction = (data: PassportFeeFields) => : 0; /** - * Transform Passport data to a FeeBreakdown shape + * Transform Passport data to a FeeBreakdown */ export const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ - applicationFee: - data["application.fee.calculated"] || data["application.fee.payable"], - total: data["application.fee.payable"], - vat: data["application.fee.payable.vat"], - reduction: calculateReduction(data), + amount: { + applicationFee: + 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, "application.fee.reduction"), + exemptions: getGranularKeys(data, "application.fee.exemption"), }); export const createPassportSchema = () => { @@ -32,11 +60,22 @@ export const createPassportSchema = () => { .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);