Skip to content

Commit 9df2182

Browse files
authored
Merge pull request #109 from DEFRA/feat/df-623-payment
feat(DF-623): add payment details section to submission emails
2 parents fce0bc1 + e3b08f0 commit 9df2182

8 files changed

Lines changed: 5152 additions & 2055 deletions

File tree

package-lock.json

Lines changed: 4766 additions & 1997 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@
3030
"setup:husky": "node -e \"try { (await import('husky')).default() } catch (e) { if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e }\" --input-type module"
3131
},
3232
"dependencies": {
33-
"@aws-sdk/client-sqs": "3.894.0",
34-
"@defra/forms-engine-plugin": "4.0.33",
35-
"@defra/forms-model": "3.0.604",
33+
"@aws-sdk/client-sqs": "3.982.0",
34+
"@defra/forms-engine-plugin": "4.0.44",
35+
"@defra/forms-model": "3.0.611",
3636
"@defra/hapi-tracing": "^1.28.0",
3737
"@elastic/ecs-pino-format": "^1.5.0",
3838
"@hapi/hapi": "^21.4.2",

src/service/mappers/formatters/human/__snapshots__/v1.test.js.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Jest Snapshot v1, https://goo.gl/fbAQLP
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
22

33
exports[`Page controller helpers format should return a valid human readable v1 response 1`] = `
44
"^ For security reasons, the links in this email expire at 12:00am on Tuesday 1 July 2025

src/service/mappers/formatters/human/v1.js

Lines changed: 114 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { format as dateFormat } from '~/src/helpers/date.js'
1616
import { stringHasNonEmptyValue } from '~/src/helpers/string-utils.js'
1717
import { escapeContent, escapeFileLabel } from '~/src/lib/notify.js'
1818
import {
19+
extractPaymentDetails,
1920
findRepeaterPageByKey,
2021
formatLocationField,
2122
formatMultilineTextField,
@@ -38,55 +39,37 @@ export function handleReferenceNumber(definition, message, lines) {
3839
}
3940

4041
/**
41-
* Human readable notify formatter v1
42+
* Appends the payment details section to the email lines if payment exists
4243
* @param {FormAdapterSubmissionMessage} formSubmissionMessage
43-
* @param {FormDefinition} formDefinition
44-
* @param {string} _schemaVersion
44+
* @param {string[]} lines
4545
*/
46-
export function formatter(
47-
formSubmissionMessage,
48-
formDefinition,
49-
_schemaVersion
50-
) {
51-
const { meta, result } = formSubmissionMessage
52-
const { isPreview, status } = meta
53-
const files = result.files
46+
function appendPaymentSection(formSubmissionMessage, lines) {
47+
const paymentDetails = extractPaymentDetails(formSubmissionMessage)
5448

55-
const formModel = new FormModel(formDefinition, { basePath: '' }, {})
56-
57-
const formName = escapeContent(meta.formName)
58-
/**
59-
* @todo Refactor this below but the code to
60-
* generate the question and answers works for now
61-
*/
62-
const now = new Date()
63-
const formattedNow = `${dateFormat(now, 'h:mmaaa')} on ${dateFormat(now, 'd MMMM yyyy')}`
64-
65-
const fileExpiryDate = addDays(now, FILE_EXPIRY_OFFSET)
66-
const formattedExpiryDate = `${dateFormat(fileExpiryDate, 'h:mmaaa')} on ${dateFormat(fileExpiryDate, 'eeee d MMMM yyyy')}`
67-
68-
const order = calculateOrder(formDefinition, formSubmissionMessage)
69-
const componentMap = new Map()
70-
/**
71-
* @type {string[]}
72-
*/
73-
const lines = []
74-
75-
lines.push(
76-
`^ For security reasons, the links in this email expire at ${escapeContent(formattedExpiryDate)}\n`
77-
)
78-
79-
if (isPreview) {
80-
lines.push(`This is a test of the ${formName} ${status} form.\n`)
49+
if (!paymentDetails) {
50+
return
8151
}
8252

8353
lines.push(
84-
`${formName} form received at ${escapeContent(formattedNow)}.\n`,
54+
'---\n',
55+
'# Payment details\n',
56+
'## Payment for\n',
57+
`${escapeContent(paymentDetails.description)}\n`,
58+
'## Total amount\n',
59+
${paymentDetails.amount}\n`,
60+
'## Date of payment\n',
61+
`${escapeContent(paymentDetails.dateOfPayment)}\n`,
8562
'---\n'
8663
)
64+
}
8765

88-
handleReferenceNumber(formDefinition, formSubmissionMessage, lines)
89-
66+
/**
67+
* Process main form entries and add them to the component map
68+
* @param {FormAdapterSubmissionMessage} formSubmissionMessage
69+
* @param {FormModel} formModel
70+
* @param {Map<string, string[]>} componentMap
71+
*/
72+
function processMainEntries(formSubmissionMessage, formModel, componentMap) {
9073
const mainEntries = Object.entries({
9174
...formSubmissionMessage.data.main,
9275
...formSubmissionMessage.data.files
@@ -115,44 +98,123 @@ export function formatter(
11598
questionLines.push('---\n')
11699
componentMap.set(key, questionLines)
117100
}
101+
}
118102

103+
/**
104+
* Process repeater entries and add them to the component map
105+
* @param {FormAdapterSubmissionMessage} formSubmissionMessage
106+
* @param {FormDefinition} formDefinition
107+
* @param {Map<string, string[]>} componentMap
108+
*/
109+
function processRepeaterEntries(
110+
formSubmissionMessage,
111+
formDefinition,
112+
componentMap
113+
) {
119114
const repeaterEntries = Object.entries(
120115
formSubmissionMessage.result.files.repeaters
121116
)
122117

123118
for (const [key, fileId] of repeaterEntries) {
124119
const repeaterPage = findRepeaterPageByKey(key, formDefinition)
125120

126-
const questionLines = /** @type {string[]} */ ([])
121+
if (!hasRepeater(repeaterPage)) {
122+
continue
123+
}
127124

128-
if (hasRepeater(repeaterPage)) {
129-
const label = escapeContent(repeaterPage.repeat.options.title)
130-
const componentKey = repeaterPage.repeat.options.name
125+
const label = escapeContent(repeaterPage.repeat.options.title)
126+
const componentKey = repeaterPage.repeat.options.name
127+
const questionLines = /** @type {string[]} */ ([])
131128

132-
questionLines.push(`## ${label}\n`)
129+
questionLines.push(`## ${label}\n`)
133130

134-
const repeaterFilename = escapeFileLabel(`Download ${label} (CSV)`)
135-
questionLines.push(
136-
`[${repeaterFilename}](${designerUrl}/file-download/${fileId})\n`,
137-
'---\n'
138-
)
139-
componentMap.set(componentKey, questionLines)
140-
}
131+
const repeaterFilename = escapeFileLabel(`Download ${label} (CSV)`)
132+
questionLines.push(
133+
`[${repeaterFilename}](${designerUrl}/file-download/${fileId})\n`,
134+
'---\n'
135+
)
136+
componentMap.set(componentKey, questionLines)
141137
}
138+
}
142139

140+
/**
141+
* Append component lines to the output in the correct order
142+
* @param {string[]} order
143+
* @param {Map<string, string[]>} componentMap
144+
* @param {string[]} lines
145+
*/
146+
function appendComponentLines(order, componentMap, lines) {
143147
for (const key of order) {
144148
const componentLines = componentMap.get(key)
145149

146150
if (componentLines) {
147151
lines.push(...componentLines)
148152
}
149153
}
154+
}
155+
156+
/**
157+
* Human readable notify formatter v1
158+
* @param {FormAdapterSubmissionMessage} formSubmissionMessage
159+
* @param {FormDefinition} formDefinition
160+
* @param {string} _schemaVersion
161+
*/
162+
export function formatter(
163+
formSubmissionMessage,
164+
formDefinition,
165+
_schemaVersion
166+
) {
167+
const { meta, result } = formSubmissionMessage
168+
const { isPreview, status } = meta
169+
const files = result.files
170+
171+
const formModel = new FormModel(formDefinition, { basePath: '' }, {})
172+
173+
const formName = escapeContent(meta.formName)
174+
/**
175+
* @todo Refactor this below but the code to
176+
* generate the question and answers works for now
177+
*/
178+
const now = new Date()
179+
const formattedNow = `${dateFormat(now, 'h:mmaaa')} on ${dateFormat(now, 'd MMMM yyyy')}`
180+
181+
const fileExpiryDate = addDays(now, FILE_EXPIRY_OFFSET)
182+
const formattedExpiryDate = `${dateFormat(fileExpiryDate, 'h:mmaaa')} on ${dateFormat(fileExpiryDate, 'eeee d MMMM yyyy')}`
183+
184+
const order = calculateOrder(formDefinition, formSubmissionMessage)
185+
const componentMap = new Map()
186+
/**
187+
* @type {string[]}
188+
*/
189+
const lines = []
190+
191+
lines.push(
192+
`^ For security reasons, the links in this email expire at ${escapeContent(formattedExpiryDate)}\n`
193+
)
194+
195+
if (isPreview) {
196+
lines.push(`This is a test of the ${formName} ${status} form.\n`)
197+
}
198+
199+
lines.push(
200+
`${formName} form received at ${escapeContent(formattedNow)}.\n`,
201+
'---\n'
202+
)
203+
204+
handleReferenceNumber(formDefinition, formSubmissionMessage, lines)
205+
206+
processMainEntries(formSubmissionMessage, formModel, componentMap)
207+
processRepeaterEntries(formSubmissionMessage, formDefinition, componentMap)
208+
appendComponentLines(order, componentMap, lines)
150209

151210
const mainResultFilename = escapeFileLabel('Download main form (CSV)')
152211
lines.push(
153212
`[${mainResultFilename}](${designerUrl}/file-download/${files.main})\n`
154213
)
155214

215+
// Add payment details section if payment exists
216+
appendPaymentSection(formSubmissionMessage, lines)
217+
156218
lines.push('\n', 'Thanks,', 'Defra')
157219

158220
return lines.join('\n')

src/service/mappers/formatters/human/v1.test.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,4 +290,90 @@ describe('Page controller helpers', () => {
290290
])
291291
})
292292
})
293+
294+
describe('payment details', () => {
295+
it('should include payment details section when payment exists', () => {
296+
const definition = buildDefinition({
297+
...exampleNotifyFormDefinition,
298+
output: {
299+
audience: 'human',
300+
version: '1'
301+
}
302+
})
303+
304+
const messageWithPayment = buildFormAdapterSubmissionMessage({
305+
...exampleNotifyFormMessage,
306+
data: {
307+
...exampleNotifyFormMessage.data,
308+
payment: {
309+
paymentId: 'pay_abc123',
310+
reference: 'REF-123-456',
311+
amount: 150,
312+
description: 'Application fee',
313+
createdAt: '2025-11-10T17:01:29.000Z'
314+
}
315+
}
316+
})
317+
318+
const formatter = getFormatter('human', '1')
319+
const output = formatter(messageWithPayment, definition, '1')
320+
321+
expect(output).toContain('# Payment details')
322+
expect(output).toContain('## Payment for')
323+
expect(output).toContain('Application fee')
324+
expect(output).toContain('## Total amount')
325+
expect(output).toContain('£150')
326+
expect(output).toContain('## Date of payment')
327+
expect(output).toContain('5:01pm on 10 November 2025')
328+
})
329+
330+
it('should not include payment details section when no payment exists', () => {
331+
const definition = buildDefinition({
332+
...exampleNotifyFormDefinition,
333+
output: {
334+
audience: 'human',
335+
version: '1'
336+
}
337+
})
338+
339+
const messageWithNoPayment = buildFormAdapterSubmissionMessage({
340+
...exampleNotifyFormMessage,
341+
data: {
342+
...exampleNotifyFormMessage.data,
343+
payment: undefined
344+
}
345+
})
346+
347+
const formatter = getFormatter('human', '1')
348+
const output = formatter(messageWithNoPayment, definition, '1')
349+
350+
expect(output).not.toContain('# Payment details')
351+
expect(output).not.toContain('## Payment for')
352+
expect(output).not.toContain('## Total amount')
353+
expect(output).not.toContain('## Date of payment')
354+
})
355+
356+
it('should not include payment details section when payment is undefined', () => {
357+
const definition = buildDefinition({
358+
...exampleNotifyFormDefinition,
359+
output: {
360+
audience: 'human',
361+
version: '1'
362+
}
363+
})
364+
365+
const messageWithNoPayment = buildFormAdapterSubmissionMessage({
366+
...exampleNotifyFormMessage,
367+
data: {
368+
...exampleNotifyFormMessage.data,
369+
payment: undefined
370+
}
371+
})
372+
373+
const formatter = getFormatter('human', '1')
374+
const output = formatter(messageWithNoPayment, definition, '1')
375+
376+
expect(output).not.toContain('# Payment details')
377+
})
378+
})
293379
})

src/service/mappers/formatters/shared.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { hasRepeater } from '@defra/forms-model'
22

3+
import { format as dateFormat } from '~/src/helpers/date.js'
34
import { escapeContent } from '~/src/lib/notify.js'
45

56
/**
@@ -56,8 +57,31 @@ export function formatLocationField(_answer, field, richFormValue) {
5657
return contextValue ? `${contextValue}\n` : ''
5758
}
5859

60+
/**
61+
* Extracts payment details from the submission message if a payment exists.
62+
* Forms only have one payment component.
63+
* @param {FormAdapterSubmissionMessage} formSubmissionMessage
64+
* @returns {{ description: string, amount: number, dateOfPayment: string } | undefined}
65+
*/
66+
export function extractPaymentDetails(formSubmissionMessage) {
67+
const payment = formSubmissionMessage.data.payment
68+
69+
if (!payment) {
70+
return undefined
71+
}
72+
73+
const date = new Date(payment.createdAt)
74+
const dateOfPayment = `${dateFormat(date, 'h:mmaaa')} on ${dateFormat(date, 'd MMMM yyyy')}`
75+
76+
return {
77+
description: payment.description,
78+
amount: payment.amount,
79+
dateOfPayment
80+
}
81+
}
82+
5983
/**
6084
* @import { Component } from '@defra/forms-engine-plugin/engine/components/helpers/components.js'
61-
* @import { RichFormValue } from '@defra/forms-engine-plugin/engine/types.js'
85+
* @import { FormAdapterSubmissionMessage, RichFormValue } from '@defra/forms-engine-plugin/engine/types.js'
6286
* @import { FormDefinition } from '@defra/forms-model'
6387
*/

0 commit comments

Comments
 (0)