Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .lintstagedrc.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export default {
'*.{cjs,js,mjs}': ['eslint --fix', 'prettier --write'],
'*.{json,md}': 'prettier --write',
'*.test.{cjs,js,mjs}': 'jest'
'*.test.{cjs,js,mjs}': 'jest --forceExit'
}
4,954 changes: 3,644 additions & 1,310 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.679.0",
"@aws-sdk/client-sqs": "3.864.0",
"@aws-sdk/client-sqs": "^3.982.0",
"@aws-sdk/s3-request-presigner": "^3.679.0",
"@defra/forms-engine-plugin": "^4.0.25",
"@defra/forms-model": "^3.0.595",
"@defra/forms-engine-plugin": "^4.0.44",
"@defra/forms-model": "^3.0.611",
"@defra/hapi-tracing": "^1.12.0",
"@elastic/ecs-pino-format": "^1.5.0",
"@hapi/boom": "^10.0.1",
Expand All @@ -55,6 +55,7 @@
"content-disposition": "^0.5.4",
"convict": "^6.2.4",
"csv-stringify": "^6.5.2",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"hapi-pino": "^13.0.0",
"hapi-swagger": "^17.3.2",
Expand Down Expand Up @@ -95,9 +96,9 @@
"eslint-plugin-promise": "^6.6.0",
"husky": "^9.1.7",
"jest": "^30.0.5",
"nock": "^14.0.10",
"jest-extended": "^7.0.0",
"lint-staged": "^16.2.7",
"nock": "^14.0.10",
"oidc-client-ts": "^3.1.0",
"prettier": "^3.7.4",
"tsx": "^4.19.3",
Expand Down
19 changes: 19 additions & 0 deletions src/helpers/payment-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { format } from 'date-fns'

/**
* Formats a payment date for display
* @param {string} isoString - ISO date string
* @returns {string} Formatted date string (e.g., "26 January 2026 – 17:01:29")
*/
export function formatPaymentDate(isoString) {
Comment thread
jbarnsley10 marked this conversation as resolved.
return format(new Date(isoString), 'd MMMM yyyy h:mmaaa')
}

/**
* Formats a payment amount with two decimal places
* @param {number} amount - amount in pounds
* @returns {string} Formatted amount (e.g., "£10.00")
*/
export function formatPaymentAmount(amount) {
return `£${amount.toFixed(2)}`
}
44 changes: 44 additions & 0 deletions src/helpers/payment-helper.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { formatPaymentAmount, formatPaymentDate } from './payment-helper.js'

describe('payment-helper', () => {
describe('formatPaymentDate', () => {
it('should format an ISO date string to UK format with time', () => {
// Using a fixed date to avoid timezone issues
const result = formatPaymentDate('2025-11-10T17:01:29.000Z')

// The date part should always be correct
expect(result).toContain('10 November 2025')
// The time will vary by timezone, so just check format
expect(result).toMatch(/10 November 2025 \d{1,2}:\d{2}(am|pm)/)
})

it('should handle different dates correctly', () => {
const result = formatPaymentDate('2026-01-26T09:30:15.000Z')

expect(result).toContain('26 January 2026')
expect(result).toMatch(/26 January 2026 \d{1,2}:\d{2}(am|pm)/)
})
})

describe('formatPaymentAmount', () => {
it('should format a whole number with two decimal places', () => {
expect(formatPaymentAmount(10)).toBe('£10.00')
})

it('should format a decimal number with two decimal places', () => {
expect(formatPaymentAmount(10.5)).toBe('£10.50')
})

it('should format a number with more than two decimal places', () => {
expect(formatPaymentAmount(10.999)).toBe('£11.00')
})

it('should format zero correctly', () => {
expect(formatPaymentAmount(0)).toBe('£0.00')
})

it('should format large amounts correctly', () => {
expect(formatPaymentAmount(1234.56)).toBe('£1234.56')
})
})
})
179 changes: 144 additions & 35 deletions src/services/submission-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import xlsx from 'xlsx'

import { config } from '~/src/config/index.js'
import { createLogger } from '~/src/helpers/logging/logger.js'
import {
formatPaymentAmount,
formatPaymentDate
} from '~/src/helpers/payment-helper.js'
import { getSubmissionRecords } from '~/src/repositories/submission-repository.js'
import {
getFormDefinition,
Expand Down Expand Up @@ -66,6 +70,18 @@ const SUBMISSION_DATE_HEADER_TEXT = 'Submission date'
const SUBMISSION_FORM_NAME = 'SubmissionFormName'
const SUBMISSION_FORM_NAME_TEXT = 'Form name'

const PAYMENT_DESCRIPTION_HEADER = 'PaymentDescription'
const PAYMENT_DESCRIPTION_HEADER_TEXT = 'Payment description'

const PAYMENT_AMOUNT_HEADER = 'PaymentAmount'
const PAYMENT_AMOUNT_HEADER_TEXT = 'Payment amount'

const PAYMENT_REFERENCE_HEADER = 'PaymentReference'
const PAYMENT_REFERENCE_HEADER_TEXT = 'Payment reference'

const PAYMENT_DATE_HEADER = 'PaymentDate'
const PAYMENT_DATE_HEADER_TEXT = 'Payment date'

const CSAT_FORM_ID = '691db72966b1bdc98fa3e72a'

/**
Expand Down Expand Up @@ -338,6 +354,116 @@ export async function addFirstCellsToRow(
}
}

/**
* Add form component cells to a row
* @param {FormModel | undefined} formModel - the form model
* @param {Map<string, CellValue>} row - the row to add cells to
* @param {SpreadsheetContext} context - the spreadsheet context
* @param {WithId<FormSubmissionDocument>} record - the submission record
* @param {SpreadsheetOptions | undefined} [options] - spreadsheet options
*/
function addFormComponentCellsToRow(formModel, row, context, record, options) {
formModel?.componentMap.forEach((component, key) => {
if (!component.isFormComponent) {
return
}

if (hasRepeater(component.page.pageDef)) {
const repeaterName = component.page.pageDef.repeat.options.name
const hasRepeaterData = repeaterName in record.data.repeaters
const items = hasRepeaterData ? record.data.repeaters[repeaterName] : []

for (let index = 0; index < items.length; index++) {
const value = getValue(items[index], key, component)
const componentKey = `${component.name} ${index + 1}`
const componentValue = `${component.label} ${index + 1}`

addCellToRow(row, componentKey, value, options)
addHeader(context, component, componentKey, componentValue)
}
} else if (component.type === ComponentType.FileUploadField) {
const files = record.data.files[component.name]
const fileLinks = Array.isArray(files)
? files.map((f) => f.userDownloadLink).join(' \r\n')
: ''

addCellToRow(row, component.name, fileLinks, options)
addHeader(context, component)
} else {
const value = getValue(record.data.main, key, component)

addCellToRow(row, component.name, value, options)
addHeader(context, component)
}
})
}

/**
* Adds a header if not already present
* @param {Map<string, string>} headers - the headers map
* @param {string} key - the header key
* @param {string} value - the header display text
*/
function addHeaderIfMissing(headers, key, value) {
if (!headers.has(key)) {
headers.set(key, value)
}
}

/**
* Add payment cells to a row if payment data exists
* @param {Map<string, CellValue>} row - the row to add cells to
* @param {Caches} caches - the spreadsheet caches
* @param {WithId<FormSubmissionDocument>} record - the submission record
* @param {SpreadsheetOptions | undefined} [options] - spreadsheet options
*/
function addPaymentCellsToRow(row, caches, record, options) {
const payment = record.data.payment
if (!payment) {
return
}

addCellToRow(row, PAYMENT_DESCRIPTION_HEADER, payment.description, options)
addHeaderIfMissing(
caches.headers,
PAYMENT_DESCRIPTION_HEADER,
PAYMENT_DESCRIPTION_HEADER_TEXT
)

addCellToRow(
row,
PAYMENT_AMOUNT_HEADER,
formatPaymentAmount(payment.amount),
options
)
addHeaderIfMissing(
caches.headers,
PAYMENT_AMOUNT_HEADER,
PAYMENT_AMOUNT_HEADER_TEXT
)

addCellToRow(row, PAYMENT_REFERENCE_HEADER, payment.reference, options)
addHeaderIfMissing(
caches.headers,
PAYMENT_REFERENCE_HEADER,
PAYMENT_REFERENCE_HEADER_TEXT
)

if (payment.createdAt) {
addCellToRow(
row,
PAYMENT_DATE_HEADER,
formatPaymentDate(payment.createdAt),
options
)
addHeaderIfMissing(
caches.headers,
PAYMENT_DATE_HEADER,
PAYMENT_DATE_HEADER_TEXT
)
}
}

/**
* Generate a submission file for a form id
* @param {string} formId - the form id
Expand Down Expand Up @@ -379,40 +505,8 @@ export async function generateSubmissionsFile(
)

addCellToRow(row, SUBMISSION_FORM_NAME, formNameFromId, options)

formModel?.componentMap.forEach((component, key) => {
if (!component.isFormComponent) {
return
}

if (hasRepeater(component.page.pageDef)) {
const repeaterName = component.page.pageDef.repeat.options.name
const hasRepeaterData = repeaterName in record.data.repeaters
const items = hasRepeaterData ? record.data.repeaters[repeaterName] : []

for (let index = 0; index < items.length; index++) {
const value = getValue(items[index], key, component)
const componentKey = `${component.name} ${index + 1}`
const componentValue = `${component.label} ${index + 1}`

addCellToRow(row, componentKey, value, options)
addHeader(context, component, componentKey, componentValue)
}
} else if (component.type === ComponentType.FileUploadField) {
const files = record.data.files[component.name]
const fileLinks = Array.isArray(files)
? files.map((f) => f.userDownloadLink).join(' \r\n')
: ''

addCellToRow(row, component.name, fileLinks, options)
addHeader(context, component)
} else {
const value = getValue(record.data.main, key, component)

addCellToRow(row, component.name, value, options)
addHeader(context, component)
}
})
addFormComponentCellsToRow(formModel, row, context, record, options)
addPaymentCellsToRow(row, caches, record, options)

rows.push(row)
}
Expand Down Expand Up @@ -523,6 +617,21 @@ function sortHeaders(components, headers) {
const idxA = componentNames.indexOf(nameA)
const idxB = componentNames.indexOf(nameB)

// Both not found -> keep original order
if (idxA === -1 && idxB === -1) {
return 0
}

// A not found, B found -> A goes after B
if (idxA === -1) {
return 1
}

// A found, B not found -> A goes before B
if (idxB === -1) {
return -1
}

return idxA - idxB
})
}
Expand Down Expand Up @@ -556,7 +665,7 @@ export function buildPreHeaders(options) {
/**
* Build an xlsx workbook from the headers and rows
* @param {string} formId - the form id
* @param {[string, string][]} headers - the file header
* @param {[string, string][]} headers - the file headers (including payment headers)
* @param {Map<string, CellValue >[]} rows - the data rows
* @param {SpreadsheetOptions} [options]
*/
Expand Down
Loading
Loading