@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
@@ -11,133 +11,135 @@ import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControl
11
11
  import { FormStatus } from '~/src/server/routes/types.js'
12
12
  import definition from '~/test/form/definitions/repeat-mixed.js'
13
13
 
14
- const itemId1 = 'abc-123'
15
- const itemId2 = 'xyz-987'
16
-
17
- const submitResponse = {
18
- message: 'Submit completed',
19
- result: {
20
- files: {
21
- main: '00000000-0000-0000-0000-000000000000',
22
- repeaters: {
23
- pizza: '11111111-1111-1111-1111-111111111111'
14
+ describe('v1 human formatter', () => {
15
+ const itemId1 = 'abc-123'
16
+ const itemId2 = 'xyz-987'
17
+
18
+ const submitResponse = {
19
+ message: 'Submit completed',
20
+ result: {
21
+ files: {
22
+ main: '00000000-0000-0000-0000-000000000000',
23
+ repeaters: {
24
+ pizza: '11111111-1111-1111-1111-111111111111'
25
+ }
24
26
  }
25
27
  }
26
28
  }
27
- }
28
29
 
29
- const model = new FormModel(definition, {
30
- basePath: 'test'
31
- })
30
+ const model = new FormModel(definition, {
31
+ basePath: 'test'
32
+ })
32
33
 
33
- const state = {
34
- $$__referenceNumber: 'foobar',
35
- orderType: 'delivery',
36
- pizza: [
37
- {
38
- toppings: 'Ham',
39
- quantity: 2,
40
- itemId: itemId1
41
- },
42
- {
43
- toppings: 'Pepperoni',
44
- quantity: 1,
45
- itemId: itemId2
46
- }
47
- ]
48
- }
49
-
50
- const pageDef = definition.pages[2]
51
- const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
52
-
53
- const controller = new SummaryPageController(model, pageDef)
54
-
55
- const request = buildFormContextRequest({
56
- method: 'get',
57
- url: pageUrl,
58
- path: pageUrl.pathname,
59
- params: {
60
- path: 'pizza-order',
61
- slug: 'repeat'
62
- },
63
- query: {},
64
- app: { model }
65
- })
34
+ const state = {
35
+ $$__referenceNumber: 'foobar',
36
+ orderType: 'delivery',
37
+ pizza: [
38
+ {
39
+ toppings: 'Ham',
40
+ quantity: 2,
41
+ itemId: itemId1
42
+ },
43
+ {
44
+ toppings: 'Pepperoni',
45
+ quantity: 1,
46
+ itemId: itemId2
47
+ }
48
+ ]
49
+ }
66
50
 
67
- const context = model.getFormContext(request, state)
68
- const summaryViewModel = controller.getSummaryViewModel(request, context)
51
+ const pageDef = definition.pages[2]
52
+ const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
69
53
 
70
- const items = getFormSubmissionData(
71
- summaryViewModel.context,
72
- summaryViewModel.details
73
- )
54
+ const controller = new SummaryPageController(model, pageDef)
74
55
 
75
- describe('getPersonalisation', () => {
76
- it.each([
77
- {
78
- state: FormStatus.Live,
79
- isPreview: false
56
+ const request = buildFormContextRequest({
57
+ method: 'get',
58
+ url: pageUrl,
59
+ path: pageUrl.pathname,
60
+ params: {
61
+ path: 'pizza-order',
62
+ slug: 'repeat'
80
63
  },
81
- {
82
- state: FormStatus.Draft,
83
- isPreview: true
84
- }
85
- ])('should personalise $state email', (formStatus) => {
86
- const body = format(context, items, model, submitResponse, formStatus)
87
-
88
- const dateNow = new Date()
89
- const dateExpiry = addDays(dateNow, 90)
64
+ query: {},
65
+ app: { model }
66
+ })
90
67
 
91
- // Check for link expiry message
92
- expect(body).toContain(
93
- `^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}`
94
- )
68
+ const context = model.getFormContext(request, state)
69
+ const summaryViewModel = controller.getSummaryViewModel(request, context)
70
+
71
+ const items = getFormSubmissionData(
72
+ summaryViewModel.context,
73
+ summaryViewModel.details
74
+ )
75
+
76
+ describe('getPersonalisation', () => {
77
+ it.each([
78
+ {
79
+ state: FormStatus.Live,
80
+ isPreview: false
81
+ },
82
+ {
83
+ state: FormStatus.Draft,
84
+ isPreview: true
85
+ }
86
+ ])('should personalise $state email', (formStatus) => {
87
+ const body = format(context, items, model, submitResponse, formStatus)
95
88
 
96
- expect(body).toContain(
97
- outdent`
98
- ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
89
+ const dateNow = new Date()
90
+ const dateExpiry = addDays(dateNow, 90)
99
91
 
100
- ---
92
+ // Check for link expiry message
93
+ expect(body).toContain(
94
+ `^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}`
95
+ )
101
96
 
102
- ## How would you like to receive your pizza?
97
+ expect(body).toContain(
98
+ outdent`
99
+ ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
103
100
 
104
- Delivery
101
+ ---
105
102
 
106
- ---
103
+ ## How would you like to receive your pizza?
107
104
 
108
- ## Pizza
105
+ Delivery
109
106
 
110
- [Download Pizza \\(CSV\\)](https://forms-designer/file-download/11111111-1111-1111-1111-111111111111)
107
+ ---
111
108
 
112
- ---
109
+ ## Pizza
113
110
 
114
- [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000)
115
- `
116
- )
117
- })
111
+ [Download Pizza \\(CSV\\)](https://forms-designer/file-download/11111111-1111-1111-1111-111111111111)
118
112
 
119
- it('should add test warnings to preview email only', () => {
120
- const formStatus = {
121
- state: FormStatus.Draft,
122
- isPreview: true
123
- }
113
+ ---
124
114
 
125
- const body1 = format(context, items, model, submitResponse, {
126
- state: FormStatus.Live,
127
- isPreview: false
115
+ [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000)
116
+ `
117
+ )
128
118
  })
129
119
 
130
- const body2 = format(context, items, model, submitResponse, {
131
- state: FormStatus.Draft,
132
- isPreview: true
133
- })
120
+ it('should add test warnings to preview email only', () => {
121
+ const formStatus = {
122
+ state: FormStatus.Draft,
123
+ isPreview: true
124
+ }
125
+
126
+ const body1 = format(context, items, model, submitResponse, {
127
+ state: FormStatus.Live,
128
+ isPreview: false
129
+ })
134
130
 
135
- expect(body1).not.toContain(
136
- `This is a test of the ${definition.name} ${formStatus.state} form`
137
- )
131
+ const body2 = format(context, items, model, submitResponse, {
132
+ state: FormStatus.Draft,
133
+ isPreview: true
134
+ })
138
135
 
139
- expect(body2).toContain(
140
- `This is a test of the ${definition.name} ${formStatus.state} form`
141
- )
136
+ expect(body1).not.toContain(
137
+ `This is a test of the ${definition.name} ${formStatus.state} form`
138
+ )
139
+
140
+ expect(body2).toContain(
141
+ `This is a test of the ${definition.name} ${formStatus.state} form`
142
+ )
143
+ })
142
144
  })
143
145
  })
@@ -7,10 +7,18 @@ import { addDays, format as dateFormat } from 'date-fns'
7
7
  import { config } from '~/src/config/index.js'
8
8
  import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
9
9
  import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers/index.js'
10
+ import { PaymentField } from '~/src/server/plugins/engine/components/index.js'
10
11
  import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js'
11
12
  import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
12
- import { type DetailItem } from '~/src/server/plugins/engine/models/types.js'
13
+ import {
14
+ type DetailItem,
15
+ type DetailItemField
16
+ } from '~/src/server/plugins/engine/models/types.js'
13
17
  import { type FormContext } from '~/src/server/plugins/engine/types.js'
18
+ import {
19
+ formatPaymentAmount,
20
+ formatPaymentDate
21
+ } from '~/src/server/plugins/payment/helper.js'
14
22
 
15
23
  const designerUrl = config.get('designerUrl')
16
24
 
@@ -49,7 +57,10 @@ export function format(
49
57
  lines.push(`${formName} form received at ${escapeMarkdown(formattedNow)}.\n`)
50
58
  lines.push('---\n')
51
59
 
52
- items.forEach((item) => {
60
+ const regularItems = items.filter((item) => !isPaymentItem(item))
61
+ const paymentItems = items.filter((item) => isPaymentItem(item))
62
+
63
+ regularItems.forEach((item) => {
53
64
  const label = escapeMarkdown(item.label)
54
65
 
55
66
  lines.push(`## ${label}\n`)
@@ -73,5 +84,53 @@ export function format(
73
84
  const filename = escapeMarkdown('Download main form (CSV)')
74
85
  lines.push(`[${filename}](${designerUrl}/file-download/${files.main})\n`)
75
86
 
87
+ appendPaymentSection(paymentItems, lines)
88
+
76
89
  return lines.join('\n')
77
90
  }
91
+
92
+ /**
93
+ * Check if an item is a PaymentField
94
+ */
95
+ function isPaymentItem(item: DetailItem): boolean {
96
+ if ('subItems' in item) {
97
+ return false
98
+ }
99
+ return item.field instanceof PaymentField
100
+ }
101
+
102
+ /**
103
+ * Appends the payment details section to the email lines if payment exists
104
+ */
105
+ function appendPaymentSection(paymentItems: DetailItem[], lines: string[]) {
106
+ if (paymentItems.length === 0) {
107
+ return
108
+ }
109
+
110
+ const paymentItem = paymentItems[0] as DetailItemField
111
+ const paymentField = paymentItem.field as PaymentField
112
+ const paymentState = paymentField.getPaymentStateFromState(paymentItem.state)
113
+
114
+ if (!paymentState) {
115
+ return
116
+ }
117
+
118
+ const formattedAmount = formatPaymentAmount(paymentState.amount)
119
+ const dateOfPayment = paymentState.preAuth?.createdAt
120
+ ? formatPaymentDate(paymentState.preAuth.createdAt)
121
+ : ''
122
+
123
+ lines.push(
124
+ '---\n',
125
+ `# Your payment of ${formattedAmount} was successful\n`,
126
+ '## Payment for\n',
127
+ `${escapeMarkdown(paymentState.description)}\n`,
128
+ '---\n',
129
+ '## Total amount\n',
130
+ `${formattedAmount}\n`,
131
+ '---\n',
132
+ '## Date of payment\n',
133
+ `${escapeMarkdown(dateOfPayment)}\n`,
134
+ '---\n'
135
+ )
136
+ }
@@ -0,0 +1,115 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
2
+
3
+ import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
4
+ import { FormModel } from '~/src/server/plugins/engine/models/index.js'
5
+ import { format } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js'
6
+ import {
7
+ SummaryPageController,
8
+ getFormSubmissionData
9
+ } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
10
+ import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
11
+ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js'
12
+ import { FormStatus } from '~/src/server/routes/types.js'
13
+ import definition from '~/test/form/definitions/payment.js'
14
+
15
+ const submitResponse = {
16
+ message: 'Submit completed',
17
+ result: {
18
+ files: {
19
+ main: '00000000-0000-0000-0000-000000000000',
20
+ repeaters: {
21
+ pizza: '11111111-1111-1111-1111-111111111111'
22
+ }
23
+ }
24
+ }
25
+ }
26
+
27
+ const model = new FormModel(definition, {
28
+ basePath: 'test'
29
+ })
30
+
31
+ const formStatus = {
32
+ isPreview: false,
33
+ state: FormStatus.Live
34
+ }
35
+
36
+ const state = {
37
+ $$__referenceNumber: 'foobar',
38
+ licenceLength: 365,
39
+ fullName: 'John Smith',
40
+ paymentField: {
41
+ paymentId: 'payment-id',
42
+ reference: 'payment-ref',
43
+ amount: 250,
44
+ description: 'Payment desc',
45
+ uuid: 'uuid',
46
+ formId: 'form-id',
47
+ isLivePayment: false,
48
+ preAuth: {
49
+ status: 'success',
50
+ createdAt: '2026-01-02T11:00:04+0000'
51
+ }
52
+ } as PaymentState
53
+ }
54
+
55
+ const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
56
+
57
+ const request = buildFormContextRequest({
58
+ method: 'get',
59
+ url: pageUrl,
60
+ path: pageUrl.pathname,
61
+ params: {
62
+ path: 'summary',
63
+ slug: 'payment'
64
+ },
65
+ query: {},
66
+ app: { model }
67
+ })
68
+
69
+ const context = model.getFormContext(
70
+ request,
71
+ state as unknown as FormSubmissionState
72
+ )
73
+
74
+ const pageDef = definition.pages[2]
75
+
76
+ const controller = new SummaryPageController(model, pageDef)
77
+
78
+ const summaryViewModel = controller.getSummaryViewModel(request, context)
79
+
80
+ const items = getFormSubmissionData(
81
+ summaryViewModel.context,
82
+ summaryViewModel.details
83
+ )
84
+
85
+ describe('getPersonalisation', () => {
86
+ it('should return the machine output', () => {
87
+ model.def = definition
88
+
89
+ const body = format(context, items, model, submitResponse, formStatus)
90
+
91
+ const parsedBody = JSON.parse(body)
92
+
93
+ const expectedData = {
94
+ main: {
95
+ licenceLength: 365,
96
+ fullName: 'John Smith'
97
+ },
98
+ payment: {
99
+ amount: 250,
100
+ createdAt: '2026-01-02T11:00:04+0000',
101
+ description: 'Payment desc',
102
+ paymentId: 'payment-id',
103
+ reference: 'payment-ref'
104
+ },
105
+ repeaters: {},
106
+ files: {}
107
+ }
108
+
109
+ expect(parsedBody.meta.schemaVersion).toBe('2')
110
+ expect(parsedBody.meta.timestamp).toBeDateString()
111
+ expect(parsedBody.meta.definition).toEqual(definition)
112
+ expect(parsedBody.meta.referenceNumber).toBe('foobar')
113
+ expect(parsedBody.data).toEqual(expectedData)
114
+ })
115
+ })
@@ -1,7 +1,10 @@
1
1
  import { type SubmitResponsePayload } from '@defra/forms-model'
2
2
 
3
3
  import { config } from '~/src/config/index.js'
4
- import { FileUploadField } from '~/src/server/plugins/engine/components/index.js'
4
+ import {
5
+ FileUploadField,
6
+ PaymentField
7
+ } from '~/src/server/plugins/engine/components/index.js'
5
8
  import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js'
6
9
  import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
7
10
  import {
@@ -16,6 +19,18 @@ import {
16
19
  type RichFormValue
17
20
  } from '~/src/server/plugins/engine/types.js'
18
21
 
22
+ /**
23
+ * Payment data for the machine output format
24
+ * Defined locally to avoid circular dependency with types.ts
25
+ */
26
+ interface PaymentOutput {
27
+ paymentId: string
28
+ reference: string
29
+ amount: number
30
+ description: string
31
+ createdAt: string
32
+ }
33
+
19
34
  const designerUrl = config.get('designerUrl')
20
35
 
21
36
  export function format(
@@ -71,6 +86,15 @@ export function format(
71
86
  * userDownloadLink: 'https://forms-designer/file-download/123-456-789'
72
87
  * }
73
88
  * ]
89
+ * },
90
+ * payments: {
91
+ * paymentComponentName: {
92
+ * paymentId: 'abc123',
93
+ * reference: 'REF-123',
94
+ * amount: 10.00,
95
+ * description: 'Application fee',
96
+ * createdAt: '2025-01-23T10:30:00.000Z'
97
+ * }
74
98
  * }
75
99
  * }
76
100
  */
@@ -82,6 +106,7 @@ export function categoriseData(items: DetailItem[]) {
82
106
  string,
83
107
  { fileId: string; fileName: string; userDownloadLink: string }[]
84
108
  >
109
+ payment?: PaymentOutput
85
110
  } = { main: {}, repeaters: {}, files: {} }
86
111
 
87
112
  items.forEach((item) => {
@@ -91,6 +116,11 @@ export function categoriseData(items: DetailItem[]) {
91
116
  output.repeaters[name] = extractRepeaters(item)
92
117
  } else if (isFileUploadFieldItem(item)) {
93
118
  output.files[name] = extractFileUploads(item)
119
+ } else if (isPaymentFieldItem(item)) {
120
+ const payment = extractPayment(item)
121
+ if (payment) {
122
+ output.payment = payment
123
+ }
94
124
  } else {
95
125
  output.main[name] = item.field.getFormValueFromState(state)
96
126
  }
@@ -148,3 +178,32 @@ function isFileUploadFieldItem(
148
178
  ): item is FileUploadFieldDetailitem {
149
179
  return item.field instanceof FileUploadField
150
180
  }
181
+
182
+ function isPaymentFieldItem(item: DetailItemField): item is DetailItemField & {
183
+ field: PaymentField
184
+ } {
185
+ return item.field instanceof PaymentField
186
+ }
187
+
188
+ /**
189
+ * Returns the "payments" section of the response body
190
+ * @param item - the payment item in the form
191
+ * @returns the payment data
192
+ */
193
+ function extractPayment(
194
+ item: DetailItemField & { field: PaymentField }
195
+ ): PaymentOutput | undefined {
196
+ const paymentState = item.field.getPaymentStateFromState(item.state)
197
+
198
+ if (!paymentState) {
199
+ return undefined
200
+ }
201
+
202
+ return {
203
+ paymentId: paymentState.paymentId,
204
+ reference: paymentState.reference,
205
+ amount: paymentState.amount,
206
+ description: paymentState.description,
207
+ createdAt: paymentState.preAuth?.createdAt ?? ''
208
+ }
209
+ }
@@ -15,12 +15,14 @@ import { type ValidationErrorItem } from 'joi'
15
15
  import {
16
16
  COMPONENT_STATE_ERROR,
17
17
  EXTERNAL_STATE_APPENDAGE,
18
- EXTERNAL_STATE_PAYLOAD
18
+ EXTERNAL_STATE_PAYLOAD,
19
+ PAYMENT_EXPIRED_NOTIFICATION
19
20
  } from '~/src/server/constants.js'
20
21
  import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
21
22
  import { optionalText } from '~/src/server/plugins/engine/components/constants.js'
22
23
  import { type BackLink } from '~/src/server/plugins/engine/components/types.js'
23
24
  import {
25
+ checkFormStatus,
24
26
  getCacheService,
25
27
  getErrors,
26
28
  getSaveAndExitHelpers,
@@ -47,6 +49,7 @@ import {
47
49
  import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js'
48
50
  import {
49
51
  FormAction,
52
+ FormStatus,
50
53
  type FormRequest,
51
54
  type FormRequestPayload,
52
55
  type FormRequestPayloadRefs,
@@ -182,6 +185,16 @@ export class QuestionPageController extends PageController {
182
185
  }
183
186
  }
184
187
 
188
+ const hasIncompletePayment = components.some(({ model }) => {
189
+ if ('paymentState' in model) {
190
+ const paymentState = model.paymentState as
191
+ | { preAuth?: { status?: string } }
192
+ | undefined
193
+ return !paymentState?.preAuth?.status
194
+ }
195
+ return false
196
+ })
197
+
185
198
  return {
186
199
  ...viewModel,
187
200
  backLink: this.getBackLink(request, context),
@@ -189,7 +202,8 @@ export class QuestionPageController extends PageController {
189
202
  showTitle,
190
203
  components,
191
204
  errors,
192
- allowSaveAndExit: this.shouldShowSaveAndExit(request.server)
205
+ allowSaveAndExit: this.shouldShowSaveAndExit(request.server),
206
+ showSubmitButton: !hasIncompletePayment
193
207
  }
194
208
  }
195
209
 
@@ -423,6 +437,12 @@ export class QuestionPageController extends PageController {
423
437
 
424
438
  viewModel.errors = (viewModel.errors ?? []).concat(flashedErrors)
425
439
 
440
+ const paymentExpiredFlash = request.yar.flash(
441
+ PAYMENT_EXPIRED_NOTIFICATION
442
+ )
443
+ viewModel.showPaymentExpiredNotification =
444
+ !Array.isArray(paymentExpiredFlash)
445
+
426
446
  /**
427
447
  * Content components can be hidden based on a condition. If the condition evaluates to true, it is safe to be kept, otherwise discard it
428
448
  */
@@ -517,7 +537,7 @@ export class QuestionPageController extends PageController {
517
537
  const action = request.payload.action
518
538
 
519
539
  if (action?.startsWith(FormAction.External)) {
520
- return this.dispatchExternal(request, h, context)
540
+ return await this.dispatchExternal(request, h, context)
521
541
  }
522
542
 
523
543
  /**
@@ -551,7 +571,7 @@ export class QuestionPageController extends PageController {
551
571
  }
552
572
  }
553
573
 
554
- private dispatchExternal(
574
+ private async dispatchExternal(
555
575
  request: FormRequestPayload,
556
576
  h: FormResponseToolkit,
557
577
  context: FormContext
@@ -602,11 +622,17 @@ export class QuestionPageController extends PageController {
602
622
  // Clear any previous state appendage
603
623
  request.yar.clear(EXTERNAL_STATE_APPENDAGE)
604
624
 
605
- return selectedComponent.dispatcher(request, h, {
625
+ // Determine if this is a live form (not preview/draft)
626
+ const { state, isPreview } = checkFormStatus(request.params)
627
+ const isLive = state === FormStatus.Live
628
+
629
+ return await selectedComponent.dispatcher(request, h, {
606
630
  component,
607
631
  controller: this,
608
632
  sourceUrl: request.url.toString(),
609
- actionArgs: args
633
+ actionArgs: args,
634
+ isLive,
635
+ isPreview
610
636
  })
611
637
  }
612
638