Skip to content

Commit ca590c6

Browse files
authored
Merge pull request #446 from DEFRA/feat/df-623-payment
Feat(DF-623): Gov UK Payment integration
2 parents d90dd7b + 397d884 commit ca590c6

9 files changed

Lines changed: 4072 additions & 1350 deletions

.lintstagedrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export default {
22
'*.{cjs,js,mjs}': ['eslint --fix', 'prettier --write'],
33
'*.{json,md}': 'prettier --write',
4-
'*.test.{cjs,js,mjs}': 'jest'
4+
'*.test.{cjs,js,mjs}': 'jest --forceExit'
55
}

package-lock.json

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

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@
3838
},
3939
"dependencies": {
4040
"@aws-sdk/client-s3": "^3.679.0",
41-
"@aws-sdk/client-sqs": "3.864.0",
41+
"@aws-sdk/client-sqs": "^3.982.0",
4242
"@aws-sdk/s3-request-presigner": "^3.679.0",
43-
"@defra/forms-engine-plugin": "^4.0.25",
44-
"@defra/forms-model": "^3.0.595",
43+
"@defra/forms-engine-plugin": "^4.0.44",
44+
"@defra/forms-model": "^3.0.611",
4545
"@defra/hapi-tracing": "^1.12.0",
4646
"@elastic/ecs-pino-format": "^1.5.0",
4747
"@hapi/boom": "^10.0.1",
@@ -55,6 +55,7 @@
5555
"content-disposition": "^0.5.4",
5656
"convict": "^6.2.4",
5757
"csv-stringify": "^6.5.2",
58+
"date-fns": "^4.1.0",
5859
"dotenv": "^16.4.7",
5960
"hapi-pino": "^13.0.0",
6061
"hapi-swagger": "^17.3.2",
@@ -95,9 +96,9 @@
9596
"eslint-plugin-promise": "^6.6.0",
9697
"husky": "^9.1.7",
9798
"jest": "^30.0.5",
98-
"nock": "^14.0.10",
9999
"jest-extended": "^7.0.0",
100100
"lint-staged": "^16.2.7",
101+
"nock": "^14.0.10",
101102
"oidc-client-ts": "^3.1.0",
102103
"prettier": "^3.7.4",
103104
"tsx": "^4.19.3",

src/helpers/payment-helper.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { format } from 'date-fns'
2+
3+
/**
4+
* Formats a payment date for display
5+
* @param {string} isoString - ISO date string
6+
* @returns {string} Formatted date string (e.g., "26 January 2026 – 17:01:29")
7+
*/
8+
export function formatPaymentDate(isoString) {
9+
return format(new Date(isoString), 'd MMMM yyyy h:mmaaa')
10+
}
11+
12+
/**
13+
* Formats a payment amount with two decimal places
14+
* @param {number} amount - amount in pounds
15+
* @returns {string} Formatted amount (e.g., "£10.00")
16+
*/
17+
export function formatPaymentAmount(amount) {
18+
return ${amount.toFixed(2)}`
19+
}

src/helpers/payment-helper.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { formatPaymentAmount, formatPaymentDate } from './payment-helper.js'
2+
3+
describe('payment-helper', () => {
4+
describe('formatPaymentDate', () => {
5+
it('should format an ISO date string to UK format with time', () => {
6+
// Using a fixed date to avoid timezone issues
7+
const result = formatPaymentDate('2025-11-10T17:01:29.000Z')
8+
9+
// The date part should always be correct
10+
expect(result).toContain('10 November 2025')
11+
// The time will vary by timezone, so just check format
12+
expect(result).toMatch(/10 November 2025 \d{1,2}:\d{2}(am|pm)/)
13+
})
14+
15+
it('should handle different dates correctly', () => {
16+
const result = formatPaymentDate('2026-01-26T09:30:15.000Z')
17+
18+
expect(result).toContain('26 January 2026')
19+
expect(result).toMatch(/26 January 2026 \d{1,2}:\d{2}(am|pm)/)
20+
})
21+
})
22+
23+
describe('formatPaymentAmount', () => {
24+
it('should format a whole number with two decimal places', () => {
25+
expect(formatPaymentAmount(10)).toBe('£10.00')
26+
})
27+
28+
it('should format a decimal number with two decimal places', () => {
29+
expect(formatPaymentAmount(10.5)).toBe('£10.50')
30+
})
31+
32+
it('should format a number with more than two decimal places', () => {
33+
expect(formatPaymentAmount(10.999)).toBe('£11.00')
34+
})
35+
36+
it('should format zero correctly', () => {
37+
expect(formatPaymentAmount(0)).toBe('£0.00')
38+
})
39+
40+
it('should format large amounts correctly', () => {
41+
expect(formatPaymentAmount(1234.56)).toBe('£1234.56')
42+
})
43+
})
44+
})

src/services/submission-service.js

Lines changed: 144 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import xlsx from 'xlsx'
99

1010
import { config } from '~/src/config/index.js'
1111
import { createLogger } from '~/src/helpers/logging/logger.js'
12+
import {
13+
formatPaymentAmount,
14+
formatPaymentDate
15+
} from '~/src/helpers/payment-helper.js'
1216
import { getSubmissionRecords } from '~/src/repositories/submission-repository.js'
1317
import {
1418
getFormDefinition,
@@ -66,6 +70,18 @@ const SUBMISSION_DATE_HEADER_TEXT = 'Submission date'
6670
const SUBMISSION_FORM_NAME = 'SubmissionFormName'
6771
const SUBMISSION_FORM_NAME_TEXT = 'Form name'
6872

73+
const PAYMENT_DESCRIPTION_HEADER = 'PaymentDescription'
74+
const PAYMENT_DESCRIPTION_HEADER_TEXT = 'Payment description'
75+
76+
const PAYMENT_AMOUNT_HEADER = 'PaymentAmount'
77+
const PAYMENT_AMOUNT_HEADER_TEXT = 'Payment amount'
78+
79+
const PAYMENT_REFERENCE_HEADER = 'PaymentReference'
80+
const PAYMENT_REFERENCE_HEADER_TEXT = 'Payment reference'
81+
82+
const PAYMENT_DATE_HEADER = 'PaymentDate'
83+
const PAYMENT_DATE_HEADER_TEXT = 'Payment date'
84+
6985
const CSAT_FORM_ID = '691db72966b1bdc98fa3e72a'
7086

7187
/**
@@ -338,6 +354,116 @@ export async function addFirstCellsToRow(
338354
}
339355
}
340356

357+
/**
358+
* Add form component cells to a row
359+
* @param {FormModel | undefined} formModel - the form model
360+
* @param {Map<string, CellValue>} row - the row to add cells to
361+
* @param {SpreadsheetContext} context - the spreadsheet context
362+
* @param {WithId<FormSubmissionDocument>} record - the submission record
363+
* @param {SpreadsheetOptions | undefined} [options] - spreadsheet options
364+
*/
365+
function addFormComponentCellsToRow(formModel, row, context, record, options) {
366+
formModel?.componentMap.forEach((component, key) => {
367+
if (!component.isFormComponent) {
368+
return
369+
}
370+
371+
if (hasRepeater(component.page.pageDef)) {
372+
const repeaterName = component.page.pageDef.repeat.options.name
373+
const hasRepeaterData = repeaterName in record.data.repeaters
374+
const items = hasRepeaterData ? record.data.repeaters[repeaterName] : []
375+
376+
for (let index = 0; index < items.length; index++) {
377+
const value = getValue(items[index], key, component)
378+
const componentKey = `${component.name} ${index + 1}`
379+
const componentValue = `${component.label} ${index + 1}`
380+
381+
addCellToRow(row, componentKey, value, options)
382+
addHeader(context, component, componentKey, componentValue)
383+
}
384+
} else if (component.type === ComponentType.FileUploadField) {
385+
const files = record.data.files[component.name]
386+
const fileLinks = Array.isArray(files)
387+
? files.map((f) => f.userDownloadLink).join(' \r\n')
388+
: ''
389+
390+
addCellToRow(row, component.name, fileLinks, options)
391+
addHeader(context, component)
392+
} else {
393+
const value = getValue(record.data.main, key, component)
394+
395+
addCellToRow(row, component.name, value, options)
396+
addHeader(context, component)
397+
}
398+
})
399+
}
400+
401+
/**
402+
* Adds a header if not already present
403+
* @param {Map<string, string>} headers - the headers map
404+
* @param {string} key - the header key
405+
* @param {string} value - the header display text
406+
*/
407+
function addHeaderIfMissing(headers, key, value) {
408+
if (!headers.has(key)) {
409+
headers.set(key, value)
410+
}
411+
}
412+
413+
/**
414+
* Add payment cells to a row if payment data exists
415+
* @param {Map<string, CellValue>} row - the row to add cells to
416+
* @param {Caches} caches - the spreadsheet caches
417+
* @param {WithId<FormSubmissionDocument>} record - the submission record
418+
* @param {SpreadsheetOptions | undefined} [options] - spreadsheet options
419+
*/
420+
function addPaymentCellsToRow(row, caches, record, options) {
421+
const payment = record.data.payment
422+
if (!payment) {
423+
return
424+
}
425+
426+
addCellToRow(row, PAYMENT_DESCRIPTION_HEADER, payment.description, options)
427+
addHeaderIfMissing(
428+
caches.headers,
429+
PAYMENT_DESCRIPTION_HEADER,
430+
PAYMENT_DESCRIPTION_HEADER_TEXT
431+
)
432+
433+
addCellToRow(
434+
row,
435+
PAYMENT_AMOUNT_HEADER,
436+
formatPaymentAmount(payment.amount),
437+
options
438+
)
439+
addHeaderIfMissing(
440+
caches.headers,
441+
PAYMENT_AMOUNT_HEADER,
442+
PAYMENT_AMOUNT_HEADER_TEXT
443+
)
444+
445+
addCellToRow(row, PAYMENT_REFERENCE_HEADER, payment.reference, options)
446+
addHeaderIfMissing(
447+
caches.headers,
448+
PAYMENT_REFERENCE_HEADER,
449+
PAYMENT_REFERENCE_HEADER_TEXT
450+
)
451+
452+
if (payment.createdAt) {
453+
addCellToRow(
454+
row,
455+
PAYMENT_DATE_HEADER,
456+
formatPaymentDate(payment.createdAt),
457+
options
458+
)
459+
addHeaderIfMissing(
460+
caches.headers,
461+
PAYMENT_DATE_HEADER,
462+
PAYMENT_DATE_HEADER_TEXT
463+
)
464+
}
465+
}
466+
341467
/**
342468
* Generate a submission file for a form id
343469
* @param {string} formId - the form id
@@ -379,40 +505,8 @@ export async function generateSubmissionsFile(
379505
)
380506

381507
addCellToRow(row, SUBMISSION_FORM_NAME, formNameFromId, options)
382-
383-
formModel?.componentMap.forEach((component, key) => {
384-
if (!component.isFormComponent) {
385-
return
386-
}
387-
388-
if (hasRepeater(component.page.pageDef)) {
389-
const repeaterName = component.page.pageDef.repeat.options.name
390-
const hasRepeaterData = repeaterName in record.data.repeaters
391-
const items = hasRepeaterData ? record.data.repeaters[repeaterName] : []
392-
393-
for (let index = 0; index < items.length; index++) {
394-
const value = getValue(items[index], key, component)
395-
const componentKey = `${component.name} ${index + 1}`
396-
const componentValue = `${component.label} ${index + 1}`
397-
398-
addCellToRow(row, componentKey, value, options)
399-
addHeader(context, component, componentKey, componentValue)
400-
}
401-
} else if (component.type === ComponentType.FileUploadField) {
402-
const files = record.data.files[component.name]
403-
const fileLinks = Array.isArray(files)
404-
? files.map((f) => f.userDownloadLink).join(' \r\n')
405-
: ''
406-
407-
addCellToRow(row, component.name, fileLinks, options)
408-
addHeader(context, component)
409-
} else {
410-
const value = getValue(record.data.main, key, component)
411-
412-
addCellToRow(row, component.name, value, options)
413-
addHeader(context, component)
414-
}
415-
})
508+
addFormComponentCellsToRow(formModel, row, context, record, options)
509+
addPaymentCellsToRow(row, caches, record, options)
416510

417511
rows.push(row)
418512
}
@@ -523,6 +617,21 @@ function sortHeaders(components, headers) {
523617
const idxA = componentNames.indexOf(nameA)
524618
const idxB = componentNames.indexOf(nameB)
525619

620+
// Both not found -> keep original order
621+
if (idxA === -1 && idxB === -1) {
622+
return 0
623+
}
624+
625+
// A not found, B found -> A goes after B
626+
if (idxA === -1) {
627+
return 1
628+
}
629+
630+
// A found, B not found -> A goes before B
631+
if (idxB === -1) {
632+
return -1
633+
}
634+
526635
return idxA - idxB
527636
})
528637
}
@@ -556,7 +665,7 @@ export function buildPreHeaders(options) {
556665
/**
557666
* Build an xlsx workbook from the headers and rows
558667
* @param {string} formId - the form id
559-
* @param {[string, string][]} headers - the file header
668+
* @param {[string, string][]} headers - the file headers (including payment headers)
560669
* @param {Map<string, CellValue >[]} rows - the data rows
561670
* @param {SpreadsheetOptions} [options]
562671
*/

0 commit comments

Comments
 (0)