From 0c699f37d6793bc00d0849e84a64fc3c9a41de51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 20 Nov 2024 13:29:34 +0000 Subject: [PATCH 01/16] feat: Update model, add optional fee breakdown section in Editor modal --- .../src/@planx/components/Pay/Editor/Editor.tsx | 7 ++++--- .../@planx/components/Pay/Editor/FeeBreakdownSection.tsx | 4 ++-- 2 files changed, 6 insertions(+), 5 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..6befd655e0 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Editor/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Editor/Editor.tsx @@ -15,6 +15,7 @@ import { Switch } from "ui/shared/Switch"; import { ICONS } from "../../shared/icons"; import { EditorProps } from "../../shared/types"; import { FeeBreakdownSection } from "./FeeBreakdownSection"; +import { FeeBreakdownSection } from "./FeeBreakdownSection"; import { GovPayMetadataSection } from "./GovPayMetadataSection"; import { InviteToPaySection } from "./InviteToPaySection"; @@ -102,9 +103,9 @@ const Component: React.FC = (props: Props) => { - - - + + + Date: Mon, 25 Nov 2024 14:18:32 +0000 Subject: [PATCH 02/16] fix: Import order, default values --- .../src/@planx/components/Pay/Editor/Editor.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 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 6befd655e0..450c13a4d5 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Editor/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Editor/Editor.tsx @@ -15,7 +15,6 @@ import { Switch } from "ui/shared/Switch"; import { ICONS } from "../../shared/icons"; import { EditorProps } from "../../shared/types"; import { FeeBreakdownSection } from "./FeeBreakdownSection"; -import { FeeBreakdownSection } from "./FeeBreakdownSection"; import { GovPayMetadataSection } from "./GovPayMetadataSection"; import { InviteToPaySection } from "./InviteToPaySection"; @@ -103,9 +102,9 @@ const Component: React.FC = (props: Props) => { - - - + + + Date: Tue, 26 Nov 2024 16:24:48 +0000 Subject: [PATCH 03/16] chore: Move to subfolder --- editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx | 2 +- .../Pay/Public/{ => FeeBreakdown}/FeeBreakdown.test.tsx | 0 .../components/Pay/Public/{ => FeeBreakdown}/FeeBreakdown.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename editor.planx.uk/src/@planx/components/Pay/Public/{ => FeeBreakdown}/FeeBreakdown.test.tsx (100%) rename editor.planx.uk/src/@planx/components/Pay/Public/{ => FeeBreakdown}/FeeBreakdown.tsx (97%) diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx index 31913894d9..e30ee2e30c 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx @@ -19,7 +19,7 @@ import { getDefaultContent, Pay, } from "../model"; -import { FeeBreakdown } from "./FeeBreakdown"; +import { FeeBreakdown } from "./FeeBreakdown/FeeBreakdown"; import InviteToPayForm, { InviteToPayFormProps } from "./InviteToPayForm"; import { PAY_API_ERROR_UNSUPPORTED_TEAM } from "./Pay"; diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown.test.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.test.tsx similarity index 100% rename from editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown.test.tsx rename to editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.test.tsx diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx similarity index 97% rename from editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown.tsx rename to editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx index 2bb415aca3..c491f071cc 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx @@ -11,7 +11,7 @@ import { hasFeatureFlag } from "lib/featureFlags"; import React from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; -import { formattedPriceWithCurrencySymbol } from "../model"; +import { formattedPriceWithCurrencySymbol } from "../../model"; const StyledTable = styled(Table)(() => ({ [`& .${tableCellClasses.root}`]: { From 50b783165cc94aeae9d97da00675b21820edd822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 27 Nov 2024 09:31:50 +0000 Subject: [PATCH 04/16] feat: Basic hook logic --- .../Pay/Public/FeeBreakdown/FeeBreakdown.tsx | 76 ++++++++++--------- .../Pay/Public/FeeBreakdown/types.ts | 12 +++ .../Public/FeeBreakdown/useFeeBreakdown.tsx | 24 ++++++ .../Pay/Public/FeeBreakdown/utils.ts | 37 +++++++++ editor.planx.uk/src/pages/Pay/MakePayment.tsx | 2 + 5 files changed, 116 insertions(+), 35 deletions(-) create mode 100644 editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/types.ts create mode 100644 editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.tsx create mode 100644 editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.ts diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx index c491f071cc..f0062d03e7 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx @@ -12,6 +12,7 @@ import React from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import { formattedPriceWithCurrencySymbol } from "../../model"; +import { useFeeBreakdown } from "./useFeeBreakdown"; const StyledTable = styled(Table)(() => ({ [`& .${tableCellClasses.root}`]: { @@ -26,7 +27,7 @@ const BoldTableRow = styled(TableRow)(() => ({ }, })); -const VAT_RATE = 20; +const VAT_RATE = 0.2; const DESCRIPTION = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; @@ -40,49 +41,56 @@ const Header = () => ( ); -const ApplicationFee = () => ( +const ApplicationFee: React.FC<{ amount: number }> = ({ amount }) => ( Application fee - {formattedPriceWithCurrencySymbol(100)} + + {formattedPriceWithCurrencySymbol(amount)} + ); -const Exemptions = () => ( - - Exemptions - {formattedPriceWithCurrencySymbol(-20)} - -); +const Reductions: React.FC<{ amount?: number }> = ({ amount }) => { + if (!amount) return null; -const Reductions = () => ( - - Reductions - {formattedPriceWithCurrencySymbol(-30)} - -); + return ( + + Reductions + + {formattedPriceWithCurrencySymbol(-amount)} + + + ); +}; -const ServiceCharge = () => ( - - Service charge - {formattedPriceWithCurrencySymbol(30)} - -); +const VAT: React.FC<{ amount?: number }> = ({ amount }) => { + if (!amount) return null; -const VAT = () => ( - - {`VAT (${VAT_RATE}%)`} - - - -); + return ( + + {`Includes VAT (${ + VAT_RATE * 100 + }%)`} + + {formattedPriceWithCurrencySymbol(amount)} + + + ); +}; -const Total = () => ( +const Total: React.FC<{ amount: number }> = ({ amount }) => ( Total - {formattedPriceWithCurrencySymbol(80)} + + {formattedPriceWithCurrencySymbol(amount)} + ); export const FeeBreakdown: React.FC = () => { + const breakdown = useFeeBreakdown(); + if (!breakdown) return null; + if (!hasFeatureFlag("FEE_BREAKDOWN")) return null; return ( @@ -97,12 +105,10 @@ 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 new file mode 100644 index 0000000000..e0967d4bc3 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/types.ts @@ -0,0 +1,12 @@ +export interface FeeBreakdown { + applicationFee: number; + total: number; + reduction: number; + vat: number | undefined; +} + +export interface PassportFeeFields { + "application.fee.calculated": number; + "application.fee.payable": number; + "application.fee.payable.vat": number; +} 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 new file mode 100644 index 0000000000..e100052ba2 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.tsx @@ -0,0 +1,24 @@ +import { logger } from "airbrake"; +import { useStore } from "pages/FlowEditor/lib/store"; + +import { FeeBreakdown } from "./types"; +import { createPassportSchema } from "./utils"; + +export const useFeeBreakdown = (): FeeBreakdown | undefined => { + const [passportData, sessionId] = useStore((state) => [ + state.computePassport().data, + state.sessionId, + ]); + const schema = createPassportSchema(); + const result = schema.safeParse(passportData); + + // Unable to parse fee data from passport, do not show FeeBreakdown component + if (!result.success) { + logger.notify( + `Failed to parse fee breakdown data from passport for session ${sessionId}. Error: ${result.error}`, + ); + return; + } + + return result.data; +}; 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 new file mode 100644 index 0000000000..90c76d260d --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { FeeBreakdown, PassportFeeFields } from "./types"; + +const toNumber = (input: number | number[]) => + Array.isArray(input) ? Number(input[0]) : input; + +const calculateReduction = (data: PassportFeeFields) => + data["application.fee.calculated"] + ? data["application.fee.calculated"] - data["application.fee.payable"] + : 0; + +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), +}); + +export const createPassportSchema = () => { + const questionSchema = z.number(); + const setValueSchema = z.array(z.coerce.number()); + const feeSchema = z + .union([questionSchema, setValueSchema]) + .transform(toNumber); + + const schema = z + .object({ + "application.fee.calculated": feeSchema.optional().default(0), + "application.fee.payable": feeSchema, + "application.fee.payable.vat": feeSchema.optional().default(0), + }) + .transform(toFeeBreakdown); + + return schema; +}; diff --git a/editor.planx.uk/src/pages/Pay/MakePayment.tsx b/editor.planx.uk/src/pages/Pay/MakePayment.tsx index f6b686dab3..4c7a2b9954 100644 --- a/editor.planx.uk/src/pages/Pay/MakePayment.tsx +++ b/editor.planx.uk/src/pages/Pay/MakePayment.tsx @@ -232,6 +232,8 @@ export default function MakePayment({ showInviteToPay={false} hideFeeBanner={true} paymentStatus={payment?.state.status} + // TODO: Handle fee breakdown for ITP scenarios + showFeeBreakdown={false} /> )} From be64a139cbaf6f890108da1d3d1d6aa00f05da57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 27 Nov 2024 09:38:48 +0000 Subject: [PATCH 05/16] chore: Fix imports --- .../src/@planx/components/Pay/Editor/FeeBreakdownSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Editor/FeeBreakdownSection.tsx b/editor.planx.uk/src/@planx/components/Pay/Editor/FeeBreakdownSection.tsx index ceb9120838..5db61ca8ae 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Editor/FeeBreakdownSection.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Editor/FeeBreakdownSection.tsx @@ -1,6 +1,6 @@ -import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'; +import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; import { useFormikContext } from "formik"; -import { hasFeatureFlag } from 'lib/featureFlags'; +import { hasFeatureFlag } from "lib/featureFlags"; import React from "react"; import ModalSection from "ui/editor/ModalSection"; import ModalSectionContent from "ui/editor/ModalSectionContent"; From ae5c5a8b1d5f38d9ca98d8b27e232b2b9fdaf8eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 27 Nov 2024 11:09:44 +0000 Subject: [PATCH 06/16] test: Utils --- .../Pay/Public/FeeBreakdown/utils.test.ts | 71 +++++++++++++++++++ .../Pay/Public/FeeBreakdown/utils.ts | 12 ++-- 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.test.ts 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 new file mode 100644 index 0000000000..25e52596b9 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.test.ts @@ -0,0 +1,71 @@ +import { PassportFeeFields } from "./types"; +import { calculateReduction, toFeeBreakdown, toNumber } from "./utils"; + +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, + }; + 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, + }; + 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, + }; + + const output = 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); + }); + + 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, + }; + + const output = toFeeBreakdown(input); + + expect(output.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 90c76d260d..cd8b41c859 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 @@ -2,15 +2,15 @@ import { z } from "zod"; import { FeeBreakdown, PassportFeeFields } from "./types"; -const toNumber = (input: number | number[]) => - Array.isArray(input) ? Number(input[0]) : input; +export const toNumber = (input: number | [number]) => + Array.isArray(input) ? input[0] : input; -const calculateReduction = (data: PassportFeeFields) => +export const calculateReduction = (data: PassportFeeFields) => data["application.fee.calculated"] ? data["application.fee.calculated"] - data["application.fee.payable"] : 0; -const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ +export const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ applicationFee: data["application.fee.calculated"] || data["application.fee.payable"], total: data["application.fee.payable"], @@ -19,8 +19,8 @@ const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ }); export const createPassportSchema = () => { - const questionSchema = z.number(); - const setValueSchema = z.array(z.coerce.number()); + const questionSchema = z.number().positive(); + const setValueSchema = z.tuple([z.coerce.number().positive()]); const feeSchema = z .union([questionSchema, setValueSchema]) .transform(toNumber); From 9eb735ca3065d464cca9a02cf4a7af7a06b5c89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 27 Nov 2024 11:24:20 +0000 Subject: [PATCH 07/16] test: Hook --- .../FeeBreakdown/useFeeBreakdown.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.test.ts 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 new file mode 100644 index 0000000000..4e4c042d5a --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.test.ts @@ -0,0 +1,116 @@ +import { logger } from "airbrake"; +import { useStore } from "pages/FlowEditor/lib/store"; +import { vi } from "vitest"; + +import { useFeeBreakdown } from "./useFeeBreakdown"; + +vi.mock("pages/FlowEditor/lib/store", () => ({ + useStore: vi.fn(), +})); + +vi.mock("airbrake", () => ({ + logger: { + notify: vi.fn(), + }, +})); + +describe("useFeeBreakdown() hook", () => { + 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, + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result).toEqual({ + applicationFee: 1000, + total: 800, + reduction: 200, + vat: 160, + }); + }); + + it("returns a fee breakdown for number tuple inputs", () => { + const mockPassportData = { + "application.fee.calculated": [1000], + "application.fee.payable": [800], + "application.fee.payable.vat": [160], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result).toEqual({ + applicationFee: 1000, + total: 800, + reduction: 200, + vat: 160, + }); + }); + }); + + describe("invalid inputs", () => { + it("returns undefined for missing data", () => { + const mockPassportData = {}; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for partial data", () => { + const mockPassportData = { + "application.fee.calculated": [1000], + "application.fee.payable.vat": [160], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for incorrect data", () => { + const mockPassportData = { + "application.fee.calculated": "some string", + "application.fee.payable": [800, 700], + "application.fee.payable.vat": false, + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result).toBeUndefined(); + }); + + it("calls Airbrake if invalid inputs are provided", () => { + const mockPassportData = {}; + const mockSessionId = "test-session"; + + vi.mocked(useStore).mockReturnValue([mockPassportData, mockSessionId]); + const loggerSpy = vi.spyOn(logger, "notify"); + + const result = useFeeBreakdown(); + + expect(result).toBeUndefined(); + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining(mockSessionId), + ); + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining("ZodError"), + ); + }); + }); +}); From 595d8dd0c2aa6516732014a87c14bdf472c77a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 27 Nov 2024 11:39:26 +0000 Subject: [PATCH 08/16] test: Move feature flag check due to rule of conditional hooks --- .../src/@planx/components/Pay/Public/Confirm.tsx | 6 +++++- .../Pay/Public/FeeBreakdown/FeeBreakdown.tsx | 3 --- .../src/@planx/components/Pay/Public/Pay.test.tsx | 15 ++++++++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx index e30ee2e30c..7c175ba4e7 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx @@ -6,6 +6,7 @@ import Typography from "@mui/material/Typography"; import { PaymentStatus } from "@opensystemslab/planx-core/types"; import Card from "@planx/components/shared/Preview/Card"; import SaveResumeButton from "@planx/components/shared/Preview/SaveResumeButton"; +import { hasFeatureFlag } from "lib/featureFlags"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useState } from "react"; import { ApplicationPath } from "types"; @@ -140,6 +141,9 @@ export default function Confirm(props: Props) { changePage, }; + const showFeeBreakdown = + props.showFeeBreakdown && hasFeatureFlag("FEE_BREAKDOWN"); + return ( <> @@ -181,7 +185,7 @@ export default function Confirm(props: Props) { /> - {props.showFeeBreakdown && } + {showFeeBreakdown && } )} {page === "Pay" ? ( diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx index f0062d03e7..8298e553c6 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx @@ -7,7 +7,6 @@ import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; -import { hasFeatureFlag } from "lib/featureFlags"; import React from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; @@ -91,8 +90,6 @@ export const FeeBreakdown: React.FC = () => { const breakdown = useFeeBreakdown(); if (!breakdown) return null; - if (!hasFeatureFlag("FEE_BREAKDOWN")) return null; - return ( diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx index be94bc59f8..96e1314306 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.test.tsx @@ -489,7 +489,20 @@ describe("the demo user view", () => { }); describe("Displaying the fee breakdown", () => { - beforeAll(() => (initialState = getState())); + beforeAll(() => { + initialState = getState(); + // Valid passport data is required to display the breakdown + setState({ + computePassport: vi.fn().mockReturnValue({ + data: { + "application.fee.calculated": 1000, + "application.fee.payable": 800, + "application.fee.payable.vat": 160, + }, + }), + }); + }); + afterEach(() => act(() => setState(initialState))); test("if the showFeeBreakdown prop is set, the breakdown is displayed to the user", () => { From 3e5252306508a8f3b8d0aacd1e3e41c889a293dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 27 Nov 2024 12:11:42 +0000 Subject: [PATCH 09/16] docs: Add some JSDocs --- .../Pay/Public/FeeBreakdown/useFeeBreakdown.tsx | 8 +++++++- .../@planx/components/Pay/Public/FeeBreakdown/utils.ts | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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 e100052ba2..216ce36f53 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 @@ -4,6 +4,13 @@ import { useStore } from "pages/FlowEditor/lib/store"; import { FeeBreakdown } from "./types"; import { createPassportSchema } from "./utils"; +/** + * Parses the users's Passport for data variables associated with their fee + * Currently relies on static `application.fee.x` variables + * + * If fee variables not found, or not successfully parsed, we do not show the user a fee breakdown + * Instead an internal error will be raised allowing us to correct the flow content + */ export const useFeeBreakdown = (): FeeBreakdown | undefined => { const [passportData, sessionId] = useStore((state) => [ state.computePassport().data, @@ -12,7 +19,6 @@ export const useFeeBreakdown = (): FeeBreakdown | undefined => { const schema = createPassportSchema(); const result = schema.safeParse(passportData); - // Unable to parse fee data from passport, do not show FeeBreakdown component if (!result.success) { logger.notify( `Failed to parse fee breakdown data from passport for session ${sessionId}. Error: ${result.error}`, 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 cd8b41c859..cc27682e3d 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,11 +5,18 @@ import { FeeBreakdown, PassportFeeFields } from "./types"; export const toNumber = (input: number | [number]) => Array.isArray(input) ? input[0] : input; +/** + * 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"] ? data["application.fee.calculated"] - data["application.fee.payable"] : 0; +/** + * Transform Passport data to a FeeBreakdown shape + */ export const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ applicationFee: data["application.fee.calculated"] || data["application.fee.payable"], From d4ffe0983b74e887e24c1f51944dda2c3c82160c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Wed, 27 Nov 2024 16:15:28 +0000 Subject: [PATCH 10/16] fix: Change `.positive()` to more correct `.nonnegative()` --- .../src/@planx/components/Pay/Public/FeeBreakdown/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 cc27682e3d..6afa520dfc 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 @@ -26,8 +26,8 @@ export const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ }); export const createPassportSchema = () => { - const questionSchema = z.number().positive(); - const setValueSchema = z.tuple([z.coerce.number().positive()]); + const questionSchema = z.number().nonnegative(); + const setValueSchema = z.tuple([z.coerce.number().nonnegative()]); const feeSchema = z .union([questionSchema, setValueSchema]) .transform(toNumber); From ab3f9e0fa45ef8aa292418a67edfee6c1bc09a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 29 Nov 2024 13:40:20 +0000 Subject: [PATCH 11/16] refactor: Restructure type to nest amount object --- .../Pay/Public/FeeBreakdown/FeeBreakdown.tsx | 10 ++-- .../Pay/Public/FeeBreakdown/types.ts | 20 ++++---- .../FeeBreakdown/useFeeBreakdown.test.ts | 20 ++++---- .../Public/FeeBreakdown/useFeeBreakdown.tsx | 7 ++- .../Pay/Public/FeeBreakdown/utils.test.ts | 46 +++++++++++-------- .../Pay/Public/FeeBreakdown/utils.ts | 36 ++++++++++----- 6 files changed, 87 insertions(+), 52 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx index 8298e553c6..42ba5e7a1b 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx @@ -90,6 +90,8 @@ export const FeeBreakdown: React.FC = () => { const breakdown = useFeeBreakdown(); if (!breakdown) return null; + const { amount, exemptions: _exemptions, reductions: _reductions } = breakdown; + return ( @@ -102,10 +104,10 @@ 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..479ed2d72e 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,16 @@ export interface FeeBreakdown { - applicationFee: number; - total: number; - reduction: number; - vat: number | undefined; + amount: { + applicationFee: number; + total: number; + reduction: number; + vat: number | undefined; + }; } export interface PassportFeeFields { - "application.fee.calculated": number; - "application.fee.payable": number; - "application.fee.payable.vat": number; -} + amount: { + "application.fee.calculated": number; + "application.fee.payable": number; + "application.fee.payable.vat": number; + }, +}; \ 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..8ab1c2ff77 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 @@ -28,10 +28,12 @@ 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, + }, }); }); @@ -47,10 +49,12 @@ 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, + } }); }); }); 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..8ec4eb3aa3 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 } from "./utils"; +import { createPassportSchema, preProcessPassport } from "./utils"; /** * Parses the users's Passport for data variables associated with their fee @@ -16,8 +16,11 @@ export const useFeeBreakdown = (): FeeBreakdown | undefined => { state.computePassport().data, state.sessionId, ]); + if (!passportData) return + const schema = createPassportSchema(); - const result = schema.safeParse(passportData); + const processedPassport = preProcessPassport(passportData); + const result = schema.safeParse(processedPassport); 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 25e52596b9..20277c610b 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,9 +20,11 @@ describe("toNumber() helper function", () => { 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, + amount: { + "application.fee.calculated": 100, + "application.fee.payable": 50, + "application.fee.payable.vat": 0, + } }; const reduction = calculateReduction(input); @@ -31,9 +33,11 @@ describe("calculateReduction() helper function", () => { it("defaults to 0 when calculated is 0", () => { const input: PassportFeeFields = { - "application.fee.calculated": 0, - "application.fee.payable": 100, - "application.fee.payable.vat": 0, + amount: { + "application.fee.calculated": 0, + "application.fee.payable": 100, + "application.fee.payable.vat": 0, + } }; const reduction = calculateReduction(input); @@ -44,28 +48,32 @@ describe("calculateReduction() helper function", () => { describe("toFeeBreakdown() helper function", () => { it("correctly maps fields", () => { const input: PassportFeeFields = { - "application.fee.calculated": 100, - "application.fee.payable": 50, - "application.fee.payable.vat": 10, + amount: { + "application.fee.calculated": 100, + "application.fee.payable": 50, + "application.fee.payable.vat": 10, + } }; - 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.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.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, + amount : { + "application.fee.calculated": 0, + "application.fee.payable.vat": 10, + "application.fee.payable": 50, + } }; - const output = toFeeBreakdown(input); + const { amount } = toFeeBreakdown(input); - expect(output.applicationFee).toEqual(input["application.fee.payable"]); + expect(amount.applicationFee).toEqual(input.amount["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..8c4924d8d8 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 @@ -9,20 +9,29 @@ export const toNumber = (input: number | [number]) => * 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"] - ? data["application.fee.calculated"] - data["application.fee.payable"] +export const calculateReduction = ({ amount }: PassportFeeFields) => + amount["application.fee.calculated"] + ? amount["application.fee.calculated"] - amount["application.fee.payable"] : 0; /** * Transform Passport data to a FeeBreakdown shape */ 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.amount["application.fee.calculated"] || data.amount["application.fee.payable"], + total: data.amount["application.fee.payable"], + vat: data.amount["application.fee.payable.vat"], + reduction: calculateReduction(data), + } +}); + +const filterByKey = (data: Record, key: string) => + Object.fromEntries(Object.entries(data).filter(([k, _v]) => k.startsWith(key))) + +export const preProcessPassport = (data: Record) => ({ + amount: filterByKey(data, "application.fee"), }); export const createPassportSchema = () => { @@ -32,13 +41,18 @@ export const createPassportSchema = () => { .union([questionSchema, setValueSchema]) .transform(toNumber); - const schema = z + const amountsSchema = z .object({ "application.fee.calculated": feeSchema.optional().default(0), "application.fee.payable": feeSchema, "application.fee.payable.vat": feeSchema.optional().default(0), - }) - .transform(toFeeBreakdown); + }); + + const schema = z + .object({ + amount: amountsSchema, + + }).transform(toFeeBreakdown); return schema; }; From c408fc9b25f35cff3a06069185f0e36547b7be3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 29 Nov 2024 14:32:32 +0000 Subject: [PATCH 12/16] feat: Add reductions and exemptions --- .../Pay/Public/FeeBreakdown/types.ts | 4 ++ .../Pay/Public/FeeBreakdown/utils.ts | 37 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) 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 479ed2d72e..f8c85944bb 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 @@ -5,6 +5,8 @@ export interface FeeBreakdown { reduction: number; vat: number | undefined; }; + reductions: string[]; + exemptions: string[]; } export interface PassportFeeFields { @@ -13,4 +15,6 @@ export interface PassportFeeFields { "application.fee.payable": number; "application.fee.payable.vat": number; }, + reductions?: Record + exemptions?: Record }; \ No newline at end of file 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 8c4924d8d8..066e178c14 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 @@ -2,6 +2,9 @@ import { z } from "zod"; import { FeeBreakdown, PassportFeeFields } from "./types"; +const reductionKeys = /^application\.fee\.reduction\..+$/; +const exemptionKeys = /^application\.fee\.exemption\..+$/; + export const toNumber = (input: number | [number]) => Array.isArray(input) ? input[0] : input; @@ -20,11 +23,18 @@ export const calculateReduction = ({ amount }: PassportFeeFields) => export const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ amount: { applicationFee: - data.amount["application.fee.calculated"] || data.amount["application.fee.payable"], + data.amount["application.fee.calculated"] || + data.amount["application.fee.payable"], total: data.amount["application.fee.payable"], vat: data.amount["application.fee.payable.vat"], reduction: calculateReduction(data), - } + }, + reductions: Object.entries(data.reductions || {}) + .filter(([_key, value]) => value) + .map(([key, _value]) => key.replace("application.fee.reduction.", "")), + exemptions: Object.entries(data.exemptions || {}) + .filter(([_key, value]) => value) + .map(([key, _value]) => key.replace("application.fee.exemption.", "")), }); const filterByKey = (data: Record, key: string) => @@ -32,6 +42,8 @@ const filterByKey = (data: Record, key: string) => export const preProcessPassport = (data: Record) => ({ amount: filterByKey(data, "application.fee"), + reductions: filterByKey(data, "application.fee.reduction"), + exemptions: filterByKey(data, "application.fee.exemption"), }); export const createPassportSchema = () => { @@ -48,11 +60,28 @@ export const createPassportSchema = () => { "application.fee.payable.vat": feeSchema.optional().default(0), }); + const reductionsSchema = z.record( + z.string().regex(reductionKeys), + z.array(z.string()) + .max(1) + .transform((val) => val[0].toLowerCase() === "true"), + ).optional(); + + const exemptionsSchema = z + .record( + z.string().regex(exemptionKeys), + z.array(z.string()) + .max(1) + .transform((val) => val[0].toLowerCase() === "true") + ) + .optional(); + const schema = z .object({ amount: amountsSchema, - + reductions: reductionsSchema, + exemptions: exemptionsSchema, }).transform(toFeeBreakdown); return schema; -}; +}; \ No newline at end of file From 5a584ab98cf7de93f9e5f52730f62d1627804627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 29 Nov 2024 16:37:01 +0000 Subject: [PATCH 13/16] test: useFeeBreakdown() --- .../FeeBreakdown/useFeeBreakdown.test.ts | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) 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 8ab1c2ff77..47434fc94e 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 @@ -34,6 +34,8 @@ describe("useFeeBreakdown() hook", () => { reduction: 200, vat: 160, }, + exemptions: [], + reductions: [], }); }); @@ -54,9 +56,81 @@ describe("useFeeBreakdown() hook", () => { 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.reasonOne": ["true"], + "application.fee.reduction.reasonTwo": ["true"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result?.reductions).toHaveLength(2); + expect(result?.reductions).toEqual( + expect.arrayContaining(["reasonOne", "reasonTwo"]) + ); + }); + + 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.reasonOne": ["false"], + "application.fee.reduction.reasonTwo": ["false"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result?.reductions).toHaveLength(0); + }); + + 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"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result?.exemptions).toHaveLength(2); + expect(result?.exemptions).toEqual( + expect.arrayContaining(["reasonOne", "reasonTwo"]) + ); + }); + + 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.reasonOne": ["false"], + "application.fee.exemption.reasonTwo": ["false"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result?.exemptions).toHaveLength(0); + }); }); describe("invalid inputs", () => { From ab81f4f89d6300233aa8437105711b112c660631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 29 Nov 2024 17:35:19 +0000 Subject: [PATCH 14/16] refactor: Tidy up --- .../Pay/Public/FeeBreakdown/utils.ts | 79 +++++++++++-------- 1 file changed, 44 insertions(+), 35 deletions(-) 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 066e178c14..13585428d6 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 @@ -2,15 +2,33 @@ import { z } from "zod"; import { FeeBreakdown, PassportFeeFields } from "./types"; -const reductionKeys = /^application\.fee\.reduction\..+$/; -const exemptionKeys = /^application\.fee\.exemption\..+$/; - 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"; + +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; +}; + /** * 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 = ({ amount }: PassportFeeFields) => amount["application.fee.calculated"] @@ -18,7 +36,7 @@ export const calculateReduction = ({ amount }: PassportFeeFields) => : 0; /** - * Transform Passport data to a FeeBreakdown shape + * Transform Passport data to a FeeBreakdown */ export const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ amount: { @@ -29,21 +47,14 @@ export const toFeeBreakdown = (data: PassportFeeFields): FeeBreakdown => ({ vat: data.amount["application.fee.payable.vat"], reduction: calculateReduction(data), }, - reductions: Object.entries(data.reductions || {}) - .filter(([_key, value]) => value) - .map(([key, _value]) => key.replace("application.fee.reduction.", "")), - exemptions: Object.entries(data.exemptions || {}) - .filter(([_key, value]) => value) - .map(([key, _value]) => key.replace("application.fee.exemption.", "")), + reductions: getGranularKeys(data.reductions, "application.fee.reduction"), + exemptions: getGranularKeys(data.exemptions, "application.fee.exemption"), }); -const filterByKey = (data: Record, key: string) => - Object.fromEntries(Object.entries(data).filter(([k, _v]) => k.startsWith(key))) - export const preProcessPassport = (data: Record) => ({ - amount: filterByKey(data, "application.fee"), - reductions: filterByKey(data, "application.fee.reduction"), - exemptions: filterByKey(data, "application.fee.exemption"), + amount: filterByKeyPrefix(data, "application.fee"), + reductions: filterByKeyPrefix(data, "application.fee.reduction"), + exemptions: filterByKeyPrefix(data, "application.fee.exemption"), }); export const createPassportSchema = () => { @@ -53,26 +64,23 @@ 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 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().regex(reductionKeys), - z.array(z.string()) - .max(1) - .transform((val) => val[0].toLowerCase() === "true"), - ).optional(); + const reductionsSchema = z + .record( + z.string(), + z.tuple([z.enum(["true", "false"])]).transform(toBoolean) + ) + .optional(); const exemptionsSchema = z .record( - z.string().regex(exemptionKeys), - z.array(z.string()) - .max(1) - .transform((val) => val[0].toLowerCase() === "true") + z.string(), + z.tuple([z.enum(["true", "false"])]).transform(toBoolean) ) .optional(); @@ -81,7 +89,8 @@ export const createPassportSchema = () => { amount: amountsSchema, reductions: reductionsSchema, exemptions: exemptionsSchema, - }).transform(toFeeBreakdown); + }) + .transform(toFeeBreakdown); return schema; -}; \ No newline at end of file +}; From d84e6a6d4b8338012c43cb61d872505baa54150d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Fri, 29 Nov 2024 17:36:33 +0000 Subject: [PATCH 15/16] feat: Basic UI --- .../Pay/Public/FeeBreakdown/FeeBreakdown.tsx | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx index 42ba5e7a1b..2fcccf850b 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx @@ -49,16 +49,52 @@ const ApplicationFee: React.FC<{ amount: number }> = ({ 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,7 +126,7 @@ export const FeeBreakdown: React.FC = () => { const breakdown = useFeeBreakdown(); if (!breakdown) return null; - const { amount, exemptions: _exemptions, reductions: _reductions } = breakdown; + const { amount, reductions, exemptions } = breakdown; return ( @@ -105,7 +141,8 @@ export const FeeBreakdown: React.FC = () => {
- + + From ae64367e41ea925a30dda47b80b09dad1030dca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dafydd=20Ll=C5=B7r=20Pearson?= Date: Sun, 1 Dec 2024 13:42:02 +0000 Subject: [PATCH 16/16] test: Ensure a realistic mock passport is used --- .../Public/FeeBreakdown/useFeeBreakdown.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 47434fc94e..b0282477b1 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"]); @@ -44,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"]); @@ -69,6 +71,7 @@ describe("useFeeBreakdown() hook", () => { "application.fee.payable.vat": 160, "application.fee.reduction.reasonOne": ["true"], "application.fee.reduction.reasonTwo": ["true"], + "some.other.fields": ["abc", "xyz"], }; vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); @@ -88,6 +91,7 @@ describe("useFeeBreakdown() hook", () => { "application.fee.payable.vat": 160, "application.fee.reduction.reasonOne": ["false"], "application.fee.reduction.reasonTwo": ["false"], + "some.other.fields": ["abc", "xyz"], }; vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); @@ -104,6 +108,7 @@ describe("useFeeBreakdown() hook", () => { "application.fee.payable.vat": 160, "application.fee.exemption.reasonOne": ["true"], "application.fee.exemption.reasonTwo": ["true"], + "some.other.fields": ["abc", "xyz"], }; vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); @@ -123,6 +128,7 @@ describe("useFeeBreakdown() hook", () => { "application.fee.payable.vat": 160, "application.fee.exemption.reasonOne": ["false"], "application.fee.exemption.reasonTwo": ["false"], + "some.other.fields": ["abc", "xyz"], }; vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); @@ -135,7 +141,9 @@ describe("useFeeBreakdown() hook", () => { 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"]); @@ -148,6 +156,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"]); @@ -162,6 +171,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"]); @@ -172,7 +182,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]);