diff --git a/api.planx.uk/docs/index.ts b/api.planx.uk/docs/index.ts index f12939a0b6..734fb58606 100644 --- a/api.planx.uk/docs/index.ts +++ b/api.planx.uk/docs/index.ts @@ -36,7 +36,7 @@ const parameters = { type: "string", required: true, description: - "Name of the Local Authority, usually the same as Planx `team`", + "Name of the Local Authority, usually the same as PlanX `team`", }, hasuraAuth: { name: "authorization", diff --git a/api.planx.uk/inviteToPay/inviteToPay.ts b/api.planx.uk/inviteToPay/inviteToPay.ts deleted file mode 100644 index 6dfa87267e..0000000000 --- a/api.planx.uk/inviteToPay/inviteToPay.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import type { PaymentRequest, KeyPath } from "@opensystemslab/planx-core/types"; - -import { ServerError } from "../errors"; -import { $api } from "../client"; - -export async function inviteToPay( - req: Request, - res: Response, - next: NextFunction, -) { - const sessionId = req.params.sessionId; - const { - payeeEmail, - payeeName, - applicantName, - sessionPreviewKeys, - }: { - payeeEmail: string; - payeeName: string; - applicantName: string; - sessionPreviewKeys: Array; - } = req.body; - - for (const [requiredFieldName, requiredFieldValue] of Object.entries({ - sessionId, - applicantName, - payeeName, - payeeEmail, - })) { - if (!requiredFieldValue) { - return next( - new ServerError({ - message: `JSON body must contain ${requiredFieldName}`, - status: 400, - }), - ); - } - } - - // lock session before creating a payment request - const locked = await $api.session.lock(sessionId); - if (locked === null) { - return next( - new ServerError({ - message: "session not found", - status: 404, - }), - ); - } - if (locked === false) { - const cause = new Error( - "this session could not be locked, perhaps because it is already locked", - ); - return next( - new ServerError({ - message: `could not initiate a payment request: ${cause.message}`, - status: 400, - cause, - }), - ); - } - - let paymentRequest: PaymentRequest | undefined; - try { - paymentRequest = await $api.paymentRequest.create({ - sessionId, - applicantName, - payeeName, - payeeEmail, - sessionPreviewKeys, - }); - } catch (e: unknown) { - // revert the session lock on failure - await $api.session.unlock(sessionId); - return next( - new ServerError({ - message: - e instanceof Error - ? `could not initiate a payment request: ${e.message}` - : "could not initiate a payment request due to an unknown error", - status: 500, - cause: e, - }), - ); - } - - res.json(paymentRequest); -} diff --git a/api.planx.uk/pay/index.ts b/api.planx.uk/modules/pay/controller.ts similarity index 57% rename from api.planx.uk/pay/index.ts rename to api.planx.uk/modules/pay/controller.ts index 0e525c3e04..8d2fe4bfb4 100644 --- a/api.planx.uk/pay/index.ts +++ b/api.planx.uk/modules/pay/controller.ts @@ -1,48 +1,32 @@ import assert from "assert"; -import { NextFunction, Request, Response } from "express"; +import { Request } from "express"; import { responseInterceptor } from "http-proxy-middleware"; -import SlackNotify from "slack-notify"; -import { logPaymentStatus } from "../send/helpers"; +import { logPaymentStatus } from "../../send/helpers"; import { usePayProxy } from "./proxy"; -import { $api } from "../client"; -import { ServerError } from "../errors"; -import { GovUKPayment } from "@opensystemslab/planx-core/types"; -import { addGovPayPaymentIdToPaymentRequest } from "./utils"; +import { $api } from "../../client"; +import { ServerError } from "../../errors"; +import { GovUKPayment, PaymentRequest } from "@opensystemslab/planx-core/types"; +import { + addGovPayPaymentIdToPaymentRequest, + postPaymentNotificationToSlack, +} from "./service/utils"; +import { + InviteToPayController, + PaymentProxyController, + PaymentRequestProxyController, +} from "./types"; assert(process.env.SLACK_WEBHOOK_URL); // exposed as /pay/:localAuthority and also used as middleware // returns the url to make a gov uk payment -export async function makePaymentViaProxy( - req: Request, - res: Response, - next: NextFunction, -) { - // confirm that this local authority (aka team) has a pay token configured before creating the proxy - const isSupported = - process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; - - if (!isSupported) { - return next( - new ServerError({ - message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, - status: 400, - }), - ); - } - - const flowId = req.query?.flowId as string | undefined; - const sessionId = req.query?.sessionId as string | undefined; - const teamSlug = req.params.localAuthority; - - if (!flowId || !sessionId || !teamSlug) { - return next( - new ServerError({ - message: "Missing required query param", - status: 400, - }), - ); - } +export const makePaymentViaProxy: PaymentProxyController = async ( + req, + res, + next, +) => { + const { flowId, sessionId } = res.locals.parsedReq.query; + const teamSlug = res.locals.parsedReq.params.localAuthority; const session = await $api.session.findDetails(sessionId); @@ -77,28 +61,16 @@ export async function makePaymentViaProxy( }, req, )(req, res, next); -} +}; -export async function makeInviteToPayPaymentViaProxy( - req: Request, - res: Response, - next: NextFunction, -) { - // confirm that this local authority (aka team) has a pay token configured before creating the proxy - const isSupported = - process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; - - if (!isSupported) { - return next({ - status: 400, - message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, - }); - } - - const flowId = req.query?.flowId as string | undefined; - const sessionId = req.query?.sessionId as string | undefined; - const paymentRequestId = req.params?.paymentRequest as string; - const teamSlug = req.params.localAuthority; +export const makeInviteToPayPaymentViaProxy: PaymentRequestProxyController = ( + req, + res, + next, +) => { + const { flowId, sessionId } = res.locals.parsedReq.query; + const { localAuthority: teamSlug, paymentRequest: paymentRequestId } = + res.locals.parsedReq.params; // drop req.params.localAuthority from the path when redirecting // so redirects to plain [GOV_UK_PAY_URL] with correct bearer token @@ -130,7 +102,7 @@ export async function makeInviteToPayPaymentViaProxy( }, req, )(req, res, next); -} +}; // exposed as /pay/:localAuthority/:paymentId and also used as middleware // fetches the status of the payment @@ -141,11 +113,10 @@ export const fetchPaymentViaProxy = fetchPaymentViaProxyWithCallback( export function fetchPaymentViaProxyWithCallback( callback: (req: Request, govUkPayment: GovUKPayment) => Promise, -) { - return async (req: Request, res: Response, next: NextFunction) => { - const flowId = req.query?.flowId as string | undefined; - const sessionId = req.query?.sessionId as string | undefined; - const teamSlug = req.params.localAuthority; +): PaymentProxyController { + return async (req, res, next) => { + const { flowId, sessionId } = res.locals.parsedReq.query; + const teamSlug = res.locals.parsedReq.params.localAuthority; // will redirect to [GOV_UK_PAY_URL]/:paymentId with correct bearer token usePayProxy( @@ -184,22 +155,56 @@ export function fetchPaymentViaProxyWithCallback( }; } -export async function postPaymentNotificationToSlack( - req: Request, - govUkResponse: GovUKPayment, - label = "", -) { - // if it's a prod payment, notify #planx-notifications so we can monitor for subsequent submissions - if (govUkResponse?.payment_provider !== "sandbox") { - const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); - const getStatus = (state: GovUKPayment["state"]) => - state.status + (state.message ? ` (${state.message})` : ""); - const payMessage = `:coin: New GOV Pay payment ${label} *${ - govUkResponse.payment_id - }* with status *${getStatus(govUkResponse.state)}* [${ - req.params.localAuthority - }]`; - await slack.send(payMessage); - console.log("Payment notification posted to Slack"); +export const inviteToPay: InviteToPayController = async (_req, res, next) => { + const { sessionId } = res.locals.parsedReq.params; + const { payeeEmail, payeeName, applicantName, sessionPreviewKeys } = + res.locals.parsedReq.body; + // lock session before creating a payment request + const locked = await $api.session.lock(sessionId); + if (locked === null) { + return next( + new ServerError({ + message: "session not found", + status: 404, + }), + ); } -} + if (locked === false) { + const cause = new Error( + "this session could not be locked, perhaps because it is already locked", + ); + return next( + new ServerError({ + message: `could not initiate a payment request: ${cause.message}`, + status: 400, + cause, + }), + ); + } + + let paymentRequest: PaymentRequest | undefined; + try { + paymentRequest = await $api.paymentRequest.create({ + sessionId, + applicantName, + payeeName, + payeeEmail, + sessionPreviewKeys, + }); + } catch (e: unknown) { + // revert the session lock on failure + await $api.session.unlock(sessionId); + return next( + new ServerError({ + message: + e instanceof Error + ? `could not initiate a payment request: ${e.message}` + : "could not initiate a payment request due to an unknown error", + status: 500, + cause: e, + }), + ); + } + + res.json(paymentRequest); +}; diff --git a/api.planx.uk/modules/pay/docs.yaml b/api.planx.uk/modules/pay/docs.yaml new file mode 100644 index 0000000000..25f8a7f953 --- /dev/null +++ b/api.planx.uk/modules/pay/docs.yaml @@ -0,0 +1,366 @@ +openapi: 3.1.0 +info: + title: Planâś• API + version: 0.1.0 +tags: + - name: pay + description: Endpoints for interacting with the GovPay API + externalDocs: + url: https://docs.payments.service.gov.uk/ +components: + parameters: + paymentId: + in: path + name: paymentId + type: string + format: uuid + required: true + paymentRequest: + in: path + name: paymentId + type: string + format: uuid + required: true + schemas: + InviteToPayRequest: + content: + application/json: + schema: + type: object + properties: + payeeEmail: + type: string + format: email + payeeName: + type: string + applicantName: + type: string + sessionPreviewKeys: + type: array + items: + type: array + items: string + CreatePaymentRequest: + description: | + Payment response for the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#example-response-for-get-information-about-a-single-payment](https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#example-response-for-get-information-about-a-single-payment) + content: + application/json: + schema: + type: object + properties: + amount: + type: integer + format: int32 + example: 14500 + reference: + type: string + example: "12345" + description: + type: string + example: "Pay your council tax" + return_url: + type: string + format: uri + example: "https://your.service.gov.uk/completed" + delayed_capture: + type: boolean + example: false + metadata: + type: object + properties: + ledger_code: + type: string + example: "AB100" + internal_reference_number: + type: integer + example: 200 + email: + type: string + format: email + example: "sherlock.holmes@example.com" + prefilled_cardholder_details: + type: object + properties: + cardholder_name: + type: string + example: "Sherlock Holmes" + billing_address: + type: object + properties: + line1: + type: string + example: "221 Baker Street" + line2: + type: string + example: "Flat b" + postcode: + type: string + example: "NW1 6XE" + city: + type: string + example: "London" + country: + type: string + example: "GB" + language: + type: string + example: "en" + responses: + CreatePaymentResponse: + content: + application/json: + description: | + Successful payment response + + Docs: [https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/#example-response-for-39-create-a-payment-39](https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/#example-response-for-39-create-a-payment-39) + schema: + type: object + properties: + amount: + type: integer + format: int32 + example: 14500 + description: + type: string + example: "Pay your council tax." + reference: + type: string + example: "12345" + language: + type: string + example: "en" + state: + type: object + properties: + status: + type: string + example: "created" + finished: + type: boolean + example: false + payment_id: + type: string + example: "hu20sqlact5260q2nanm0q8u93" + payment_provider: + type: string + example: "stripe" + created_date: + type: string + format: date-time + example: "2022-03-25T13:11:29.019Z" + refund_summary: + type: object + properties: + status: + type: string + example: "pending" + amount_available: + type: integer + format: int32 + example: 14500 + amount_submitted: + type: integer + format: int32 + example: 0 + settlement_summary: + type: object + properties: {} + delayed_capture: + type: boolean + example: false + moto: + type: boolean + example: false + return_url: + type: string + format: uri + example: "https://your.service.gov.uk/completed" + _links: + type: object + properties: + self: + type: object + properties: + href: + type: string + format: uri + example: "https://publicapi.payments.service.gov.uk/v1/payments/hu20sqlact5260q2nanm0q8u93" + method: + type: string + example: "GET" + next_url: + type: object + properties: + href: + type: string + format: uri + example: "https://www.payments.service.gov.uk/secure/ef1b6ff1-db34-4c62-b854-3ed4ba3c4049" + method: + type: string + example: "GET" + next_url_post: + type: object + properties: + type: + type: string + example: "application/x-www-form-urlencoded" + params: + type: object + properties: + chargeTokenId: + type: string + example: "ef1b6ff1-db34-4c62-b854-3ed4ba3c4049" + href: + type: string + format: uri + example: "https://www.payments.service.gov.uk/secure" + method: + type: string + example: "POST" + events: + type: object + properties: + href: + type: string + format: uri + example: "https://publicapi.payments.service.gov.uk/v1/payments/hu20sqlact5260q2nanm0q8u93/events" + method: + type: string + example: "GET" + refunds: + type: object + properties: + href: + type: string + format: uri + example: "https://publicapi.payments.service.gov.uk/v1/payments/hu20sqlact5260q2nanm0q8u93/refunds" + method: + type: string + example: "GET" + cancel: + type: object + properties: + href: + type: string + format: uri + example: "https://publicapi.payments.service.gov.uk/v1/payments/hu20sqlact5260q2nanm0q8u93/cancel" + method: + type: string + example: "POST" + FetchPaymentResponse: + content: + application/json: + description: | + Payment response for the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#example-response-for-get-information-about-a-single-payment](https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#example-response-for-get-information-about-a-single-payment) + schema: + type: object + properties: + amount: + type: integer + format: int32 + example: 3750 + state: + type: object + properties: + status: + type: string + example: "success" + finished: + type: boolean + example: true + payment_id: + type: string + example: "hu20sqlact5260q2nanm0q8u93" + _links: + type: object + properties: + next_url: + type: object + properties: + href: + type: string + format: uri + example: "https://www.payments.service.gov.uk/secure/ef1b6ff1-db34-4c62-b854-3ed4ba3c4049" + method: + type: string + example: "GET" +paths: + /pay/{localAuthority}: + post: + summary: Initiate a new payment + description: | + Initiate a GovPay payment via proxy. This will return a GovPay URL which we can forward the user to in order for them to make a payment. + + Docs: [https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/](https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/) + tags: + - pay + parameters: + - $ref: "#/components/parameters/localAuthority" + requestBody: + $ref: "#/components/schemas/CreatePaymentRequest" + responses: + "200": + $ref: "#/components/responses/CreatePaymentResponse" + /pay/{localAuthority}/{paymentId}: + get: + summary: Get payment status + description: | + Get the status of a payment via a proxied request to the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#get-information-about-a-single-payment-api-reference](https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#get-information-about-a-single-payment-api-reference) + tags: + - pay + parameters: + - $ref: "#/components/parameters/localAuthority" + - $ref: "#/components/parameters/paymentId" + responses: + "200": + $ref: "#/components/responses/FetchPaymentResponse" + /payment-request/{paymentRequest}/pay: + post: + summary: Create a request for an "invite to pay" payment + description: | + Create a payment request via a proxied request to the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/](https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/) + tags: + - pay + parameters: + - $ref: "#/components/parameters/localAuthority" + - $ref: "#/components/parameters/paymentRequest" + requestBody: + $ref: "#/components/schemas/CreatePaymentRequest" + responses: + "200": + $ref: "#/components/responses/CreatePaymentResponse" + /payment-request/{paymentRequest}/payment/{paymentId}: + get: + summary: Get status for an "invite to pay" payment + description: | + Get the status of a payment via a proxied request to the GovPay API + + Docs: [https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#get-information-about-a-single-payment-api-reference](https://docs.payments.service.gov.uk/api_reference/single_payment_reference/#get-information-about-a-single-payment-api-reference) + tags: + - pay + parameters: + - $ref: "#/components/parameters/paymentRequest" + - $ref: "#/components/parameters/paymentId" + responses: + "200": + $ref: "#/components/responses/FetchPaymentResponse" + /invite-to-pay/{sessionId}: + post: + summary: Generate a payment request + tags: + - pay + parameters: + - $ref: "#/components/parameters/sessionId" + requestBody: + $ref: "#/components/schemas/InviteToPayRequest" + responses: + "200": + $ref: "#/components/responses/CreatePaymentResponse" diff --git a/api.planx.uk/pay/index.test.ts b/api.planx.uk/modules/pay/index.test.ts similarity index 97% rename from api.planx.uk/pay/index.test.ts rename to api.planx.uk/modules/pay/index.test.ts index 4c24e51eef..ab4a129d37 100644 --- a/api.planx.uk/pay/index.test.ts +++ b/api.planx.uk/modules/pay/index.test.ts @@ -1,8 +1,8 @@ import nock from "nock"; -import { queryMock } from "../tests/graphqlQueryMock"; +import { queryMock } from "../../tests/graphqlQueryMock"; import supertest from "supertest"; -import app from "../server"; +import app from "../../server"; jest.mock("@opensystemslab/planx-core", () => { return { diff --git a/api.planx.uk/inviteToPay/paymentRequest.ts b/api.planx.uk/modules/pay/middleware.ts similarity index 50% rename from api.planx.uk/inviteToPay/paymentRequest.ts rename to api.planx.uk/modules/pay/middleware.ts index bc09f7ff34..f0a205899a 100644 --- a/api.planx.uk/inviteToPay/paymentRequest.ts +++ b/api.planx.uk/modules/pay/middleware.ts @@ -1,20 +1,25 @@ +import { NextFunction, Request, RequestHandler, Response } from "express"; import { gql } from "graphql-request"; -import { NextFunction, Request, Response } from "express"; -import { ServerError } from "../errors"; -import { - postPaymentNotificationToSlack, - fetchPaymentViaProxyWithCallback, -} from "../pay"; -import { GovUKPayment } from "@opensystemslab/planx-core/types"; -import { $api } from "../client"; +import { $api } from "../../client"; +import { ServerError } from "../../errors"; + +/** + * Confirm that this local authority (aka team) has a pay token + * TODO: Check this against a DB value instead of env vars? + */ +export const isTeamUsingGovPay: RequestHandler = (req, _res, next) => { + const isSupported = + process.env[`GOV_UK_PAY_TOKEN_${req.params.localAuthority.toUpperCase()}`]; + + if (!isSupported) { + return next({ + status: 400, + message: `GOV.UK Pay is not enabled for this local authority (${req.params.localAuthority})`, + }); + } -// https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/#json-body-parameters-for-39-create-a-payment-39 -interface GovPayCreatePayment { - amount: number; - reference: string; - description: string; - return_url: string; -} + next(); +}; interface GetPaymentRequestDetails { paymentRequest: { @@ -31,9 +36,6 @@ interface GetPaymentRequestDetails { } | null; } -// middleware used by routes: -// * /payment-request/:paymentRequest/pay -// * /payment-request/:paymentRequest/payment/:paymentId export async function fetchPaymentRequestDetails( req: Request, _res: Response, @@ -82,7 +84,14 @@ export async function fetchPaymentRequestDetails( next(); } -// middleware used by /payment-request/:paymentRequest/pay?returnURL=... +// https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/#json-body-parameters-for-39-create-a-payment-39 +interface GovPayCreatePayment { + amount: number; + reference: string; + description: string; + return_url: string; +} + export async function buildPaymentPayload( req: Request, _res: Response, @@ -117,68 +126,3 @@ export async function buildPaymentPayload( next(); } - -export const fetchPaymentRequestViaProxy = fetchPaymentViaProxyWithCallback( - async (req: Request, govUkResponse: GovUKPayment) => { - const paymentRequestId = req.params.paymentRequest; - if (paymentRequestId && govUkResponse?.state.status === "success") { - await markPaymentRequestAsPaid(paymentRequestId, govUkResponse); - } - await postPaymentNotificationToSlack(req, govUkResponse, "(invite to pay)"); - }, -); - -interface MarkPaymentRequestAsPaid { - updatePaymentRequestPaidAt: { - affectedRows: number; - }; - appendGovUKPaymentToSessionData: { - affectedRows: number; - }; -} - -export const markPaymentRequestAsPaid = async ( - paymentRequestId: string, - govUkPayment: GovUKPayment, -) => { - const query = gql` - mutation MarkPaymentRequestAsPaid( - $paymentRequestId: uuid! - $govUkPayment: jsonb - ) { - updatePaymentRequestPaidAt: update_payment_requests( - where: { - _and: { id: { _eq: $paymentRequestId }, paid_at: { _is_null: true } } - } - _set: { paid_at: "now()" } - ) { - affectedRows: affected_rows - } - - # This will also overwrite any abandoned payments attempted on the session - appendGovUKPaymentToSessionData: update_lowcal_sessions( - _append: { data: $govUkPayment } - where: { payment_requests: { id: { _eq: $paymentRequestId } } } - ) { - affectedRows: affected_rows - } - } - `; - try { - const { updatePaymentRequestPaidAt, appendGovUKPaymentToSessionData } = - await $api.client.request(query, { - paymentRequestId, - govUkPayment: { govUkPayment }, - }); - if (!updatePaymentRequestPaidAt?.affectedRows) { - throw Error(`payment request ${paymentRequestId} not updated`); - } - if (!appendGovUKPaymentToSessionData?.affectedRows) { - throw Error( - `session for payment request ${paymentRequestId} not updated`, - ); - } - } catch (error) { - throw Error("Error marking payment request as paid: " + error); - } -}; diff --git a/api.planx.uk/pay/proxy.ts b/api.planx.uk/modules/pay/proxy.ts similarity index 91% rename from api.planx.uk/pay/proxy.ts rename to api.planx.uk/modules/pay/proxy.ts index e946101872..66d9ee47a5 100644 --- a/api.planx.uk/pay/proxy.ts +++ b/api.planx.uk/modules/pay/proxy.ts @@ -1,6 +1,6 @@ import { Request } from "express"; import { fixRequestBody, Options } from "http-proxy-middleware"; -import { useProxy } from "../shared/middleware/proxy"; +import { useProxy } from "../../shared/middleware/proxy"; export const usePayProxy = (options: Partial, req: Request) => { return useProxy({ diff --git a/api.planx.uk/modules/pay/routes.ts b/api.planx.uk/modules/pay/routes.ts new file mode 100644 index 0000000000..928f3b764e --- /dev/null +++ b/api.planx.uk/modules/pay/routes.ts @@ -0,0 +1,60 @@ +import { Router } from "express"; +import { + fetchPaymentViaProxy, + inviteToPay, + makeInviteToPayPaymentViaProxy, + makePaymentViaProxy, +} from "./controller"; +import { + buildPaymentPayload, + fetchPaymentRequestDetails, + isTeamUsingGovPay, +} from "./middleware"; +import { + inviteToPaySchema, + paymentProxySchema, + paymentRequestProxySchema, +} from "./types"; +import { validate } from "../../shared/middleware/validate"; +import { fetchPaymentRequestViaProxy } from "./service/inviteToPay"; + +const router = Router(); + +router.post( + "/pay/:localAuthority", + isTeamUsingGovPay, + validate(paymentProxySchema), + makePaymentViaProxy, +); + +router.get( + "/pay/:localAuthority/:paymentId", + isTeamUsingGovPay, + validate(paymentProxySchema), + fetchPaymentViaProxy, +); + +router.post( + "/payment-request/:paymentRequest/pay", + fetchPaymentRequestDetails, + buildPaymentPayload, + isTeamUsingGovPay, + validate(paymentRequestProxySchema), + makeInviteToPayPaymentViaProxy, +); + +router.get( + "/payment-request/:paymentRequest/payment/:paymentId", + fetchPaymentRequestDetails, + isTeamUsingGovPay, + validate(paymentProxySchema), + fetchPaymentRequestViaProxy, +); + +router.post( + "/invite-to-pay/:sessionId", + validate(inviteToPaySchema), + inviteToPay, +); + +export default router; diff --git a/api.planx.uk/inviteToPay/createPaymentSendEvents.test.ts b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.test.ts similarity index 92% rename from api.planx.uk/inviteToPay/createPaymentSendEvents.test.ts rename to api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.test.ts index 4855cb0b36..09fc3cf2fc 100644 --- a/api.planx.uk/inviteToPay/createPaymentSendEvents.test.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.test.ts @@ -1,10 +1,10 @@ import supertest from "supertest"; -import app from "../server"; -import { createScheduledEvent } from "../lib/hasura/metadata"; -import { queryMock } from "../tests/graphqlQueryMock"; -import { flowWithInviteToPay } from "../tests/mocks/inviteToPayData"; +import app from "../../../../server"; +import { createScheduledEvent } from "../../../../lib/hasura/metadata"; +import { queryMock } from "../../../../tests/graphqlQueryMock"; +import { flowWithInviteToPay } from "../../../../tests/mocks/inviteToPayData"; -jest.mock("../lib/hasura/metadata"); +jest.mock("../../../../lib/hasura/metadata"); const mockedCreateScheduledEvent = createScheduledEvent as jest.MockedFunction< typeof createScheduledEvent >; diff --git a/api.planx.uk/inviteToPay/createPaymentSendEvents.ts b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts similarity index 95% rename from api.planx.uk/inviteToPay/createPaymentSendEvents.ts rename to api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts index 35ac8e3787..618fa1de43 100644 --- a/api.planx.uk/inviteToPay/createPaymentSendEvents.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts @@ -1,13 +1,13 @@ import { ComponentType } from "@opensystemslab/planx-core/types"; import { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; -import { $api, $public } from "../client"; +import { $api, $public } from "../../../../client"; import { ScheduledEventResponse, createScheduledEvent, -} from "../lib/hasura/metadata"; -import { getMostRecentPublishedFlow } from "../helpers"; -import { Flow, Node, Team } from "../types"; +} from "../../../../lib/hasura/metadata"; +import { getMostRecentPublishedFlow } from "../../../../helpers"; +import { Flow, Node, Team } from "../../../../types"; enum Destination { BOPS = "bops", diff --git a/api.planx.uk/inviteToPay/inviteToPay.test.ts b/api.planx.uk/modules/pay/service/inviteToPay/index.test.ts similarity index 85% rename from api.planx.uk/inviteToPay/inviteToPay.test.ts rename to api.planx.uk/modules/pay/service/inviteToPay/index.test.ts index e7d245d867..1dac53c222 100644 --- a/api.planx.uk/inviteToPay/inviteToPay.test.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/index.test.ts @@ -1,6 +1,6 @@ import supertest from "supertest"; -import { queryMock } from "../tests/graphqlQueryMock"; -import app from "../server"; +import { queryMock } from "../../../../tests/graphqlQueryMock"; +import app from "../../../../server"; import { validSessionQueryMock, notFoundQueryMock, @@ -10,14 +10,14 @@ import { unlockSessionQueryMock, getPublishedFlowDataQueryMock, createPaymentRequestQueryMock, -} from "../tests/mocks/inviteToPayMocks"; +} from "../../../../tests/mocks/inviteToPayMocks"; import { payee, applicant, validSession, notFoundSession, paymentRequestResponse, -} from "../tests/mocks/inviteToPayData"; +} from "../../../../tests/mocks/inviteToPayData"; describe("Invite to pay API route", () => { const inviteToPayBaseRoute = "/invite-to-pay"; @@ -69,10 +69,8 @@ describe("Invite to pay API route", () => { .send(invalidPostBody) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - "JSON body must contain payeeName", - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); }); @@ -83,10 +81,8 @@ describe("Invite to pay API route", () => { .send(invalidPostBody) .expect(400) .then((response) => { - expect(response.body).toHaveProperty( - "error", - "JSON body must contain payeeEmail", - ); + expect(response.body).toHaveProperty("issues"); + expect(response.body).toHaveProperty("name", "ZodError"); }); }); diff --git a/api.planx.uk/inviteToPay/index.ts b/api.planx.uk/modules/pay/service/inviteToPay/index.ts similarity index 78% rename from api.planx.uk/inviteToPay/index.ts rename to api.planx.uk/modules/pay/service/inviteToPay/index.ts index b0d1494bd3..f7b095ca4d 100644 --- a/api.planx.uk/inviteToPay/index.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/index.ts @@ -1,4 +1,3 @@ -export * from "./inviteToPay"; export * from "./paymentRequest"; export * from "./sendPaymentEmail"; export * from "./sendConfirmationEmail"; diff --git a/api.planx.uk/modules/pay/service/inviteToPay/paymentRequest.ts b/api.planx.uk/modules/pay/service/inviteToPay/paymentRequest.ts new file mode 100644 index 0000000000..e69278d87e --- /dev/null +++ b/api.planx.uk/modules/pay/service/inviteToPay/paymentRequest.ts @@ -0,0 +1,71 @@ +import { gql } from "graphql-request"; +import { Request } from "express"; +import { fetchPaymentViaProxyWithCallback } from "../../controller"; +import { GovUKPayment } from "@opensystemslab/planx-core/types"; +import { $api } from "../../../../client"; +import { postPaymentNotificationToSlack } from "../utils"; + +export const fetchPaymentRequestViaProxy = fetchPaymentViaProxyWithCallback( + async (req: Request, govUkResponse: GovUKPayment) => { + const paymentRequestId = req.params.paymentRequest; + if (paymentRequestId && govUkResponse?.state.status === "success") { + await markPaymentRequestAsPaid(paymentRequestId, govUkResponse); + } + await postPaymentNotificationToSlack(req, govUkResponse, "(invite to pay)"); + }, +); + +interface MarkPaymentRequestAsPaid { + updatePaymentRequestPaidAt: { + affectedRows: number; + }; + appendGovUKPaymentToSessionData: { + affectedRows: number; + }; +} + +export const markPaymentRequestAsPaid = async ( + paymentRequestId: string, + govUkPayment: GovUKPayment, +) => { + const query = gql` + mutation MarkPaymentRequestAsPaid( + $paymentRequestId: uuid! + $govUkPayment: jsonb + ) { + updatePaymentRequestPaidAt: update_payment_requests( + where: { + _and: { id: { _eq: $paymentRequestId }, paid_at: { _is_null: true } } + } + _set: { paid_at: "now()" } + ) { + affectedRows: affected_rows + } + + # This will also overwrite any abandoned payments attempted on the session + appendGovUKPaymentToSessionData: update_lowcal_sessions( + _append: { data: $govUkPayment } + where: { payment_requests: { id: { _eq: $paymentRequestId } } } + ) { + affectedRows: affected_rows + } + } + `; + try { + const { updatePaymentRequestPaidAt, appendGovUKPaymentToSessionData } = + await $api.client.request(query, { + paymentRequestId, + govUkPayment: { govUkPayment }, + }); + if (!updatePaymentRequestPaidAt?.affectedRows) { + throw Error(`payment request ${paymentRequestId} not updated`); + } + if (!appendGovUKPaymentToSessionData?.affectedRows) { + throw Error( + `session for payment request ${paymentRequestId} not updated`, + ); + } + } catch (error) { + throw Error("Error marking payment request as paid: " + error); + } +}; diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.test.ts similarity index 94% rename from api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts rename to api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.test.ts index 9e4126062b..b564744cfb 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.test.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.test.ts @@ -1,10 +1,10 @@ import supertest from "supertest"; -import app from "../server"; -import { queryMock } from "../tests/graphqlQueryMock"; +import app from "../../../../server"; +import { queryMock } from "../../../../tests/graphqlQueryMock"; import { sendAgentAndPayeeConfirmationEmail } from "./sendConfirmationEmail"; -import { sendEmail } from "../lib/notify"; +import { sendEmail } from "../../../../lib/notify"; -jest.mock("../lib/notify", () => ({ +jest.mock("../../../../lib/notify", () => ({ sendEmail: jest.fn(), })); diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.ts similarity index 92% rename from api.planx.uk/inviteToPay/sendConfirmationEmail.ts rename to api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.ts index 368f766731..fb69bad444 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendConfirmationEmail.ts @@ -1,8 +1,8 @@ -import { $public, $api } from "../client"; -import { sendEmail } from "../lib/notify"; +import { $public, $api } from "../../../../client"; +import { sendEmail } from "../../../../lib/notify"; import { gql } from "graphql-request"; -import { convertSlugToName } from "../modules/saveAndReturn/service/utils"; -import type { AgentAndPayeeSubmissionNotifyConfig } from "../types"; +import { convertSlugToName } from "../../../saveAndReturn/service/utils"; +import type { AgentAndPayeeSubmissionNotifyConfig } from "../../../../types"; export async function sendAgentAndPayeeConfirmationEmail(sessionId: string) { const { personalisation, applicantEmail, payeeEmail, projectTypes } = diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts similarity index 96% rename from api.planx.uk/inviteToPay/sendPaymentEmail.test.ts rename to api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts index 4c4012e9f4..cc2c593d67 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts @@ -1,11 +1,11 @@ import supertest from "supertest"; -import app from "../server"; -import { queryMock } from "../tests/graphqlQueryMock"; +import app from "../../../../server"; +import { queryMock } from "../../../../tests/graphqlQueryMock"; import { validatePaymentRequestNotFoundQueryMock, validatePaymentRequestQueryMock, -} from "../tests/mocks/inviteToPayMocks"; +} from "../../../../tests/mocks/inviteToPayMocks"; import { CoreDomainClient } from "@opensystemslab/planx-core"; jest diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts similarity index 93% rename from api.planx.uk/inviteToPay/sendPaymentEmail.ts rename to api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts index cf2f33fd96..c23a806896 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts @@ -3,12 +3,16 @@ import { calculateExpiryDate, convertSlugToName, getServiceLink, -} from "../modules/saveAndReturn/service/utils"; -import { Template, getClientForTemplate, sendEmail } from "../lib/notify"; -import { InviteToPayNotifyConfig } from "../types"; -import { Team } from "../types"; +} from "../../../saveAndReturn/service/utils"; +import { + Template, + getClientForTemplate, + sendEmail, +} from "../../../../lib/notify"; +import { InviteToPayNotifyConfig } from "../../../../types"; +import { Team } from "../../../../types"; import type { PaymentRequest } from "@opensystemslab/planx-core/types"; -import { $public } from "../client"; +import { $public } from "../../../../client"; interface SessionDetails { email: string; diff --git a/api.planx.uk/modules/pay/service/utils.ts b/api.planx.uk/modules/pay/service/utils.ts new file mode 100644 index 0000000000..d5057fd48c --- /dev/null +++ b/api.planx.uk/modules/pay/service/utils.ts @@ -0,0 +1,53 @@ +import { GovUKPayment } from "@opensystemslab/planx-core/types"; +import { $api } from "../../../client"; +import { gql } from "graphql-request"; + +import SlackNotify from "slack-notify"; +import { Request } from "express"; + +export const addGovPayPaymentIdToPaymentRequest = async ( + paymentRequestId: string, + govUKPayment: GovUKPayment, +): Promise => { + const query = gql` + mutation AddGovPayPaymentIdToPaymentRequest( + $paymentRequestId: uuid! + $govPayPaymentId: String + ) { + update_payment_requests_by_pk( + pk_columns: { id: $paymentRequestId } + _set: { govpay_payment_id: $govPayPaymentId } + ) { + id + } + } + `; + try { + await $api.client.request(query, { + paymentRequestId, + govPayPaymentId: govUKPayment.payment_id, + }); + } catch (error) { + throw Error(`payment request ${paymentRequestId} not updated`); + } +}; + +export async function postPaymentNotificationToSlack( + req: Request, + govUkResponse: GovUKPayment, + label = "", +) { + // if it's a prod payment, notify #planx-notifications so we can monitor for subsequent submissions + if (govUkResponse?.payment_provider !== "sandbox") { + const slack = SlackNotify(process.env.SLACK_WEBHOOK_URL!); + const getStatus = (state: GovUKPayment["state"]) => + state.status + (state.message ? ` (${state.message})` : ""); + const payMessage = `:coin: New GOV Pay payment ${label} *${ + govUkResponse.payment_id + }* with status *${getStatus(govUkResponse.state)}* [${ + req.params.localAuthority + }]`; + await slack.send(payMessage); + console.log("Payment notification posted to Slack"); + } +} diff --git a/api.planx.uk/modules/pay/types.ts b/api.planx.uk/modules/pay/types.ts new file mode 100644 index 0000000000..4c16ea8c8b --- /dev/null +++ b/api.planx.uk/modules/pay/types.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { ValidatedRequestHandler } from "../../shared/middleware/validate"; +import { PaymentRequest } from "@opensystemslab/planx-core/types"; + +export const paymentProxySchema = z.object({ + query: z.object({ + flowId: z.string().uuid(), + sessionId: z.string().uuid(), + }), + params: z.object({ + localAuthority: z.string(), + }), +}); + +export type PaymentProxyController = ValidatedRequestHandler< + typeof paymentProxySchema, + Buffer +>; + +export const inviteToPaySchema = z.object({ + body: z.object({ + payeeEmail: z.string().email(), + payeeName: z.string(), + applicantName: z.string(), + sessionPreviewKeys: z.array(z.array(z.string())), + }), + params: z.object({ + sessionId: z.string().uuid(), + }), +}); + +export type InviteToPayController = ValidatedRequestHandler< + typeof inviteToPaySchema, + PaymentRequest +>; + +export const paymentRequestProxySchema = z.object({ + query: z.object({ + flowId: z.string().uuid(), + sessionId: z.string().uuid(), + }), + params: z.object({ + paymentRequest: z.string(), + localAuthority: z.string(), + }), +}); + +export type PaymentRequestProxyController = ValidatedRequestHandler< + typeof paymentRequestProxySchema, + Buffer +>; diff --git a/api.planx.uk/modules/sendEmail/controller.ts b/api.planx.uk/modules/sendEmail/controller.ts index a91ae0bfdf..0f3570c02f 100644 --- a/api.planx.uk/modules/sendEmail/controller.ts +++ b/api.planx.uk/modules/sendEmail/controller.ts @@ -1,7 +1,7 @@ import { sendSinglePaymentEmail, sendAgentAndPayeeConfirmationEmail, -} from "../../inviteToPay"; +} from "../pay/service/inviteToPay"; import { sendSingleApplicationEmail } from "../saveAndReturn/service/utils"; import { ServerError } from "../../errors"; import { NextFunction } from "express"; diff --git a/api.planx.uk/modules/webhooks/routes.ts b/api.planx.uk/modules/webhooks/routes.ts index dc7c1bbbcd..233817d1c4 100644 --- a/api.planx.uk/modules/webhooks/routes.ts +++ b/api.planx.uk/modules/webhooks/routes.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { useHasuraAuth } from "../auth/middleware"; -import { createPaymentSendEvents } from "../../inviteToPay/createPaymentSendEvents"; +import { createPaymentSendEvents } from "../pay/service/inviteToPay/createPaymentSendEvents"; import { validate } from "../../shared/middleware/validate"; import { createPaymentExpiryEventsController, diff --git a/api.planx.uk/pay/utils.ts b/api.planx.uk/pay/utils.ts deleted file mode 100644 index f731abf085..0000000000 --- a/api.planx.uk/pay/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { GovUKPayment } from "@opensystemslab/planx-core/types"; -import { $api } from "../client"; -import { gql } from "graphql-request"; - -export const addGovPayPaymentIdToPaymentRequest = async ( - paymentRequestId: string, - govUKPayment: GovUKPayment, -): Promise => { - const query = gql` - mutation AddGovPayPaymentIdToPaymentRequest( - $paymentRequestId: uuid! - $govPayPaymentId: String - ) { - update_payment_requests_by_pk( - pk_columns: { id: $paymentRequestId } - _set: { govpay_payment_id: $govPayPaymentId } - ) { - id - } - } - `; - try { - await $api.client.request(query, { - paymentRequestId, - govPayPaymentId: govUKPayment.payment_id, - }); - } catch (error) { - throw Error(`payment request ${paymentRequestId} not updated`); - } -}; diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 75a50208f5..3f7b65f7a5 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -4,7 +4,6 @@ import assert from "assert"; import cookieParser from "cookie-parser"; import cookieSession from "cookie-session"; import cors from "cors"; -import { stringify } from "csv-stringify"; import express, { ErrorRequestHandler } from "express"; import noir from "pino-noir"; import pinoLogger from "express-pino-logger"; @@ -13,17 +12,6 @@ import passport from "passport"; import helmet from "helmet"; import { ServerError } from "./errors"; -import { - makePaymentViaProxy, - fetchPaymentViaProxy, - makeInviteToPayPaymentViaProxy, -} from "./pay"; -import { - inviteToPay, - fetchPaymentRequestDetails, - buildPaymentPayload, - fetchPaymentRequestViaProxy, -} from "./inviteToPay"; import { useHasuraAuth } from "./modules/auth/middleware"; import airbrake from "./airbrake"; @@ -46,6 +34,7 @@ import saveAndReturnRoutes from "./modules/saveAndReturn/routes"; import sendEmailRoutes from "./modules/sendEmail/routes"; import fileRoutes from "./modules/file/routes"; import gisRoutes from "./modules/gis/routes"; +import payRoutes from "./modules/pay/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; @@ -116,25 +105,6 @@ app.get("/download-application-files/:sessionId", downloadApplicationFiles); assert(process.env[`GOV_UK_PAY_TOKEN_${authority}`]); }); -// used by startNewPayment() in @planx/components/Pay/Public/Pay.tsx -app.post("/pay/:localAuthority", makePaymentViaProxy); - -// used by refetchPayment() in @planx/components/Pay/Public/Pay.tsx -app.get("/pay/:localAuthority/:paymentId", fetchPaymentViaProxy); - -app.post( - "/payment-request/:paymentRequest/pay", - fetchPaymentRequestDetails, - buildPaymentPayload, - makeInviteToPayPaymentViaProxy, -); - -app.get( - "/payment-request/:paymentRequest/payment/:paymentId", - fetchPaymentRequestDetails, - fetchPaymentRequestViaProxy, -); - // needed for storing original URL to redirect to in login flow app.use( cookieSession({ @@ -170,28 +140,7 @@ app.use(saveAndReturnRoutes); app.use(sendEmailRoutes); app.use("/flows", flowRoutes); app.use(gisRoutes); - -// allows an applicant to download their application data on the Confirmation page -app.post("/download-application", async (req, res, next) => { - if (!req.body) { - res.send({ - message: "Missing application `data` to download", - }); - } - - try { - // build a CSV and stream the response - stringify(req.body, { - columns: ["question", "responses", "metadata"], - header: true, - }).pipe(res); - res.header("Content-type", "text/csv"); - } catch (err) { - next(err); - } -}); - -app.post("/invite-to-pay/:sessionId", inviteToPay); +app.use(payRoutes); const errorHandler: ErrorRequestHandler = (errorObject, _req, res, _next) => { const { status = 500, message = "Something went wrong" } = (() => {