@defra/forms-engine-plugin 4.0.43 → 4.0.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.public/stylesheets/application.min.css +1 -1
- package/.public/stylesheets/application.min.css.map +1 -1
- package/.server/client/stylesheets/_payment-field.scss +8 -0
- package/.server/client/stylesheets/application.scss +2 -0
- package/.server/index.js +3 -1
- package/.server/index.js.map +1 -1
- package/.server/server/constants.d.ts +1 -0
- package/.server/server/constants.js +1 -0
- package/.server/server/constants.js.map +1 -1
- package/.server/server/forms/payment-test.yaml +42 -0
- package/.server/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
- package/.server/server/plugins/engine/components/FormComponent.d.ts +1 -0
- package/.server/server/plugins/engine/components/FormComponent.js +1 -0
- package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
- package/.server/server/plugins/engine/components/PaymentField.d.ts +135 -0
- package/.server/server/plugins/engine/components/PaymentField.js +228 -0
- package/.server/server/plugins/engine/components/PaymentField.js.map +1 -0
- package/.server/server/plugins/engine/components/PaymentField.types.d.ts +21 -0
- package/.server/server/plugins/engine/components/PaymentField.types.js +2 -0
- package/.server/server/plugins/engine/components/PaymentField.types.js.map +1 -0
- package/.server/server/plugins/engine/components/UkAddressField.d.ts +1 -1
- package/.server/server/plugins/engine/components/UkAddressField.js +3 -1
- package/.server/server/plugins/engine/components/UkAddressField.js.map +1 -1
- package/.server/server/plugins/engine/components/helpers/components.d.ts +1 -1
- package/.server/server/plugins/engine/components/helpers/components.js +3 -0
- package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
- package/.server/server/plugins/engine/components/index.d.ts +1 -0
- package/.server/server/plugins/engine/components/index.js +1 -0
- package/.server/server/plugins/engine/components/index.js.map +1 -1
- package/.server/server/plugins/engine/helpers.d.ts +1 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +3 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.js +7 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
- package/.server/server/plugins/engine/outputFormatters/human/v1.js +34 -1
- package/.server/server/plugins/engine/outputFormatters/human/v1.js.map +1 -1
- package/.server/server/plugins/engine/outputFormatters/machine/v2.d.ts +22 -0
- package/.server/server/plugins/engine/outputFormatters/machine/v2.js +43 -1
- package/.server/server/plugins/engine/outputFormatters/machine/v2.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +29 -8
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -0
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +17 -0
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +173 -51
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/errors.d.ts +31 -0
- package/.server/server/plugins/engine/pageControllers/errors.js +59 -2
- package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/helpers/submission.d.ts +27 -0
- package/.server/server/plugins/engine/pageControllers/helpers/submission.js +77 -0
- package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -0
- package/.server/server/plugins/engine/plugin.js +4 -1
- package/.server/server/plugins/engine/plugin.js.map +1 -1
- package/.server/server/plugins/engine/routes/index.js +8 -4
- package/.server/server/plugins/engine/routes/index.js.map +1 -1
- package/.server/server/plugins/engine/routes/payment-helper.d.ts +14 -0
- package/.server/server/plugins/engine/routes/payment-helper.js +41 -0
- package/.server/server/plugins/engine/routes/payment-helper.js.map +1 -0
- package/.server/server/plugins/engine/routes/payment-helper.test.js +81 -0
- package/.server/server/plugins/engine/routes/payment-helper.test.js.map +1 -0
- package/.server/server/plugins/engine/routes/payment.d.ts +8 -0
- package/.server/server/plugins/engine/routes/payment.js +140 -0
- package/.server/server/plugins/engine/routes/payment.js.map +1 -0
- package/.server/server/plugins/engine/routes/payment.test.js +187 -0
- package/.server/server/plugins/engine/routes/payment.test.js.map +1 -0
- package/.server/server/plugins/engine/services/localFormsService.js +6 -0
- package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
- package/.server/server/plugins/engine/types/schema.js +7 -0
- package/.server/server/plugins/engine/types/schema.js.map +1 -1
- package/.server/server/plugins/engine/types.d.ts +19 -1
- package/.server/server/plugins/engine/types.js +4 -0
- package/.server/server/plugins/engine/types.js.map +1 -1
- package/.server/server/plugins/engine/validationHelpers.d.ts +1 -1
- package/.server/server/plugins/engine/validationHelpers.js.map +1 -1
- package/.server/server/plugins/engine/views/components/paymentfield.html +42 -0
- package/.server/server/plugins/engine/views/index.html +9 -1
- package/.server/server/plugins/engine/views/partials/form.html +20 -5
- package/.server/server/plugins/engine/views/summary.html +17 -1
- package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
- package/.server/server/plugins/payment/helper.d.ts +30 -0
- package/.server/server/plugins/payment/helper.js +49 -0
- package/.server/server/plugins/payment/helper.js.map +1 -0
- package/.server/server/plugins/payment/helper.test.js +37 -0
- package/.server/server/plugins/payment/helper.test.js.map +1 -0
- package/.server/server/plugins/payment/service.d.ts +40 -0
- package/.server/server/plugins/payment/service.js +129 -0
- package/.server/server/plugins/payment/service.js.map +1 -0
- package/.server/server/plugins/payment/service.test.js +162 -0
- package/.server/server/plugins/payment/service.test.js.map +1 -0
- package/.server/server/plugins/payment/types.d.ts +172 -0
- package/.server/server/plugins/payment/types.js +78 -0
- package/.server/server/plugins/payment/types.js.map +1 -0
- package/.server/server/types.d.ts +2 -0
- package/.server/server/types.js.map +1 -1
- package/.server/typings/hapi/index.d.js.map +1 -1
- package/README.md +12 -9
- package/package.json +2 -2
- package/src/client/stylesheets/_payment-field.scss +8 -0
- package/src/client/stylesheets/application.scss +2 -0
- package/src/index.ts +5 -1
- package/src/server/constants.js +1 -0
- package/src/server/forms/payment-test.yaml +42 -0
- package/src/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
- package/src/server/plugins/engine/components/FormComponent.ts +1 -0
- package/src/server/plugins/engine/components/PaymentField.test.ts +611 -0
- package/src/server/plugins/engine/components/PaymentField.ts +367 -0
- package/src/server/plugins/engine/components/PaymentField.types.ts +21 -0
- package/src/server/plugins/engine/components/UkAddressField.ts +2 -1
- package/src/server/plugins/engine/components/helpers/components.ts +5 -0
- package/src/server/plugins/engine/components/index.ts +1 -0
- package/src/server/plugins/engine/models/SummaryViewModel.ts +8 -0
- package/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts +147 -0
- package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +105 -103
- package/src/server/plugins/engine/outputFormatters/human/v1.ts +61 -2
- package/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts +115 -0
- package/src/server/plugins/engine/outputFormatters/machine/v2.ts +60 -1
- package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +32 -6
- package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +247 -72
- package/src/server/plugins/engine/pageControllers/errors.test.ts +13 -1
- package/src/server/plugins/engine/pageControllers/errors.ts +79 -4
- package/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +299 -0
- package/src/server/plugins/engine/pageControllers/helpers/submission.ts +110 -0
- package/src/server/plugins/engine/plugin.ts +11 -6
- package/src/server/plugins/engine/routes/index.ts +17 -16
- package/src/server/plugins/engine/routes/payment-helper.js +39 -0
- package/src/server/plugins/engine/routes/payment-helper.test.js +90 -0
- package/src/server/plugins/engine/routes/payment.js +151 -0
- package/src/server/plugins/engine/routes/payment.test.js +180 -0
- package/src/server/plugins/engine/services/localFormsService.js +7 -0
- package/src/server/plugins/engine/types/schema.ts +9 -0
- package/src/server/plugins/engine/types.ts +24 -1
- package/src/server/plugins/engine/validationHelpers.ts +1 -1
- package/src/server/plugins/engine/views/components/paymentfield.html +42 -0
- package/src/server/plugins/engine/views/index.html +9 -1
- package/src/server/plugins/engine/views/partials/form.html +20 -5
- package/src/server/plugins/engine/views/summary.html +17 -1
- package/src/server/plugins/payment/helper.js +56 -0
- package/src/server/plugins/payment/helper.test.js +52 -0
- package/src/server/plugins/payment/service.js +171 -0
- package/src/server/plugins/payment/service.test.js +205 -0
- package/src/server/plugins/payment/types.js +77 -0
- package/src/server/types.ts +2 -0
- package/src/typings/hapi/index.d.ts +1 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import Boom from '@hapi/boom'
|
|
2
|
+
import { StatusCodes } from 'http-status-codes'
|
|
3
|
+
import Joi from 'joi'
|
|
4
|
+
|
|
5
|
+
import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js'
|
|
6
|
+
import { getPaymentContext } from '~/src/server/plugins/engine/routes/payment-helper.js'
|
|
7
|
+
|
|
8
|
+
export const PAYMENT_RETURN_PATH = '/payment-callback'
|
|
9
|
+
export const PAYMENT_SESSION_PREFIX = 'payment-'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Flash form component state after successful payment
|
|
13
|
+
* @param {Request} request - the request
|
|
14
|
+
* @param {PaymentSessionData} session - the session data containing payment state
|
|
15
|
+
* @param {GetPaymentResponse} paymentStatus - the payment status response from GOV.UK Pay
|
|
16
|
+
*/
|
|
17
|
+
function flashComponentState(request, session, paymentStatus) {
|
|
18
|
+
/** @type {PaymentState} */
|
|
19
|
+
const paymentState = {
|
|
20
|
+
paymentId: paymentStatus.paymentId,
|
|
21
|
+
reference: session.reference,
|
|
22
|
+
amount: session.amount,
|
|
23
|
+
description: session.description,
|
|
24
|
+
uuid: session.uuid,
|
|
25
|
+
formId: session.formId,
|
|
26
|
+
isLivePayment: session.isLivePayment,
|
|
27
|
+
payerEmail: paymentStatus.email,
|
|
28
|
+
preAuth: {
|
|
29
|
+
status: 'success',
|
|
30
|
+
createdAt: new Date().toISOString()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @type {ExternalStateAppendage} */
|
|
35
|
+
const appendage = {
|
|
36
|
+
component: session.componentName,
|
|
37
|
+
data: /** @type {FormState} */ (/** @type {unknown} */ (paymentState))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
request.yar.flash(EXTERNAL_STATE_APPENDAGE, appendage, true)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Gets the payment routes for handling GOV.UK Pay callbacks
|
|
45
|
+
* @returns {ServerRoute[]}
|
|
46
|
+
*/
|
|
47
|
+
export function getRoutes() {
|
|
48
|
+
return [getReturnRoute()]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handles successful payment states (capturable/success)
|
|
53
|
+
* @param {Request} request - the request
|
|
54
|
+
* @param {ResponseToolkit} h - the response toolkit
|
|
55
|
+
* @param {PaymentSessionData} session - the session data
|
|
56
|
+
* @param {string} sessionKey - the session key
|
|
57
|
+
* @param {GetPaymentResponse} paymentStatus - the payment status from GOV.UK Pay
|
|
58
|
+
*/
|
|
59
|
+
function handlePaymentSuccess(request, h, session, sessionKey, paymentStatus) {
|
|
60
|
+
flashComponentState(request, session, paymentStatus)
|
|
61
|
+
request.yar.clear(sessionKey)
|
|
62
|
+
return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Handles failed/cancelled/error payment states
|
|
67
|
+
* @param {Request} request - the request
|
|
68
|
+
* @param {ResponseToolkit} h - the response toolkit
|
|
69
|
+
* @param {PaymentSessionData} session - the session data
|
|
70
|
+
* @param {string} sessionKey - the session key
|
|
71
|
+
*/
|
|
72
|
+
function handlePaymentFailure(request, h, session, sessionKey) {
|
|
73
|
+
request.yar.clear(sessionKey)
|
|
74
|
+
return h.redirect(session.failureUrl).code(StatusCodes.SEE_OTHER)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Route handler for payment return URL
|
|
79
|
+
* This is called when GOV.UK Pay redirects the user back after payment
|
|
80
|
+
* @returns {ServerRoute}
|
|
81
|
+
*/
|
|
82
|
+
function getReturnRoute() {
|
|
83
|
+
return {
|
|
84
|
+
method: 'GET',
|
|
85
|
+
path: PAYMENT_RETURN_PATH,
|
|
86
|
+
async handler(request, h) {
|
|
87
|
+
const { uuid } = /** @type {{ uuid: string }} */ (request.query)
|
|
88
|
+
const { session, sessionKey, paymentStatus } = await getPaymentContext(
|
|
89
|
+
request,
|
|
90
|
+
uuid
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle
|
|
95
|
+
*/
|
|
96
|
+
const { status } = paymentStatus.state
|
|
97
|
+
|
|
98
|
+
switch (status) {
|
|
99
|
+
case 'capturable':
|
|
100
|
+
case 'success':
|
|
101
|
+
return handlePaymentSuccess(
|
|
102
|
+
request,
|
|
103
|
+
h,
|
|
104
|
+
session,
|
|
105
|
+
sessionKey,
|
|
106
|
+
paymentStatus
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
case 'cancelled':
|
|
110
|
+
case 'failed':
|
|
111
|
+
case 'error':
|
|
112
|
+
return handlePaymentFailure(request, h, session, sessionKey)
|
|
113
|
+
|
|
114
|
+
case 'created':
|
|
115
|
+
case 'started':
|
|
116
|
+
case 'submitted': {
|
|
117
|
+
const nextUrl = paymentStatus._links.next_url?.href
|
|
118
|
+
|
|
119
|
+
if (!nextUrl) {
|
|
120
|
+
throw Boom.badRequest(
|
|
121
|
+
`Payment in state '${status}' but no next_url available`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return h.redirect(nextUrl).code(StatusCodes.SEE_OTHER)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
default: {
|
|
129
|
+
const unknownStatus = /** @type {string} */ (status)
|
|
130
|
+
throw Boom.internal(`Unknown payment status: ${unknownStatus}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
options: {
|
|
135
|
+
validate: {
|
|
136
|
+
query: Joi.object()
|
|
137
|
+
.keys({
|
|
138
|
+
uuid: Joi.string().uuid().required()
|
|
139
|
+
})
|
|
140
|
+
.required()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @import { Request, ResponseToolkit, ServerRoute } from '@hapi/hapi'
|
|
148
|
+
* @import { GetPaymentResponse, PaymentSessionData } from '~/src/server/plugins/payment/types.js'
|
|
149
|
+
* @import { PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
|
|
150
|
+
* @import { ExternalStateAppendage, FormState } from '~/src/server/plugins/engine/types.js'
|
|
151
|
+
*/
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { StatusCodes } from 'http-status-codes'
|
|
2
|
+
|
|
3
|
+
import { createServer } from '~/src/server/index.js'
|
|
4
|
+
import { getPaymentContext } from '~/src/server/plugins/engine/routes/payment-helper.js'
|
|
5
|
+
import { renderResponse } from '~/test/helpers/component-helpers.js'
|
|
6
|
+
|
|
7
|
+
jest.mock('~/src/server/plugins/engine/routes/payment-helper.js')
|
|
8
|
+
|
|
9
|
+
describe('Payment routes', () => {
|
|
10
|
+
/** @type {Server} */
|
|
11
|
+
let server
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
server = await createServer()
|
|
15
|
+
await server.initialize()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.resetAllMocks()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('Return route /payment-callback', () => {
|
|
23
|
+
const uuid = '06a5b11e-e3e0-48a2-8ac3-56c0fcb6c20d'
|
|
24
|
+
const options = {
|
|
25
|
+
method: 'get',
|
|
26
|
+
url: `/payment-callback?uuid=${uuid}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const paymentSessionData = {
|
|
30
|
+
uuid,
|
|
31
|
+
formId: 'form-id',
|
|
32
|
+
reference: 'form-ref-123',
|
|
33
|
+
paymentId: 'payment-id',
|
|
34
|
+
amount: 123,
|
|
35
|
+
description: 'Payment desc',
|
|
36
|
+
isLivePayment: false,
|
|
37
|
+
componentName: 'my-component',
|
|
38
|
+
returnUrl: 'http://host.com/return-url',
|
|
39
|
+
failureUrl: 'http://host.com/failure-url'
|
|
40
|
+
}
|
|
41
|
+
const sessionKey = 'session-key'
|
|
42
|
+
|
|
43
|
+
test.each([
|
|
44
|
+
{ status: 'capturable', finalUrl: 'http://host.com/return-url' },
|
|
45
|
+
{ status: 'success', finalUrl: 'http://host.com/return-url' },
|
|
46
|
+
{ status: 'cancelled', finalUrl: 'http://host.com/failure-url' },
|
|
47
|
+
{ status: 'failed', finalUrl: 'http://host.com/failure-url' },
|
|
48
|
+
{ status: 'error', finalUrl: 'http://host.com/failure-url' },
|
|
49
|
+
{ status: 'created', finalUrl: '/next-url' },
|
|
50
|
+
{ status: 'started', finalUrl: '/next-url' },
|
|
51
|
+
{ status: 'submitted', finalUrl: '/next-url' }
|
|
52
|
+
])('should handle payment status of $row.status', async (row) => {
|
|
53
|
+
const paymentStatus = {
|
|
54
|
+
paymentId: 'new-payment-id',
|
|
55
|
+
amount: 125,
|
|
56
|
+
_links: {
|
|
57
|
+
next_url: {
|
|
58
|
+
href: '/next-url',
|
|
59
|
+
method: 'get'
|
|
60
|
+
},
|
|
61
|
+
self: {
|
|
62
|
+
href: '/self',
|
|
63
|
+
method: 'get'
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
state: /** @type {PaymentResponseState} */ ({
|
|
67
|
+
status: row.status,
|
|
68
|
+
finished: true
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
jest.mocked(getPaymentContext).mockResolvedValueOnce({
|
|
72
|
+
session: paymentSessionData,
|
|
73
|
+
sessionKey,
|
|
74
|
+
paymentStatus
|
|
75
|
+
})
|
|
76
|
+
const { response } = await renderResponse(server, options)
|
|
77
|
+
|
|
78
|
+
expect(response.statusCode).toBe(StatusCodes.SEE_OTHER)
|
|
79
|
+
expect(response.headers.location).toBe(row.finalUrl)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should throw if nextUrl is missing', async () => {
|
|
83
|
+
const paymentStatus = {
|
|
84
|
+
paymentId: 'new-payment-id',
|
|
85
|
+
_links: {
|
|
86
|
+
next_url: {},
|
|
87
|
+
self: {
|
|
88
|
+
href: '/self',
|
|
89
|
+
method: 'get'
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
state: /** @type {PaymentResponseState} */ ({
|
|
93
|
+
status: 'created',
|
|
94
|
+
finished: true
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
jest.mocked(getPaymentContext).mockResolvedValueOnce({
|
|
98
|
+
session: paymentSessionData,
|
|
99
|
+
sessionKey,
|
|
100
|
+
// @ts-expect-error - deliberate missing element from object
|
|
101
|
+
paymentStatus
|
|
102
|
+
})
|
|
103
|
+
const { response } = await renderResponse(server, options)
|
|
104
|
+
|
|
105
|
+
expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST)
|
|
106
|
+
// @ts-expect-error - error object
|
|
107
|
+
expect(response.result?.message).toBe(
|
|
108
|
+
"Payment in state 'created' but no next_url available"
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should throw if invalid status', async () => {
|
|
113
|
+
const paymentStatus = {
|
|
114
|
+
paymentId: 'new-payment-id',
|
|
115
|
+
_links: {
|
|
116
|
+
next_url: {
|
|
117
|
+
href: '/next-url',
|
|
118
|
+
method: 'get'
|
|
119
|
+
},
|
|
120
|
+
self: {
|
|
121
|
+
href: '/self',
|
|
122
|
+
method: 'get'
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
state: {
|
|
126
|
+
status: 'invalid',
|
|
127
|
+
finished: true
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
jest.mocked(getPaymentContext).mockResolvedValueOnce({
|
|
131
|
+
session: paymentSessionData,
|
|
132
|
+
sessionKey,
|
|
133
|
+
// @ts-expect-error - deliberate invalid value which doesnt meet type
|
|
134
|
+
paymentStatus
|
|
135
|
+
})
|
|
136
|
+
const { response } = await renderResponse(server, options)
|
|
137
|
+
|
|
138
|
+
expect(response.statusCode).toBe(StatusCodes.INTERNAL_SERVER_ERROR)
|
|
139
|
+
// @ts-expect-error - error object
|
|
140
|
+
expect(response.result?.message).toBe('Unknown payment status: invalid')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should handle payment with email from GOV.UK Pay response', async () => {
|
|
144
|
+
const paymentStatus = {
|
|
145
|
+
paymentId: 'new-payment-id',
|
|
146
|
+
payment_id: 'new-payment-id',
|
|
147
|
+
amount: 125,
|
|
148
|
+
email: 'payer@example.com',
|
|
149
|
+
_links: {
|
|
150
|
+
next_url: {
|
|
151
|
+
href: '/next-url',
|
|
152
|
+
method: 'get'
|
|
153
|
+
},
|
|
154
|
+
self: {
|
|
155
|
+
href: '/self',
|
|
156
|
+
method: 'get'
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
state: /** @type {PaymentResponseState} */ ({
|
|
160
|
+
status: 'success',
|
|
161
|
+
finished: true
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
jest.mocked(getPaymentContext).mockResolvedValueOnce({
|
|
165
|
+
session: paymentSessionData,
|
|
166
|
+
sessionKey,
|
|
167
|
+
paymentStatus
|
|
168
|
+
})
|
|
169
|
+
const { response } = await renderResponse(server, options)
|
|
170
|
+
|
|
171
|
+
expect(response.statusCode).toBe(StatusCodes.SEE_OTHER)
|
|
172
|
+
expect(response.headers.location).toBe('http://host.com/return-url')
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @import { Server } from '@hapi/hapi'
|
|
179
|
+
* @import { PaymentResponseState } from '~/src/server/plugins/payment/types.js'
|
|
180
|
+
*/
|
|
@@ -58,5 +58,12 @@ export const formsService = async () => {
|
|
|
58
58
|
slug: 'simple-form'
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
+
await loader.addForm('src/server/forms/payment-test.yaml', {
|
|
62
|
+
...metadata,
|
|
63
|
+
id: 'b2c3d4e5-f6a7-8901-bcde-f01234567890',
|
|
64
|
+
title: 'Payment Test Form',
|
|
65
|
+
slug: 'payment-test'
|
|
66
|
+
})
|
|
67
|
+
|
|
61
68
|
return loader.toFormsService()
|
|
62
69
|
}
|
|
@@ -43,6 +43,15 @@ export const formAdapterSubmissionMessageDataSchema =
|
|
|
43
43
|
Joi.object<FormAdapterSubmissionMessageData>().keys({
|
|
44
44
|
main: Joi.object(),
|
|
45
45
|
repeaters: Joi.object(),
|
|
46
|
+
payment: Joi.object()
|
|
47
|
+
.keys({
|
|
48
|
+
paymentId: Joi.string().required(),
|
|
49
|
+
reference: Joi.string().required(),
|
|
50
|
+
amount: Joi.number().required(),
|
|
51
|
+
description: Joi.string().required(),
|
|
52
|
+
createdAt: Joi.string().required()
|
|
53
|
+
})
|
|
54
|
+
.optional(),
|
|
46
55
|
files: Joi.object().pattern(
|
|
47
56
|
Joi.string(),
|
|
48
57
|
Joi.array().items(
|
|
@@ -17,7 +17,10 @@ import { type JoiExpression, type ValidationErrorItem } from 'joi'
|
|
|
17
17
|
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
|
|
18
18
|
import { type UkAddressState } from '~/src/server/plugins/engine/components/UkAddressField.js'
|
|
19
19
|
import { type Component } from '~/src/server/plugins/engine/components/helpers/components.js'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
type FileUploadField,
|
|
22
|
+
type PaymentField
|
|
23
|
+
} from '~/src/server/plugins/engine/components/index.js'
|
|
21
24
|
import {
|
|
22
25
|
type BackLink,
|
|
23
26
|
type ComponentText,
|
|
@@ -329,6 +332,8 @@ export interface FormPageViewModel extends PageViewModelBase {
|
|
|
329
332
|
errors?: FormSubmissionError[]
|
|
330
333
|
hasMissingNotificationEmail?: boolean
|
|
331
334
|
allowSaveAndExit: boolean
|
|
335
|
+
showSubmitButton?: boolean
|
|
336
|
+
showPaymentExpiredNotification?: boolean
|
|
332
337
|
}
|
|
333
338
|
|
|
334
339
|
export interface RepeaterSummaryPageViewModel extends PageViewModelBase {
|
|
@@ -393,6 +398,8 @@ export interface ExternalArgs {
|
|
|
393
398
|
controller: QuestionPageController
|
|
394
399
|
sourceUrl: string
|
|
395
400
|
actionArgs: Record<string, string>
|
|
401
|
+
isLive: boolean
|
|
402
|
+
isPreview: boolean
|
|
396
403
|
}
|
|
397
404
|
|
|
398
405
|
export interface PostcodeLookupExternalArgs extends ExternalArgs {
|
|
@@ -454,6 +461,14 @@ export interface FormAdapterFile {
|
|
|
454
461
|
userDownloadLink: string
|
|
455
462
|
}
|
|
456
463
|
|
|
464
|
+
export interface FormAdapterPayment {
|
|
465
|
+
paymentId: string
|
|
466
|
+
reference: string
|
|
467
|
+
amount: number
|
|
468
|
+
description: string
|
|
469
|
+
createdAt: string
|
|
470
|
+
}
|
|
471
|
+
|
|
457
472
|
export interface FormAdapterSubmissionMessageResult {
|
|
458
473
|
files: {
|
|
459
474
|
main: string
|
|
@@ -467,6 +482,13 @@ export interface FormAdapterSubmissionMessageResult {
|
|
|
467
482
|
export type FileUploadFieldDetailitem = Omit<DetailItemField, 'field'> & {
|
|
468
483
|
field: FileUploadField
|
|
469
484
|
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* A detail item specifically for payments
|
|
488
|
+
*/
|
|
489
|
+
export type PaymentFieldDetailItem = Omit<DetailItemField, 'field'> & {
|
|
490
|
+
field: PaymentField
|
|
491
|
+
}
|
|
470
492
|
export type RichFormValue =
|
|
471
493
|
| FormValue
|
|
472
494
|
| FormPayload
|
|
@@ -480,6 +502,7 @@ export interface FormAdapterSubmissionMessageData {
|
|
|
480
502
|
main: Record<string, RichFormValue | null>
|
|
481
503
|
repeaters: Record<string, Record<string, RichFormValue>[]>
|
|
482
504
|
files: Record<string, FormAdapterFile[]>
|
|
505
|
+
payment?: FormAdapterPayment
|
|
483
506
|
}
|
|
484
507
|
|
|
485
508
|
export interface FormAdapterSubmissionMessagePayload {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{% from "govuk/components/warning-text/macro.njk" import govukWarningText %}
|
|
2
|
+
{% from "govuk/components/button/macro.njk" import govukButton %}
|
|
3
|
+
|
|
4
|
+
{% macro PaymentField(component) %}
|
|
5
|
+
{% set model = component.model %}
|
|
6
|
+
{% set amount = model.amount %}
|
|
7
|
+
{% set description = model.description %}
|
|
8
|
+
{% set paymentState = model.paymentState %}
|
|
9
|
+
{% set isPreAuthorised = paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %}
|
|
10
|
+
|
|
11
|
+
<div class="app-payment-field">
|
|
12
|
+
{% if isPreAuthorised %}
|
|
13
|
+
{# Payment already pre-authorised - show confirmation message #}
|
|
14
|
+
<h2 class="govuk-heading-m">You have already authorised a payment for this form</h2>
|
|
15
|
+
|
|
16
|
+
<p class="govuk-body">Continue to submit the form. You will not be charged twice.</p>
|
|
17
|
+
{% else %}
|
|
18
|
+
{# No pre-authorisation - show payment form #}
|
|
19
|
+
<h2 class="govuk-heading-m">{{ model.label.text if model.label and model.label.text else "Payment details required" }}</h2>
|
|
20
|
+
|
|
21
|
+
<p class="govuk-body">{{ description }}</p>
|
|
22
|
+
|
|
23
|
+
{{ govukWarningText({
|
|
24
|
+
text: "You may see a pending transaction in your bank account but you will only be charged when you submit the form.",
|
|
25
|
+
iconFallbackText: "Warning"
|
|
26
|
+
}) }}
|
|
27
|
+
|
|
28
|
+
<p class="govuk-body">You can submit the form after you have added your payment details.</p>
|
|
29
|
+
|
|
30
|
+
<p class="govuk-body govuk-!-margin-bottom-1">Total amount:</p>
|
|
31
|
+
<p class="govuk-heading-l govuk-!-margin-bottom-4 govuk-!-padding-top-0">£{{ amount }}</p>
|
|
32
|
+
|
|
33
|
+
{{ govukButton({
|
|
34
|
+
text: "Add payment details",
|
|
35
|
+
attributes: {
|
|
36
|
+
name: "action",
|
|
37
|
+
value: "external-" + model.name
|
|
38
|
+
}
|
|
39
|
+
}) }}
|
|
40
|
+
{% endif %}
|
|
41
|
+
</div>
|
|
42
|
+
{% endmacro %}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{% extends baseLayoutPath %}
|
|
2
2
|
|
|
3
3
|
{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
|
|
4
|
+
{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %}
|
|
4
5
|
{% from "partials/components.html" import componentList with context %}
|
|
5
6
|
|
|
6
7
|
{% block content %}
|
|
@@ -10,7 +11,14 @@
|
|
|
10
11
|
{% include "partials/preview-banner.html" %}
|
|
11
12
|
{% endif %}
|
|
12
13
|
|
|
13
|
-
{% if
|
|
14
|
+
{% if showPaymentExpiredNotification %}
|
|
15
|
+
{{ govukNotificationBanner({
|
|
16
|
+
titleText: "Important",
|
|
17
|
+
html: '<h3 class="govuk-notification-banner__heading">Your payment has been cancelled</h3><p class="govuk-body">Your payment details were deleted because the form was inactive for 5 days.</p><p class="govuk-body">Add your payment details again.</p>'
|
|
18
|
+
}) }}
|
|
19
|
+
{% endif %}
|
|
20
|
+
|
|
21
|
+
{% if errors | length and not showPaymentExpiredNotification %}
|
|
14
22
|
{{ govukErrorSummary({
|
|
15
23
|
titleText: "There is a problem",
|
|
16
24
|
errorList: checkErrorTemplates(errors)
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{% from "govuk/components/button/macro.njk" import govukButton %}
|
|
2
2
|
{% from "govuk/components/summary-list/macro.njk" import govukSummaryList -%}
|
|
3
3
|
|
|
4
|
+
{% set noPaymentFields = true %}
|
|
5
|
+
{% set hasIncompletePayment = false %}
|
|
6
|
+
|
|
7
|
+
{% for comp in components %}
|
|
8
|
+
{% if comp.type == 'PaymentField' %}
|
|
9
|
+
{% set noPaymentFields = false %}
|
|
10
|
+
{# Check if payment is incomplete (no preAuth status) #}
|
|
11
|
+
{% if not comp.model.paymentState or not comp.model.paymentState.preAuth or comp.model.paymentState.preAuth.status != 'success' %}
|
|
12
|
+
{% set hasIncompletePayment = true %}
|
|
13
|
+
{% endif %}
|
|
14
|
+
{% endif %}
|
|
15
|
+
{% endfor %}
|
|
16
|
+
|
|
4
17
|
<form method="post" novalidate>
|
|
5
18
|
<input type="hidden" name="crumb" value="{{ crumb }}">
|
|
6
19
|
|
|
@@ -15,11 +28,13 @@
|
|
|
15
28
|
{% endif %}
|
|
16
29
|
|
|
17
30
|
<div class="govuk-button-group">
|
|
18
|
-
{
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
{% if showSubmitButton !== false and not hasIncompletePayment %}
|
|
32
|
+
{{ govukButton({
|
|
33
|
+
text: buttonText,
|
|
34
|
+
isStartButton: isStartPage,
|
|
35
|
+
preventDoubleClick: true
|
|
36
|
+
}) }}
|
|
37
|
+
{% endif %}
|
|
23
38
|
|
|
24
39
|
{% if allowSaveAndExit %}
|
|
25
40
|
{{ govukButton({
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
|
|
4
4
|
{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %}
|
|
5
5
|
{% from "govuk/components/button/macro.njk" import govukButton %}
|
|
6
|
+
{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %}
|
|
6
7
|
{% from "partials/components.html" import componentList with context %}
|
|
7
8
|
{% from "govuk/components/input/macro.njk" import govukInput %}
|
|
8
9
|
|
|
@@ -13,6 +14,14 @@
|
|
|
13
14
|
{% include "partials/preview-banner.html" %}
|
|
14
15
|
{% endif %}
|
|
15
16
|
|
|
17
|
+
{% if paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %}
|
|
18
|
+
{{ govukNotificationBanner({
|
|
19
|
+
type: "success",
|
|
20
|
+
titleText: "Success",
|
|
21
|
+
html: "<h3 class=\"govuk-notification-banner__heading\">We have your payment details</h3><p class=\"govuk-body\">Your payment is on hold. We will charge you when you submit the form.</p>"
|
|
22
|
+
}) }}
|
|
23
|
+
{% endif %}
|
|
24
|
+
|
|
16
25
|
{% if errors %}
|
|
17
26
|
{{ govukErrorSummary({
|
|
18
27
|
titleText: "There is a problem",
|
|
@@ -41,6 +50,13 @@
|
|
|
41
50
|
{% endif %}
|
|
42
51
|
{% endfor %}
|
|
43
52
|
|
|
53
|
+
{% if paymentDetails %}
|
|
54
|
+
<h2 class="govuk-heading-m">
|
|
55
|
+
{{ paymentDetails.title.text }}
|
|
56
|
+
</h2>
|
|
57
|
+
{{ govukSummaryList(paymentDetails.summaryList) }}
|
|
58
|
+
{% endif %}
|
|
59
|
+
|
|
44
60
|
<form method="post" novalidate>
|
|
45
61
|
<input type="hidden" name="crumb" value="{{ crumb }}">
|
|
46
62
|
|
|
@@ -59,7 +75,7 @@
|
|
|
59
75
|
{% set isDeclaration = declaration or components | length %}
|
|
60
76
|
|
|
61
77
|
{{ govukButton({
|
|
62
|
-
text: "Accept and
|
|
78
|
+
text: "Accept and submit" if isDeclaration else "Submit",
|
|
63
79
|
name: "action",
|
|
64
80
|
value: "send",
|
|
65
81
|
preventDoubleClick: true
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { format } from 'date-fns'
|
|
2
|
+
|
|
3
|
+
import { PaymentService } from '~/src/server/plugins/payment/service.js'
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_PAYMENT_HELP_URL =
|
|
6
|
+
'https://www.gov.uk/government/organisations/department-for-environment-food-rural-affairs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Determine which payment API key value to use.
|
|
10
|
+
* If a draft preview form or a live preview form, read the TEST API key value specific to that form.
|
|
11
|
+
* If a live (non-preview) form, read the LIVE API key value specific to that form.
|
|
12
|
+
* @param {boolean} isLivePayment - true if this is a live payment (as opposed to a test one)
|
|
13
|
+
* @param {string} formId - id of the form
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function getPaymentApiKey(isLivePayment, formId) {
|
|
17
|
+
const apiKeyValue = isLivePayment
|
|
18
|
+
? process.env[`PAYMENT_PROVIDER_API_KEY_LIVE_${formId}`]
|
|
19
|
+
: process.env[`PAYMENT_PROVIDER_API_KEY_TEST_${formId}`]
|
|
20
|
+
|
|
21
|
+
if (!apiKeyValue) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Missing payment api key for ${isLivePayment ? 'live' : 'test'} form id ${formId}`
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return apiKeyValue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a PaymentService instance with the appropriate API key
|
|
31
|
+
* @param {boolean} isLivePayment - true if this is a live payment
|
|
32
|
+
* @param {string} formId - id of the form
|
|
33
|
+
* @returns {PaymentService}
|
|
34
|
+
*/
|
|
35
|
+
export function createPaymentService(isLivePayment, formId) {
|
|
36
|
+
const apiKey = getPaymentApiKey(isLivePayment, formId)
|
|
37
|
+
return new PaymentService(apiKey)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Formats a payment date for display
|
|
42
|
+
* @param {string} isoString - ISO date string
|
|
43
|
+
* @returns {string} Formatted date string (e.g., "26 January 2026 5:01pm")
|
|
44
|
+
*/
|
|
45
|
+
export function formatPaymentDate(isoString) {
|
|
46
|
+
return format(new Date(isoString), 'd MMMM yyyy h:mmaaa')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Formats a payment amount with two decimal places
|
|
51
|
+
* @param {number} amount - amount in pounds
|
|
52
|
+
* @returns {string} Formatted amount (e.g., "£10.00")
|
|
53
|
+
*/
|
|
54
|
+
export function formatPaymentAmount(amount) {
|
|
55
|
+
return `£${amount.toFixed(2)}`
|
|
56
|
+
}
|