@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.
Files changed (142) hide show
  1. package/.public/stylesheets/application.min.css +1 -1
  2. package/.public/stylesheets/application.min.css.map +1 -1
  3. package/.server/client/stylesheets/_payment-field.scss +8 -0
  4. package/.server/client/stylesheets/application.scss +2 -0
  5. package/.server/index.js +3 -1
  6. package/.server/index.js.map +1 -1
  7. package/.server/server/constants.d.ts +1 -0
  8. package/.server/server/constants.js +1 -0
  9. package/.server/server/constants.js.map +1 -1
  10. package/.server/server/forms/payment-test.yaml +42 -0
  11. package/.server/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
  12. package/.server/server/plugins/engine/components/FormComponent.d.ts +1 -0
  13. package/.server/server/plugins/engine/components/FormComponent.js +1 -0
  14. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  15. package/.server/server/plugins/engine/components/PaymentField.d.ts +135 -0
  16. package/.server/server/plugins/engine/components/PaymentField.js +228 -0
  17. package/.server/server/plugins/engine/components/PaymentField.js.map +1 -0
  18. package/.server/server/plugins/engine/components/PaymentField.types.d.ts +21 -0
  19. package/.server/server/plugins/engine/components/PaymentField.types.js +2 -0
  20. package/.server/server/plugins/engine/components/PaymentField.types.js.map +1 -0
  21. package/.server/server/plugins/engine/components/UkAddressField.d.ts +1 -1
  22. package/.server/server/plugins/engine/components/UkAddressField.js +3 -1
  23. package/.server/server/plugins/engine/components/UkAddressField.js.map +1 -1
  24. package/.server/server/plugins/engine/components/helpers/components.d.ts +1 -1
  25. package/.server/server/plugins/engine/components/helpers/components.js +3 -0
  26. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  27. package/.server/server/plugins/engine/components/index.d.ts +1 -0
  28. package/.server/server/plugins/engine/components/index.js +1 -0
  29. package/.server/server/plugins/engine/components/index.js.map +1 -1
  30. package/.server/server/plugins/engine/helpers.d.ts +1 -0
  31. package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +3 -0
  32. package/.server/server/plugins/engine/models/SummaryViewModel.js +7 -0
  33. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  34. package/.server/server/plugins/engine/outputFormatters/human/v1.js +34 -1
  35. package/.server/server/plugins/engine/outputFormatters/human/v1.js.map +1 -1
  36. package/.server/server/plugins/engine/outputFormatters/machine/v2.d.ts +22 -0
  37. package/.server/server/plugins/engine/outputFormatters/machine/v2.js +43 -1
  38. package/.server/server/plugins/engine/outputFormatters/machine/v2.js.map +1 -1
  39. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +29 -8
  40. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  41. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -0
  42. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +17 -0
  43. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +173 -51
  44. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  45. package/.server/server/plugins/engine/pageControllers/errors.d.ts +31 -0
  46. package/.server/server/plugins/engine/pageControllers/errors.js +59 -2
  47. package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -1
  48. package/.server/server/plugins/engine/pageControllers/helpers/submission.d.ts +27 -0
  49. package/.server/server/plugins/engine/pageControllers/helpers/submission.js +77 -0
  50. package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -0
  51. package/.server/server/plugins/engine/plugin.js +4 -1
  52. package/.server/server/plugins/engine/plugin.js.map +1 -1
  53. package/.server/server/plugins/engine/routes/index.js +8 -4
  54. package/.server/server/plugins/engine/routes/index.js.map +1 -1
  55. package/.server/server/plugins/engine/routes/payment-helper.d.ts +14 -0
  56. package/.server/server/plugins/engine/routes/payment-helper.js +41 -0
  57. package/.server/server/plugins/engine/routes/payment-helper.js.map +1 -0
  58. package/.server/server/plugins/engine/routes/payment-helper.test.js +81 -0
  59. package/.server/server/plugins/engine/routes/payment-helper.test.js.map +1 -0
  60. package/.server/server/plugins/engine/routes/payment.d.ts +8 -0
  61. package/.server/server/plugins/engine/routes/payment.js +140 -0
  62. package/.server/server/plugins/engine/routes/payment.js.map +1 -0
  63. package/.server/server/plugins/engine/routes/payment.test.js +187 -0
  64. package/.server/server/plugins/engine/routes/payment.test.js.map +1 -0
  65. package/.server/server/plugins/engine/services/localFormsService.js +6 -0
  66. package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
  67. package/.server/server/plugins/engine/types/schema.js +7 -0
  68. package/.server/server/plugins/engine/types/schema.js.map +1 -1
  69. package/.server/server/plugins/engine/types.d.ts +19 -1
  70. package/.server/server/plugins/engine/types.js +4 -0
  71. package/.server/server/plugins/engine/types.js.map +1 -1
  72. package/.server/server/plugins/engine/validationHelpers.d.ts +1 -1
  73. package/.server/server/plugins/engine/validationHelpers.js.map +1 -1
  74. package/.server/server/plugins/engine/views/components/paymentfield.html +42 -0
  75. package/.server/server/plugins/engine/views/index.html +9 -1
  76. package/.server/server/plugins/engine/views/partials/form.html +20 -5
  77. package/.server/server/plugins/engine/views/summary.html +17 -1
  78. package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
  79. package/.server/server/plugins/payment/helper.d.ts +30 -0
  80. package/.server/server/plugins/payment/helper.js +49 -0
  81. package/.server/server/plugins/payment/helper.js.map +1 -0
  82. package/.server/server/plugins/payment/helper.test.js +37 -0
  83. package/.server/server/plugins/payment/helper.test.js.map +1 -0
  84. package/.server/server/plugins/payment/service.d.ts +40 -0
  85. package/.server/server/plugins/payment/service.js +129 -0
  86. package/.server/server/plugins/payment/service.js.map +1 -0
  87. package/.server/server/plugins/payment/service.test.js +162 -0
  88. package/.server/server/plugins/payment/service.test.js.map +1 -0
  89. package/.server/server/plugins/payment/types.d.ts +172 -0
  90. package/.server/server/plugins/payment/types.js +78 -0
  91. package/.server/server/plugins/payment/types.js.map +1 -0
  92. package/.server/server/types.d.ts +2 -0
  93. package/.server/server/types.js.map +1 -1
  94. package/.server/typings/hapi/index.d.js.map +1 -1
  95. package/README.md +12 -9
  96. package/package.json +2 -2
  97. package/src/client/stylesheets/_payment-field.scss +8 -0
  98. package/src/client/stylesheets/application.scss +2 -0
  99. package/src/index.ts +5 -1
  100. package/src/server/constants.js +1 -0
  101. package/src/server/forms/payment-test.yaml +42 -0
  102. package/src/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
  103. package/src/server/plugins/engine/components/FormComponent.ts +1 -0
  104. package/src/server/plugins/engine/components/PaymentField.test.ts +611 -0
  105. package/src/server/plugins/engine/components/PaymentField.ts +367 -0
  106. package/src/server/plugins/engine/components/PaymentField.types.ts +21 -0
  107. package/src/server/plugins/engine/components/UkAddressField.ts +2 -1
  108. package/src/server/plugins/engine/components/helpers/components.ts +5 -0
  109. package/src/server/plugins/engine/components/index.ts +1 -0
  110. package/src/server/plugins/engine/models/SummaryViewModel.ts +8 -0
  111. package/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts +147 -0
  112. package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +105 -103
  113. package/src/server/plugins/engine/outputFormatters/human/v1.ts +61 -2
  114. package/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts +115 -0
  115. package/src/server/plugins/engine/outputFormatters/machine/v2.ts +60 -1
  116. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +32 -6
  117. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +247 -72
  118. package/src/server/plugins/engine/pageControllers/errors.test.ts +13 -1
  119. package/src/server/plugins/engine/pageControllers/errors.ts +79 -4
  120. package/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +299 -0
  121. package/src/server/plugins/engine/pageControllers/helpers/submission.ts +110 -0
  122. package/src/server/plugins/engine/plugin.ts +11 -6
  123. package/src/server/plugins/engine/routes/index.ts +17 -16
  124. package/src/server/plugins/engine/routes/payment-helper.js +39 -0
  125. package/src/server/plugins/engine/routes/payment-helper.test.js +90 -0
  126. package/src/server/plugins/engine/routes/payment.js +151 -0
  127. package/src/server/plugins/engine/routes/payment.test.js +180 -0
  128. package/src/server/plugins/engine/services/localFormsService.js +7 -0
  129. package/src/server/plugins/engine/types/schema.ts +9 -0
  130. package/src/server/plugins/engine/types.ts +24 -1
  131. package/src/server/plugins/engine/validationHelpers.ts +1 -1
  132. package/src/server/plugins/engine/views/components/paymentfield.html +42 -0
  133. package/src/server/plugins/engine/views/index.html +9 -1
  134. package/src/server/plugins/engine/views/partials/form.html +20 -5
  135. package/src/server/plugins/engine/views/summary.html +17 -1
  136. package/src/server/plugins/payment/helper.js +56 -0
  137. package/src/server/plugins/payment/helper.test.js +52 -0
  138. package/src/server/plugins/payment/service.js +171 -0
  139. package/src/server/plugins/payment/service.test.js +205 -0
  140. package/src/server/plugins/payment/types.js +77 -0
  141. package/src/server/types.ts +2 -0
  142. 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 {
@@ -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 {
@@ -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,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
+ }