From 380f27e35758148a022f7de2bd7f30290a7aadf3 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 9 Oct 2023 07:51:33 +0100 Subject: [PATCH] feat: add option to use "Pay without pay" and show error in cases of undefined fee (#2284) --- .../src/@planx/components/Pay/Editor.tsx | 39 ++-- .../@planx/components/Pay/Public/Confirm.tsx | 120 +++++++----- .../components/Pay/Public/Pay.stories.tsx | 14 ++ .../@planx/components/Pay/Public/Pay.test.tsx | 183 ++++++++++++++++-- .../src/@planx/components/Pay/Public/Pay.tsx | 50 +++-- .../src/@planx/components/Pay/model.ts | 2 + 6 files changed, 310 insertions(+), 98 deletions(-) diff --git a/editor.planx.uk/src/@planx/components/Pay/Editor.tsx b/editor.planx.uk/src/@planx/components/Pay/Editor.tsx index e37945e05b..b6fac2b43e 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Editor.tsx @@ -31,6 +31,7 @@ function Component(props: any) { `

You can pay for your application by using GOV.UK Pay.

\

Your application will be sent after you have paid the fee. \ Wait until you see an application sent message before closing your browser.

`, + hidePay: props.node?.data?.hidePay || false, allowInviteToPay: props.node?.data?.allowInviteToPay ?? true, secondaryPageTitle: props.node?.data?.secondaryPageTitle || @@ -113,20 +114,30 @@ function Component(props: any) { /> - - - { - formik.setFieldValue( - "allowInviteToPay", - !formik.values.allowInviteToPay, - ); - }} - > - Allow applicants to invite someone else to pay - - + { + formik.setFieldValue("hidePay", !formik.values.hidePay); + }} + style={{ width: "100%" }} + > + Hide the pay buttons and show fee for information only + + + + + { + formik.setFieldValue( + "allowInviteToPay", + !formik.values.allowInviteToPay, + ); + }} + style={{ width: "100%" }} + > + Allow applicants to invite someone else to pay + {formik.values.allowInviteToPay ? ( <> 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 a482eaa863..2b5c6efa93 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Confirm.tsx @@ -15,6 +15,7 @@ import ReactMarkdownOrHtml from "ui/ReactMarkdownOrHtml"; import { formattedPriceWithCurrencySymbol } from "../model"; import InviteToPayForm, { InviteToPayFormProps } from "./InviteToPayForm"; +import { PAY_API_ERROR_UNSUPPORTED_TEAM } from "./Pay"; export interface Props { title?: string; @@ -35,6 +36,7 @@ export interface Props { onConfirm: () => void; error?: string; hideFeeBanner?: boolean; + hidePay?: boolean; } interface PayBodyProps extends Props { @@ -60,53 +62,9 @@ const PayBody: React.FC = (props) => { const path = useStore((state) => state.path); const isSaveReturn = path === ApplicationPath.SaveAndReturn; - return ( - <> - {!props.error ? ( - - - - {props.instructionsTitle || "How to pay"} - - You can pay for your application by using GOV.UK Pay.

\ -

Your application will be sent after you have paid the fee. \ - Wait until you see an application sent message before closing your browser.

` - } - openLinksOnNewTab - /> - - {props.showInviteToPay && ( - <> - - - )} - {isSaveReturn && } -
-
- ) : ( + if (props.error) { + if (props.error.startsWith(PAY_API_ERROR_UNSUPPORTED_TEAM)) { + return ( @@ -118,8 +76,68 @@ const PayBody: React.FC = (props) => { - )} - + ); + } else { + return ( + + + + {props.error} + + + This error has been logged and our team will see it soon. You can + safely close this tab and try resuming again soon by returning to + this URL. + + + + ); + } + } + + return ( + + + + {props.instructionsTitle || "How to pay"} + + You can pay for your application by using GOV.UK Pay.

\ +

Your application will be sent after you have paid the fee. \ + Wait until you see an application sent message before closing your browser.

` + } + openLinksOnNewTab + /> + + {!props.hidePay && props.showInviteToPay && ( + <> + + + )} + {isSaveReturn && } +
+
); }; @@ -176,7 +194,9 @@ export default function Confirm(props: Props) { className="marginBottom" component="span" > - {formattedPriceWithCurrencySymbol(props.fee)} + {isNaN(props.fee) + ? "Unknown" + : formattedPriceWithCurrencySymbol(props.fee)} { - const handleSubmit = jest.fn(); +const flowWithUndefinedFee: Store.flow = { + _root: { + edges: ["setValue", "pay"], + }, + setValue: { + type: TYPES.SetValue, + edges: ["pay"], + data: { + fn: "application.fee.payable", + val: "0", + }, + }, + pay: { + type: TYPES.Pay, + data: { + fn: "application.fee.typo", + }, + }, +}; - // if no props.fn, then fee defaults to 0 - setup(); +const flowWithZeroFee: Store.flow = { + _root: { + edges: ["setValue", "pay"], + }, + setValue: { + type: TYPES.SetValue, + edges: ["pay"], + data: { + fn: "application.fee.payable", + val: "0", + }, + }, + pay: { + type: TYPES.Pay, + data: { + fn: "application.fee.payable", + }, + }, +}; - // handleSubmit is still called to set auto = true so Pay isn't seen in card sequence - expect(handleSubmit).toHaveBeenCalled(); -}); +// Mimic having passed setValue to reach Pay +const breadcrumbs: Breadcrumbs = { + setValue: { + auto: true, + data: { + "application.fee.payable": ["0"], + }, + }, +}; + +describe("Pay component when fee is undefined or £0", () => { + beforeEach(() => { + getState().resetPreview(); + }); + + it("Shows an error if fee is undefined", () => { + const handleSubmit = jest.fn(); + + setState({ flow: flowWithUndefinedFee, breadcrumbs: breadcrumbs }); + expect(getState().computePassport()).toEqual({ + data: { "application.fee.payable": ["0"] }, + }); + + setup(); + + // handleSubmit has NOT been called (not skipped), Pay shows error instead + expect(handleSubmit).not.toHaveBeenCalled(); + expect( + screen.getByText("We are unable to calculate your fee right now"), + ).toBeInTheDocument(); + expect(screen.queryByText("Continue")).not.toBeInTheDocument(); + }); + + it("Skips pay if fee = 0", () => { + const handleSubmit = jest.fn(); + + setState({ flow: flowWithZeroFee, breadcrumbs: breadcrumbs }); + expect(getState().computePassport()).toEqual({ + data: { "application.fee.payable": ["0"] }, + }); -it("should not have any accessibility violations", async () => { - const handleSubmit = jest.fn(); - const { container } = setup(); - const results = await axe(container); - expect(results).toHaveNoViolations(); + setup(); + + // handleSubmit is called to auto-answer Pay (aka "skip" in card sequence) + expect(handleSubmit).toHaveBeenCalled(); + }); }); const defaultProps = { @@ -89,7 +161,8 @@ describe("Confirm component without inviteToPay", () => { it("displays an error and continue-with-testing button if Pay is not enabled for this team", async () => { const handleSubmit = jest.fn(); - const errorMessage = "No pay token found for this team!"; + const errorMessage = + "GOV.UK Pay is not enabled for this local authority (testing)"; const { user } = setup( { expect(handleSubmit).toHaveBeenCalled(); }); - it("should not have any accessibility violations", async () => { - const { container } = setup(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - it("displays the Save/Resume option if the application path requires it", () => { act(() => setState({ @@ -141,6 +208,12 @@ describe("Confirm component without inviteToPay", () => { expect(screen.queryByText(saveButtonText)).not.toBeInTheDocument(); expect(screen.queryByText(resumeButtonText)).not.toBeInTheDocument(); }); + + it("should not have any accessibility violations", async () => { + const { container } = setup(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); }); describe("Confirm component with inviteToPay", () => { @@ -293,3 +366,71 @@ describe("Confirm component with inviteToPay", () => { expect(results).toHaveNoViolations(); }); }); + +describe("Confirm component in information-only mode", () => { + beforeAll(() => (initialState = getState())); + afterEach(() => act(() => setState(initialState))); + + it("renders correctly", async () => { + const handleSubmit = jest.fn(); + const { user } = setup( + , + ); + + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Pay for your application", + ); + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( + "The fee is", + ); + expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent( + "How to pay", + ); + + expect(screen.getByRole("button")).toHaveTextContent("Continue"); + expect(screen.getByRole("button")).not.toHaveTextContent("Pay"); + + await user.click(screen.getByText("Continue")); + expect(handleSubmit).toHaveBeenCalled(); + }); + + it("renders correctly when inviteToPay is also toggled on by an editor", async () => { + const handleSubmit = jest.fn(); + const { user } = setup( + , + ); + + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Pay for your application", + ); + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( + "The fee is", + ); + expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent( + "How to pay", + ); + + expect(screen.getByRole("button")).toHaveTextContent("Continue"); + expect(screen.getByRole("button")).not.toHaveTextContent("Pay"); + expect(screen.getByRole("button")).not.toHaveTextContent( + "Invite someone else to pay for this application", + ); + + await user.click(screen.getByText("Continue")); + expect(handleSubmit).toHaveBeenCalled(); + }); + + it("should not have any accessibility violations", async () => { + const handleSubmit = jest.fn(); + const { container } = setup( + , + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx index c843b3d5dc..6925de00fd 100644 --- a/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx +++ b/editor.planx.uk/src/@planx/components/Pay/Public/Pay.tsx @@ -30,9 +30,11 @@ type ComponentState = | { status: "fetching_payment"; displayText?: string } | { status: "retry" } | { status: "success"; displayText?: string } - | { status: "unsupported_team" }; + | { status: "unsupported_team" } + | { status: "undefined_fee" }; enum Action { + NoFeeFound, NoPaymentFound, IncompletePaymentFound, IncompletePaymentConfirmed, @@ -42,6 +44,9 @@ enum Action { Success, } +export const PAY_API_ERROR_UNSUPPORTED_TEAM = + "GOV.UK Pay is not enabled for this local authority"; + function Component(props: Props) { const [ flowId, @@ -67,6 +72,8 @@ function Component(props: Props) { // Handles UI states const reducer = (_state: ComponentState, action: Action): ComponentState => { switch (action) { + case Action.NoFeeFound: + return { status: "undefined_fee" }; case Action.NoPaymentFound: return { status: "init" }; case Action.IncompletePaymentFound: @@ -101,11 +108,18 @@ function Component(props: Props) { const handleError = useErrorHandler(); useEffect(() => { - if (isNaN(fee) || fee <= 0) { - // skip the pay component because there's no fee to charge + // Auto-skip component when fee=0 + if (fee <= 0) { return props.handleSubmit({ auto: true }); } + // If props.fn is undefined, display & log an error + if (isNaN(fee)) { + dispatch(Action.NoFeeFound); + logger.notify(`Unable to calculate fee for session ${sessionId}`); + return; + } + if (!govUkPayment) { dispatch(Action.NoPaymentFound); return; @@ -231,7 +245,11 @@ function Component(props: Props) { window.location.replace(payment._links.next_url.href); }) .catch((error) => { - if (error.response?.data?.error?.endsWith("local authority")) { + if ( + error.response?.data?.error?.startsWith( + PAY_API_ERROR_UNSUPPORTED_TEAM, + ) + ) { // Show a custom message if this team isn't set up to use Pay yet dispatch(Action.StartNewPaymentError); } else { @@ -247,18 +265,19 @@ function Component(props: Props) { <> {state.status === "init" || state.status === "retry" || - state.status === "unsupported_team" ? ( + state.status === "unsupported_team" || + state.status === "undefined_fee" ? ( { - if (state.status === "init") { + if (props.hidePay || state.status === "unsupported_team") { + // Show "Continue" button to proceed + props.handleSubmit({ auto: false }); + } else if (state.status === "init") { startNewPayment(); } else if (state.status === "retry") { resumeExistingPayment(); - } else if (state.status === "unsupported_team") { - // Allow "Continue" button to skip Pay - props.handleSubmit({ auto: true }); } }} buttonTitle={ @@ -267,14 +286,19 @@ function Component(props: Props) { : "Retry payment" } error={ - state.status === "unsupported_team" - ? "GOV.UK Pay is not enabled for this local authority" - : undefined + (state.status === "unsupported_team" && + "GOV.UK Pay is not enabled for this local authority") || + (state.status === "undefined_fee" && + "We are unable to calculate your fee right now") || + undefined } showInviteToPay={ - props.allowInviteToPay && state.status !== "unsupported_team" + props.allowInviteToPay && + !props.hidePay && + state.status !== "unsupported_team" } paymentStatus={govUkPayment?.state?.status} + hidePay={props.hidePay} /> ) : ( diff --git a/editor.planx.uk/src/@planx/components/Pay/model.ts b/editor.planx.uk/src/@planx/components/Pay/model.ts index 7ff2c6d0e7..9d0fe202d3 100644 --- a/editor.planx.uk/src/@planx/components/Pay/model.ts +++ b/editor.planx.uk/src/@planx/components/Pay/model.ts @@ -12,6 +12,7 @@ export interface Pay extends MoreInformation { fn?: string; instructionsTitle?: string; instructionsDescription?: string; + hidePay?: boolean; allowInviteToPay?: boolean; secondaryPageTitle?: string; nomineeTitle?: string; @@ -87,6 +88,7 @@ export const validationSchema = object({ fn: string().trim().required("Data field is required"), instructionsTitle: string().trim().required(), instructionsDescription: string().trim().required(), + hidePay: boolean(), allowInviteToPay: boolean(), nomineeTitle: string().trim().when("allowInviteToPay", { is: true,