@defra/forms-engine-plugin 4.0.42 → 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/javascripts/shared.min.js +1 -1
- package/.public/javascripts/shared.min.js.map +1 -1
- package/.public/stylesheets/application.min.css +1 -1
- package/.public/stylesheets/application.min.css.map +1 -1
- package/.server/client/javascripts/location-map.js +8 -4
- package/.server/client/javascripts/location-map.js.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/configureEnginePlugin.d.ts +1 -1
- package/.server/server/plugins/engine/configureEnginePlugin.js +4 -2
- package/.server/server/plugins/engine/configureEnginePlugin.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/options.js +2 -1
- package/.server/server/plugins/engine/options.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 +10 -5
- 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 +20 -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/map/routes/get-os-token.d.ts +6 -0
- package/.server/server/plugins/map/routes/get-os-token.js +41 -0
- package/.server/server/plugins/map/routes/get-os-token.js.map +1 -0
- package/.server/server/plugins/map/routes/get-os-token.test.js +49 -0
- package/.server/server/plugins/map/routes/get-os-token.test.js.map +1 -0
- package/.server/server/plugins/map/routes/index.d.ts +1 -11
- package/.server/server/plugins/map/routes/index.js +60 -16
- package/.server/server/plugins/map/routes/index.js.map +1 -1
- package/.server/server/plugins/map/types.d.ts +1 -0
- package/.server/server/plugins/map/types.js +1 -0
- package/.server/server/plugins/map/types.js.map +1 -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 +3 -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/javascripts/location-map.js +12 -4
- 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/configureEnginePlugin.ts +4 -2
- package/src/server/plugins/engine/models/SummaryViewModel.ts +8 -0
- package/src/server/plugins/engine/options.js +2 -1
- 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 +17 -10
- 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 +25 -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/map/routes/get-os-token.js +41 -0
- package/src/server/plugins/map/routes/get-os-token.test.js +55 -0
- package/src/server/plugins/map/routes/index.js +70 -24
- package/src/server/plugins/map/types.js +1 -0
- 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 +3 -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 {
|
|
@@ -423,6 +430,7 @@ export interface PluginOptions {
|
|
|
423
430
|
onRequest?: OnRequestCallback
|
|
424
431
|
baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com"
|
|
425
432
|
ordnanceSurveyApiKey?: string
|
|
433
|
+
ordnanceSurveyApiSecret?: string
|
|
426
434
|
}
|
|
427
435
|
|
|
428
436
|
export interface FormAdapterSubmissionMessageMeta {
|
|
@@ -453,6 +461,14 @@ export interface FormAdapterFile {
|
|
|
453
461
|
userDownloadLink: string
|
|
454
462
|
}
|
|
455
463
|
|
|
464
|
+
export interface FormAdapterPayment {
|
|
465
|
+
paymentId: string
|
|
466
|
+
reference: string
|
|
467
|
+
amount: number
|
|
468
|
+
description: string
|
|
469
|
+
createdAt: string
|
|
470
|
+
}
|
|
471
|
+
|
|
456
472
|
export interface FormAdapterSubmissionMessageResult {
|
|
457
473
|
files: {
|
|
458
474
|
main: string
|
|
@@ -466,6 +482,13 @@ export interface FormAdapterSubmissionMessageResult {
|
|
|
466
482
|
export type FileUploadFieldDetailitem = Omit<DetailItemField, 'field'> & {
|
|
467
483
|
field: FileUploadField
|
|
468
484
|
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* A detail item specifically for payments
|
|
488
|
+
*/
|
|
489
|
+
export type PaymentFieldDetailItem = Omit<DetailItemField, 'field'> & {
|
|
490
|
+
field: PaymentField
|
|
491
|
+
}
|
|
469
492
|
export type RichFormValue =
|
|
470
493
|
| FormValue
|
|
471
494
|
| FormPayload
|
|
@@ -479,6 +502,7 @@ export interface FormAdapterSubmissionMessageData {
|
|
|
479
502
|
main: Record<string, RichFormValue | null>
|
|
480
503
|
repeaters: Record<string, Record<string, RichFormValue>[]>
|
|
481
504
|
files: Record<string, FormAdapterFile[]>
|
|
505
|
+
payment?: FormAdapterPayment
|
|
482
506
|
}
|
|
483
507
|
|
|
484
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,41 @@
|
|
|
1
|
+
import { post } from '~/src/server/services/httpService.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @type {string}
|
|
5
|
+
*/
|
|
6
|
+
let cachedToken
|
|
7
|
+
let tokenExpiry = 0
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get Ordnance Survey OAuth token
|
|
11
|
+
* @param {MapConfiguration} options - Ordnance survey map options
|
|
12
|
+
*/
|
|
13
|
+
export async function getAccessToken(options) {
|
|
14
|
+
const { ordnanceSurveyApiKey: key, ordnanceSurveyApiSecret: secret } = options
|
|
15
|
+
const now = Date.now()
|
|
16
|
+
|
|
17
|
+
if (cachedToken && now < tokenExpiry) {
|
|
18
|
+
return cachedToken
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const creds = `${key}:${secret}`
|
|
22
|
+
const result = await post('https://api.os.uk/oauth2/token/v1', {
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Basic ${btoa(creds)}`,
|
|
25
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
26
|
+
},
|
|
27
|
+
payload: 'grant_type=client_credentials',
|
|
28
|
+
json: true
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const data = result.payload
|
|
32
|
+
|
|
33
|
+
cachedToken = data.access_token
|
|
34
|
+
tokenExpiry = now + (data.expires_in - 60) * 1000 // refresh early
|
|
35
|
+
|
|
36
|
+
return cachedToken
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @import { MapConfiguration } from '~/src/server/plugins/map/types.js'
|
|
41
|
+
*/
|