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,