@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.
Files changed (169) hide show
  1. package/.public/javascripts/shared.min.js +1 -1
  2. package/.public/javascripts/shared.min.js.map +1 -1
  3. package/.public/stylesheets/application.min.css +1 -1
  4. package/.public/stylesheets/application.min.css.map +1 -1
  5. package/.server/client/javascripts/location-map.js +8 -4
  6. package/.server/client/javascripts/location-map.js.map +1 -1
  7. package/.server/client/stylesheets/_payment-field.scss +8 -0
  8. package/.server/client/stylesheets/application.scss +2 -0
  9. package/.server/index.js +3 -1
  10. package/.server/index.js.map +1 -1
  11. package/.server/server/constants.d.ts +1 -0
  12. package/.server/server/constants.js +1 -0
  13. package/.server/server/constants.js.map +1 -1
  14. package/.server/server/forms/payment-test.yaml +42 -0
  15. package/.server/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
  16. package/.server/server/plugins/engine/components/FormComponent.d.ts +1 -0
  17. package/.server/server/plugins/engine/components/FormComponent.js +1 -0
  18. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  19. package/.server/server/plugins/engine/components/PaymentField.d.ts +135 -0
  20. package/.server/server/plugins/engine/components/PaymentField.js +228 -0
  21. package/.server/server/plugins/engine/components/PaymentField.js.map +1 -0
  22. package/.server/server/plugins/engine/components/PaymentField.types.d.ts +21 -0
  23. package/.server/server/plugins/engine/components/PaymentField.types.js +2 -0
  24. package/.server/server/plugins/engine/components/PaymentField.types.js.map +1 -0
  25. package/.server/server/plugins/engine/components/UkAddressField.d.ts +1 -1
  26. package/.server/server/plugins/engine/components/UkAddressField.js +3 -1
  27. package/.server/server/plugins/engine/components/UkAddressField.js.map +1 -1
  28. package/.server/server/plugins/engine/components/helpers/components.d.ts +1 -1
  29. package/.server/server/plugins/engine/components/helpers/components.js +3 -0
  30. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  31. package/.server/server/plugins/engine/components/index.d.ts +1 -0
  32. package/.server/server/plugins/engine/components/index.js +1 -0
  33. package/.server/server/plugins/engine/components/index.js.map +1 -1
  34. package/.server/server/plugins/engine/configureEnginePlugin.d.ts +1 -1
  35. package/.server/server/plugins/engine/configureEnginePlugin.js +4 -2
  36. package/.server/server/plugins/engine/configureEnginePlugin.js.map +1 -1
  37. package/.server/server/plugins/engine/helpers.d.ts +1 -0
  38. package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +3 -0
  39. package/.server/server/plugins/engine/models/SummaryViewModel.js +7 -0
  40. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  41. package/.server/server/plugins/engine/options.js +2 -1
  42. package/.server/server/plugins/engine/options.js.map +1 -1
  43. package/.server/server/plugins/engine/outputFormatters/human/v1.js +34 -1
  44. package/.server/server/plugins/engine/outputFormatters/human/v1.js.map +1 -1
  45. package/.server/server/plugins/engine/outputFormatters/machine/v2.d.ts +22 -0
  46. package/.server/server/plugins/engine/outputFormatters/machine/v2.js +43 -1
  47. package/.server/server/plugins/engine/outputFormatters/machine/v2.js.map +1 -1
  48. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +29 -8
  49. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  50. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -0
  51. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +17 -0
  52. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +173 -51
  53. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  54. package/.server/server/plugins/engine/pageControllers/errors.d.ts +31 -0
  55. package/.server/server/plugins/engine/pageControllers/errors.js +59 -2
  56. package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -1
  57. package/.server/server/plugins/engine/pageControllers/helpers/submission.d.ts +27 -0
  58. package/.server/server/plugins/engine/pageControllers/helpers/submission.js +77 -0
  59. package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -0
  60. package/.server/server/plugins/engine/plugin.js +10 -5
  61. package/.server/server/plugins/engine/plugin.js.map +1 -1
  62. package/.server/server/plugins/engine/routes/index.js +8 -4
  63. package/.server/server/plugins/engine/routes/index.js.map +1 -1
  64. package/.server/server/plugins/engine/routes/payment-helper.d.ts +14 -0
  65. package/.server/server/plugins/engine/routes/payment-helper.js +41 -0
  66. package/.server/server/plugins/engine/routes/payment-helper.js.map +1 -0
  67. package/.server/server/plugins/engine/routes/payment-helper.test.js +81 -0
  68. package/.server/server/plugins/engine/routes/payment-helper.test.js.map +1 -0
  69. package/.server/server/plugins/engine/routes/payment.d.ts +8 -0
  70. package/.server/server/plugins/engine/routes/payment.js +140 -0
  71. package/.server/server/plugins/engine/routes/payment.js.map +1 -0
  72. package/.server/server/plugins/engine/routes/payment.test.js +187 -0
  73. package/.server/server/plugins/engine/routes/payment.test.js.map +1 -0
  74. package/.server/server/plugins/engine/services/localFormsService.js +6 -0
  75. package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
  76. package/.server/server/plugins/engine/types/schema.js +7 -0
  77. package/.server/server/plugins/engine/types/schema.js.map +1 -1
  78. package/.server/server/plugins/engine/types.d.ts +20 -1
  79. package/.server/server/plugins/engine/types.js +4 -0
  80. package/.server/server/plugins/engine/types.js.map +1 -1
  81. package/.server/server/plugins/engine/validationHelpers.d.ts +1 -1
  82. package/.server/server/plugins/engine/validationHelpers.js.map +1 -1
  83. package/.server/server/plugins/engine/views/components/paymentfield.html +42 -0
  84. package/.server/server/plugins/engine/views/index.html +9 -1
  85. package/.server/server/plugins/engine/views/partials/form.html +20 -5
  86. package/.server/server/plugins/engine/views/summary.html +17 -1
  87. package/.server/server/plugins/map/routes/get-os-token.d.ts +6 -0
  88. package/.server/server/plugins/map/routes/get-os-token.js +41 -0
  89. package/.server/server/plugins/map/routes/get-os-token.js.map +1 -0
  90. package/.server/server/plugins/map/routes/get-os-token.test.js +49 -0
  91. package/.server/server/plugins/map/routes/get-os-token.test.js.map +1 -0
  92. package/.server/server/plugins/map/routes/index.d.ts +1 -11
  93. package/.server/server/plugins/map/routes/index.js +60 -16
  94. package/.server/server/plugins/map/routes/index.js.map +1 -1
  95. package/.server/server/plugins/map/types.d.ts +1 -0
  96. package/.server/server/plugins/map/types.js +1 -0
  97. package/.server/server/plugins/map/types.js.map +1 -1
  98. package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
  99. package/.server/server/plugins/payment/helper.d.ts +30 -0
  100. package/.server/server/plugins/payment/helper.js +49 -0
  101. package/.server/server/plugins/payment/helper.js.map +1 -0
  102. package/.server/server/plugins/payment/helper.test.js +37 -0
  103. package/.server/server/plugins/payment/helper.test.js.map +1 -0
  104. package/.server/server/plugins/payment/service.d.ts +40 -0
  105. package/.server/server/plugins/payment/service.js +129 -0
  106. package/.server/server/plugins/payment/service.js.map +1 -0
  107. package/.server/server/plugins/payment/service.test.js +162 -0
  108. package/.server/server/plugins/payment/service.test.js.map +1 -0
  109. package/.server/server/plugins/payment/types.d.ts +172 -0
  110. package/.server/server/plugins/payment/types.js +78 -0
  111. package/.server/server/plugins/payment/types.js.map +1 -0
  112. package/.server/server/types.d.ts +3 -0
  113. package/.server/server/types.js.map +1 -1
  114. package/.server/typings/hapi/index.d.js.map +1 -1
  115. package/README.md +12 -9
  116. package/package.json +2 -2
  117. package/src/client/javascripts/location-map.js +12 -4
  118. package/src/client/stylesheets/_payment-field.scss +8 -0
  119. package/src/client/stylesheets/application.scss +2 -0
  120. package/src/index.ts +5 -1
  121. package/src/server/constants.js +1 -0
  122. package/src/server/forms/payment-test.yaml +42 -0
  123. package/src/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
  124. package/src/server/plugins/engine/components/FormComponent.ts +1 -0
  125. package/src/server/plugins/engine/components/PaymentField.test.ts +611 -0
  126. package/src/server/plugins/engine/components/PaymentField.ts +367 -0
  127. package/src/server/plugins/engine/components/PaymentField.types.ts +21 -0
  128. package/src/server/plugins/engine/components/UkAddressField.ts +2 -1
  129. package/src/server/plugins/engine/components/helpers/components.ts +5 -0
  130. package/src/server/plugins/engine/components/index.ts +1 -0
  131. package/src/server/plugins/engine/configureEnginePlugin.ts +4 -2
  132. package/src/server/plugins/engine/models/SummaryViewModel.ts +8 -0
  133. package/src/server/plugins/engine/options.js +2 -1
  134. package/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts +147 -0
  135. package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +105 -103
  136. package/src/server/plugins/engine/outputFormatters/human/v1.ts +61 -2
  137. package/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts +115 -0
  138. package/src/server/plugins/engine/outputFormatters/machine/v2.ts +60 -1
  139. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +32 -6
  140. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +247 -72
  141. package/src/server/plugins/engine/pageControllers/errors.test.ts +13 -1
  142. package/src/server/plugins/engine/pageControllers/errors.ts +79 -4
  143. package/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +299 -0
  144. package/src/server/plugins/engine/pageControllers/helpers/submission.ts +110 -0
  145. package/src/server/plugins/engine/plugin.ts +17 -10
  146. package/src/server/plugins/engine/routes/index.ts +17 -16
  147. package/src/server/plugins/engine/routes/payment-helper.js +39 -0
  148. package/src/server/plugins/engine/routes/payment-helper.test.js +90 -0
  149. package/src/server/plugins/engine/routes/payment.js +151 -0
  150. package/src/server/plugins/engine/routes/payment.test.js +180 -0
  151. package/src/server/plugins/engine/services/localFormsService.js +7 -0
  152. package/src/server/plugins/engine/types/schema.ts +9 -0
  153. package/src/server/plugins/engine/types.ts +25 -1
  154. package/src/server/plugins/engine/validationHelpers.ts +1 -1
  155. package/src/server/plugins/engine/views/components/paymentfield.html +42 -0
  156. package/src/server/plugins/engine/views/index.html +9 -1
  157. package/src/server/plugins/engine/views/partials/form.html +20 -5
  158. package/src/server/plugins/engine/views/summary.html +17 -1
  159. package/src/server/plugins/map/routes/get-os-token.js +41 -0
  160. package/src/server/plugins/map/routes/get-os-token.test.js +55 -0
  161. package/src/server/plugins/map/routes/index.js +70 -24
  162. package/src/server/plugins/map/types.js +1 -0
  163. package/src/server/plugins/payment/helper.js +56 -0
  164. package/src/server/plugins/payment/helper.test.js +52 -0
  165. package/src/server/plugins/payment/service.js +171 -0
  166. package/src/server/plugins/payment/service.test.js +205 -0
  167. package/src/server/plugins/payment/types.js +77 -0
  168. package/src/server/types.ts +3 -0
  169. 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 { type FileUploadField } from '~/src/server/plugins/engine/components/index.js'
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 {
@@ -20,7 +20,7 @@ export interface ExternalComponent {
20
20
  request: FormRequestPayload,
21
21
  h: FormResponseToolkit,
22
22
  args: ExternalArgs
23
- ): ResponseObject
23
+ ): Promise<ResponseObject>
24
24
  }
25
25
 
26
26
  /**
@@ -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 errors | length %}
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
- {{ govukButton({
19
- text: buttonText,
20
- isStartButton: isStartPage,
21
- preventDoubleClick: true
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 send" if isDeclaration else "Send",
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
+ */