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) => { - - - + + + ({ - [`& .${tableCellClasses.root}`]: { - paddingLeft: 0, - paddingRight: 0, - }, -})); - -const BoldTableRow = styled(TableRow)(() => ({ - [`& .${tableCellClasses.root}`]: { - fontWeight: FONT_WEIGHT_SEMI_BOLD, - }, -})); - -const VAT_RATE = 20; - -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."; - -const Header = () => ( - - - Description - Amount - - -); - -const ApplicationFee = () => ( - - Application fee - {formattedPriceWithCurrencySymbol(100)} - -); - -const Exemptions = () => ( - - Exemptions - {formattedPriceWithCurrencySymbol(-20)} - -); - -const Reductions = () => ( - - Reductions - {formattedPriceWithCurrencySymbol(-30)} - -); - -const ServiceCharge = () => ( - - Service charge - {formattedPriceWithCurrencySymbol(30)} - -); - -const VAT = () => ( - - {`VAT (${VAT_RATE}%)`} - - - -); - -const Total = () => ( - - Total - {formattedPriceWithCurrencySymbol(80)} - -); - -export const FeeBreakdown: React.FC = () => { - if (!hasFeatureFlag("FEE_BREAKDOWN")) return null; - - return ( - - - Fee breakdown - - - {DESCRIPTION} - - - -
- - - - - - - - - - - - ); -}; 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/FeeBreakdown.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx new file mode 100644 index 0000000000..5ef73fc59f --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/FeeBreakdown.tsx @@ -0,0 +1,153 @@ +import Box from "@mui/material/Box"; +import { styled } from "@mui/material/styles"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell, { tableCellClasses } from "@mui/material/TableCell"; +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 React from "react"; +import { FONT_WEIGHT_SEMI_BOLD } from "theme"; + +import { formattedPriceWithCurrencySymbol } from "../../model"; +import { useFeeBreakdown } from "./useFeeBreakdown"; + +const StyledTable = styled(Table)(() => ({ + [`& .${tableCellClasses.root}`]: { + paddingLeft: 0, + paddingRight: 0, + }, +})); + +const BoldTableRow = styled(TableRow)(() => ({ + [`& .${tableCellClasses.root}`]: { + fontWeight: FONT_WEIGHT_SEMI_BOLD, + }, +})); + +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."; + +const Header = () => ( + + + Description + Amount + + +); + +const ApplicationFee: React.FC<{ amount: number }> = ({ amount }) => ( + + Application fee + + {formattedPriceWithCurrencySymbol(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} + + + )) + } + + ); +}; + +const VAT: React.FC<{ amount?: number }> = ({ amount }) => { + if (!amount) return null; + + return ( + + {`Includes VAT (${ + VAT_RATE * 100 + }%)`} + + {formattedPriceWithCurrencySymbol(amount)} + + + ); +}; + +const Total: React.FC<{ amount: number }> = ({ amount }) => ( + + Total + + {formattedPriceWithCurrencySymbol(amount)} + + +); + +export const FeeBreakdown: React.FC = () => { + const breakdown = useFeeBreakdown(); + if (!breakdown) return null; + + const { amount, reductions, exemptions } = breakdown; + + return ( + + + Fee breakdown + + + {DESCRIPTION} + + + +
+ + + + + + + + + + + ); +}; 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..176cf87724 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/types.ts @@ -0,0 +1,21 @@ +export interface FeeBreakdown { + 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 new file mode 100644 index 0000000000..5312cb12c2 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.test.ts @@ -0,0 +1,256 @@ +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, + "some.other.fields": ["abc", "xyz"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result).toEqual({ + amount: { + applicationFee: 1000, + total: 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"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result).toEqual({ + 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 = { + "some.other.fields": ["abc", "xyz"], + }; + + 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], + "some.other.fields": ["abc", "xyz"], + }; + + 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, + "some.other.fields": ["abc", "xyz"], + }; + + vi.mocked(useStore).mockReturnValue([mockPassportData, "test-session"]); + + const result = useFeeBreakdown(); + + expect(result).toBeUndefined(); + }); + + it("calls Airbrake if invalid inputs are provided", () => { + const mockPassportData = { + "some.other.fields": ["abc", "xyz"], + }; + 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"), + ); + }); + }); +}); 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..b1e6b10c1d --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/useFeeBreakdown.tsx @@ -0,0 +1,32 @@ +import { logger } from "airbrake"; +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, + state.sessionId, + ]); + if (!passportData) return + + const schema = createPassportSchema(); + const result = schema.safeParse(passportData); + + 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.test.ts b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.test.ts new file mode 100644 index 0000000000..935c8cb066 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.test.ts @@ -0,0 +1,91 @@ +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, + "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.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.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["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 new file mode 100644 index 0000000000..759c5438db --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Pay/Public/FeeBreakdown/utils.ts @@ -0,0 +1,83 @@ +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: { + 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 = () => { + 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; +}; 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 71e43ed2d5..0391100764 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 @@ -1,7 +1,6 @@ import { PaymentStatus } from "@opensystemslab/planx-core/types"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; import { screen } from "@testing-library/react"; -import { hasFeatureFlag } from "lib/featureFlags"; import { FullStore, Store, useStore } from "pages/FlowEditor/lib/store"; import React from "react"; import { act } from "react-dom/test-utils"; @@ -9,7 +8,6 @@ import * as ReactNavi from "react-navi"; import { setup } from "testUtils"; import { ApplicationPath, Breadcrumbs } from "types"; import { vi } from "vitest"; -import { Mock } from "vitest"; import { axe } from "vitest-axe"; import Confirm, { Props } from "./Confirm"; @@ -27,8 +25,6 @@ vi.mock("lib/featureFlags", () => ({ hasFeatureFlag: vi.fn(), })); -const mockHasFeatureFlag = hasFeatureFlag as Mock; - const resumeButtonText = "Resume an application you have already started"; const saveButtonText = "Save and return to this application later";