diff --git a/.github/workflows/check-pull-request.yml b/.github/workflows/check-pull-request.yml
index d960dd6fb..ceb69602a 100644
--- a/.github/workflows/check-pull-request.yml
+++ b/.github/workflows/check-pull-request.yml
@@ -193,7 +193,7 @@ jobs:
analysis:
name: Analysis
- if: ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') }}
+ if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-24.04
needs: [build, tasks]
@@ -207,7 +207,7 @@ jobs:
uses: actions/cache/restore@v4
with:
enableCrossOsArchive: true
- fail-on-cache-miss: false
+ fail-on-cache-miss: true
key: test-unit-${{ runner.os }}-node24-${{ github.sha }}
path: coverage
diff --git a/README.md b/README.md
index 74e508cd5..d78c6de3b 100644
--- a/README.md
+++ b/README.md
@@ -6,15 +6,18 @@ It is designed to be embedded in the frontend of a digital service and provide a
## Table of Contents
-- [Demo of DXT](#demo-of-dxt)
-- [Installation](#installation)
-- [Documentation](#documentation)
-- [Publishing the Package](#publishing-the-package)
- - [Semantic Versioning Control](#semantic-versioning-control)
- - [Major-Version Release Branches](#major-version-release-branches)
- - [Manual Workflow Triggers](#manual-workflow-triggers)
- - [Workflow Triggers](#workflow-triggers)
- - [Safety and Consistency](#safety-and-consistency)
+- [@defra/forms-engine-plugin](#defraforms-engine-plugin)
+ - [Table of Contents](#table-of-contents)
+ - [Demo of DXT](#demo-of-dxt)
+ - [Installation](#installation)
+ - [Documentation](#documentation)
+ - [Contributing](#contributing)
+ - [Publishing the package](#publishing-the-package)
+ - [Semantic Versioning Control](#semantic-versioning-control)
+ - [Major-Version Release Branches](#major-version-release-branches)
+ - [Manual Workflow Triggers](#manual-workflow-triggers)
+ - [Workflow Triggers](#workflow-triggers)
+ - [Safety and Consistency](#safety-and-consistency)
## Demo of DXT
diff --git a/jest.setup.cjs b/jest.setup.cjs
index d9e1df3b6..fad6f3680 100644
--- a/jest.setup.cjs
+++ b/jest.setup.cjs
@@ -13,3 +13,4 @@ process.env.UPLOADER_BUCKET_NAME = 'dummy-bucket'
process.env.GOOGLE_ANALYTICS_TRACKING_ID = 'G-123456789'
process.env.SUBMISSION_EMAIL_ADDRESS = 'dummy@defra.gov.uk'
process.env.ORDNANCE_SURVEY_API_KEY = 'dummy'
+process.env.PAYMENT_PROVIDER_API_KEY_TEST_formid = 'test-api-key'
diff --git a/package-lock.json b/package-lock.json
index 9382fcbd8..bfa523c95 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
- "@defra/forms-model": "^3.0.603",
+ "@defra/forms-model": "^3.0.611",
"@defra/hapi-tracing": "^1.29.0",
"@defra/interactive-map": "^0.0.3-alpha",
"@elastic/ecs-pino-format": "^1.5.0",
@@ -322,6 +322,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2141,6 +2142,7 @@
"integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -2233,6 +2235,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -2256,6 +2259,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -2308,9 +2312,9 @@
}
},
"node_modules/@defra/forms-model": {
- "version": "3.0.603",
- "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.603.tgz",
- "integrity": "sha512-EZBateAzf7GfPl1HmfYl3vPf384eLQEqFAXKmAtNgaxvLMT2qhkvwcluYg0Jq5Lyx0aRQD4CEPV4Xm42phTOzg==",
+ "version": "3.0.611",
+ "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.611.tgz",
+ "integrity": "sha512-QDOP0p2BRjJSjrQ/S0Mgd6QqwvOyRH8ByhKgzST8FSkn1CF88aEblSfqe506vd4Di0aS+EXzv1AVIitgxBQJMw==",
"license": "OGL-UK-3.0",
"dependencies": {
"@joi/date": "^2.1.1",
@@ -2418,9 +2422,9 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
- "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
+ "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2430,9 +2434,9 @@
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
- "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -3086,7 +3090,8 @@
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@foliojs-fork/fontkit": {
"version": "1.9.2",
@@ -6306,6 +6311,7 @@
"integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.46.4",
@@ -6336,6 +6342,7 @@
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.4",
"@typescript-eslint/types": "8.46.4",
@@ -7277,6 +7284,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8113,6 +8121,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -8232,6 +8241,7 @@
"integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -8368,6 +8378,7 @@
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@@ -9508,6 +9519,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=12"
}
@@ -10520,6 +10532,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -10755,6 +10768,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -10931,6 +10945,7 @@
"integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"builtins": "^5.0.1",
@@ -11007,6 +11022,7 @@
"integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==",
"dev": true,
"license": "ISC",
+ "peer": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
@@ -13225,6 +13241,7 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -14274,6 +14291,7 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -14283,6 +14301,7 @@
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
"integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
"license": "BSD-3-Clause",
+ "peer": true,
"dependencies": {
"@hapi/hoek": "^9.3.0",
"@hapi/topo": "^5.1.0",
@@ -14358,6 +14377,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -14783,6 +14803,7 @@
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz",
"integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==",
"license": "BSD-3-Clause",
+ "peer": true,
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
@@ -15087,7 +15108,6 @@
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz",
"integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==",
"license": "SEE LICENSE IN LICENSE.txt",
- "peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/geojson-types": "^1.0.2",
@@ -15120,29 +15140,25 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
"integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==",
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/mapbox-gl/node_modules/@mapbox/tiny-sdf": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz",
"integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==",
- "license": "BSD-2-Clause",
- "peer": true
+ "license": "BSD-2-Clause"
},
"node_modules/mapbox-gl/node_modules/@mapbox/unitbezier": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz",
"integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==",
- "license": "BSD-2-Clause",
- "peer": true
+ "license": "BSD-2-Clause"
},
"node_modules/mapbox-gl/node_modules/@mapbox/vector-tile": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
"integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
"license": "BSD-3-Clause",
- "peer": true,
"dependencies": {
"@mapbox/point-geometry": "~0.1.0"
}
@@ -15151,29 +15167,25 @@
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/mapbox-gl/node_modules/geojson-vt": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz",
"integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==",
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/mapbox-gl/node_modules/kdbush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz",
"integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==",
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/mapbox-gl/node_modules/pbf": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
"integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
"license": "BSD-3-Clause",
- "peer": true,
"dependencies": {
"ieee754": "^1.1.12",
"resolve-protobuf-schema": "^2.1.0"
@@ -15186,22 +15198,19 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/mapbox-gl/node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/mapbox-gl/node_modules/supercluster": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz",
"integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==",
"license": "ISC",
- "peer": true,
"dependencies": {
"kdbush": "^3.0.0"
}
@@ -15210,8 +15219,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz",
"integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==",
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/maplibre-gl": {
"version": "5.16.0",
@@ -16616,6 +16624,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -17192,6 +17201,7 @@
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -17265,15 +17275,6 @@
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
- "node_modules/preact": {
- "version": "8.5.3",
- "resolved": "https://registry.npmjs.org/preact/-/preact-8.5.3.tgz",
- "integrity": "sha512-O3kKP+1YdgqHOFsZF2a9JVdtqD+RPzCQc3rP+Ualf7V6rmRDchZ9MJbiGTT7LuyqFKZqlHSOyO/oMFmI2lVTsw==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -18504,6 +18505,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -19293,6 +19295,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4",
@@ -20086,6 +20089,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -20235,6 +20239,7 @@
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -20369,6 +20374,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -20687,6 +20693,7 @@
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -20755,6 +20762,7 @@
"integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.6.1",
"@webpack-cli/configtest": "^3.0.1",
diff --git a/package.json b/package.json
index 58101e4b0..609c65ad3 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,7 @@
},
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
- "@defra/forms-model": "^3.0.603",
+ "@defra/forms-model": "^3.0.611",
"@defra/hapi-tracing": "^1.29.0",
"@defra/interactive-map": "^0.0.3-alpha",
"@elastic/ecs-pino-format": "^1.5.0",
diff --git a/src/client/stylesheets/_payment-field.scss b/src/client/stylesheets/_payment-field.scss
new file mode 100644
index 000000000..af8a043b8
--- /dev/null
+++ b/src/client/stylesheets/_payment-field.scss
@@ -0,0 +1,8 @@
+@use "govuk-frontend" as *;
+
+.app-payment-field {
+ background-color: govuk-colour("light-grey");
+ border-top: 5px solid govuk-colour("blue");
+ padding: govuk-spacing(4);
+ margin-bottom: govuk-spacing(6);
+}
diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss
index 349c344c2..61f7d4c1d 100644
--- a/src/client/stylesheets/application.scss
+++ b/src/client/stylesheets/application.scss
@@ -2,6 +2,8 @@
@use "shared";
@use "code";
@use "tag-env";
+@use "location-fields";
+@use "payment-field";
// An example of some user-supplied styling
// Not great practice but it illustrates the point
diff --git a/src/index.ts b/src/index.ts
index a9e1835fb..6353419ad 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -23,7 +23,11 @@ const ordnanceSurveyApiKey = config.get('ordnanceSurveyApiKey')
* Main entrypoint to the application.
*/
async function startServer() {
- const server = await createServer({ ordnanceSurveyApiKey })
+ const server = await createServer({
+ ordnanceSurveyApiKey,
+ // Enable save and exit for devserver
+ saveAndExit: (_request, h) => h.redirect('/')
+ })
await server.start()
process.send?.('online')
diff --git a/src/server/constants.js b/src/server/constants.js
index 301539c14..b9e68652b 100644
--- a/src/server/constants.js
+++ b/src/server/constants.js
@@ -3,3 +3,4 @@ export const FORM_PREFIX = ''
export const EXTERNAL_STATE_PAYLOAD = 'EXTERNAL_STATE_PAYLOAD'
export const EXTERNAL_STATE_APPENDAGE = 'EXTERNAL_STATE_APPENDAGE'
export const COMPONENT_STATE_ERROR = 'COMPONENT_STATE_ERROR'
+export const PAYMENT_EXPIRED_NOTIFICATION = 'PAYMENT_EXPIRED_NOTIFICATION'
diff --git a/src/server/forms/payment-test.yaml b/src/server/forms/payment-test.yaml
new file mode 100644
index 000000000..39764fa96
--- /dev/null
+++ b/src/server/forms/payment-test.yaml
@@ -0,0 +1,42 @@
+---
+schema: 2
+name: Payment Test Form
+declaration: "
All the answers you have provided are true to the best of your knowledge.
"
+pages:
+ - title: What is your name?
+ path: '/person'
+ components:
+ - name: personName
+ title: What is your name?
+ type: TextField
+ shortDescription: Your name
+ options:
+ required: true
+ next:
+ - path: '/pay-for-your-licence'
+ - title: A page title
+ path: '/pay-for-your-licence'
+ components:
+ - name: pageGuidance
+ type: Html
+ title: Guidance
+ content: "Random guidance
"
+ options: {}
+ - name: licencePayment
+ title: Payment details required
+ type: PaymentField
+ options:
+ required: true
+ amount: 300
+ description: Processing fee for your application.
+ next:
+ - path: '/summary'
+ - title: Summary
+ path: '/summary'
+ controller: './pages/summary.js'
+ components: []
+ next: []
+conditions: []
+sections: []
+lists: []
+startPage: '/person'
diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml
index fa653641d..66683758b 100644
--- a/src/server/forms/register-as-a-unicorn-breeder.yaml
+++ b/src/server/forms/register-as-a-unicorn-breeder.yaml
@@ -242,6 +242,20 @@ pages:
content: 'Fill in this field'
options:
required: false
+ next:
+ - path: '/pay-for-your-licence'
+ - title: Pay for your licence
+ path: '/pay-for-your-licence'
+ section: section
+ components:
+ - name: licencePayment
+ title: Unicorn breeder licence fee
+ type: PaymentField
+ hint: You'll be redirected to GOV.UK Pay to complete your payment
+ options:
+ required: true
+ amount: 50
+ description: Unicorn breeder annual licence fee
next:
- path: '/summary'
conditions:
diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts
index 015274975..63a96d72e 100644
--- a/src/server/plugins/engine/components/FormComponent.ts
+++ b/src/server/plugins/engine/components/FormComponent.ts
@@ -30,6 +30,7 @@ export class FormComponent extends ComponentBase {
label: string
isFormComponent = true
+ isAppendageStateSingleObject = false
constructor(
def: FormComponentsDef,
diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts
new file mode 100644
index 000000000..d426f6ac1
--- /dev/null
+++ b/src/server/plugins/engine/components/PaymentField.test.ts
@@ -0,0 +1,611 @@
+import {
+ ComponentType,
+ type FormMetadata,
+ type PaymentFieldComponent
+} from '@defra/forms-model'
+
+import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
+import {
+ getAnswer,
+ type Field
+} from '~/src/server/plugins/engine/components/helpers/components.js'
+import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
+import { PaymentPreAuthError } from '~/src/server/plugins/engine/pageControllers/errors.js'
+import {
+ type FormContext,
+ type FormValue
+} from '~/src/server/plugins/engine/types.js'
+import {
+ type FormRequestPayload,
+ type FormResponseToolkit
+} from '~/src/server/routes/types.js'
+import { get, post, postJson } from '~/src/server/services/httpService.js'
+import definition from '~/test/form/definitions/blank.js'
+import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'
+
+jest.mock('~/src/server/services/httpService.ts')
+
+describe('PaymentField', () => {
+ let model: FormModel
+
+ beforeEach(() => {
+ model = new FormModel(definition, {
+ basePath: 'test'
+ })
+ })
+
+ describe('Defaults', () => {
+ let def: PaymentFieldComponent
+ let collection: ComponentCollection
+ let field: Field
+
+ beforeEach(() => {
+ def = {
+ title: 'Example payment field',
+ name: 'myComponent',
+ type: ComponentType.PaymentField,
+ options: {
+ amount: 100,
+ description: 'Test payment description'
+ }
+ } satisfies PaymentFieldComponent
+
+ collection = new ComponentCollection([def], { model })
+ field = collection.fields[0]
+ })
+
+ describe('Schema', () => {
+ it('uses component title as label as default', () => {
+ const { formSchema } = collection
+ const { keys } = formSchema.describe()
+
+ expect(keys).toHaveProperty(
+ 'myComponent',
+ expect.objectContaining({
+ flags: expect.objectContaining({
+ label: 'Example payment field'
+ })
+ })
+ )
+ })
+
+ it('uses component name as keys', () => {
+ const { formSchema } = collection
+ const { keys } = formSchema.describe()
+
+ expect(field.keys).toEqual(['myComponent'])
+ expect(field.collection).toBeUndefined()
+
+ for (const key of field.keys) {
+ expect(keys).toHaveProperty(key)
+ }
+ })
+
+ it('is required by default', () => {
+ const { formSchema } = collection
+ const { keys } = formSchema.describe()
+
+ expect(keys).toHaveProperty(
+ 'myComponent',
+ expect.objectContaining({
+ keys: expect.objectContaining({
+ amount: expect.objectContaining({
+ flags: expect.objectContaining({
+ presence: 'required'
+ })
+ })
+ })
+ })
+ )
+ })
+
+ it('adds errors for empty value', () => {
+ const payment = {
+ paymentId: '',
+ reference: '',
+ amount: 0,
+ description: '',
+ uuid: '',
+ formId: '',
+ isLivePayment: false
+ }
+ const result = collection.validate(
+ getFormData(payment as unknown as FormValue)
+ )
+
+ const errors = result.errors ?? []
+
+ expect(errors[0]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.paymentId'
+ })
+ )
+
+ expect(errors[1]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.reference'
+ })
+ )
+
+ expect(errors[2]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.description'
+ })
+ )
+
+ expect(errors[3]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.uuid'
+ })
+ )
+
+ expect(errors[4]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.formId'
+ })
+ )
+
+ expect(errors[5]).toEqual(
+ expect.objectContaining({
+ text: 'Select myComponent.preAuth'
+ })
+ )
+ })
+
+ it('adds errors for invalid values', () => {
+ const result1 = collection.validate(getFormData(['invalid']))
+ const result2 = collection.validate(
+ // @ts-expect-error - Allow invalid param for test
+ getFormData({ unknown: 'invalid' })
+ )
+
+ expect(result1.errors).toBeTruthy()
+ expect(result2.errors).toBeTruthy()
+ })
+ })
+
+ describe('State', () => {
+ const paymentForState = {
+ paymentId: 'payment-id',
+ reference: 'payment-ref',
+ amount: 150,
+ description: 'payment description',
+ uuid: 'ee501106-4ce1-4947-91a7-7cc1a335ccd8',
+ formId: 'formid',
+ isLivePayment: false
+ }
+ it('returns text from state', () => {
+ const state1 = getFormState(paymentForState as unknown as FormValue)
+ const state2 = getFormState(null)
+
+ const answer1 = getAnswer(field, state1)
+ const answer2 = getAnswer(field, state2)
+
+ expect(answer1).toBe('£150.00 - payment description')
+ expect(answer2).toBe('')
+ })
+ })
+
+ describe('View model', () => {
+ it('sets Nunjucks component defaults', () => {
+ const viewModel = field.getViewModel(getFormData(undefined))
+
+ expect(viewModel).toEqual(
+ expect.objectContaining({
+ label: { text: def.title },
+ name: 'myComponent',
+ id: 'myComponent',
+ amount: '100.00',
+ attributes: {},
+ description: 'Test payment description'
+ })
+ )
+ })
+
+ it('sets Nunjucks component values', () => {
+ const paymentForViewModel = {
+ paymentId: 'payment-id',
+ reference: 'payment-ref',
+ uuid: 'ee501106-4ce1-4947-91a7-7cc1a335ccd8',
+ formId: 'formid',
+ amount: 100,
+ description: 'Test payment description',
+ isLivePayment: false
+ } as unknown as FormValue
+ const viewModel = field.getViewModel(getFormData(paymentForViewModel))
+
+ expect(viewModel).toEqual(
+ expect.objectContaining({
+ label: { text: def.title },
+ name: 'myComponent',
+ id: 'myComponent',
+ amount: '100.00',
+ attributes: {},
+ description: 'Test payment description'
+ })
+ )
+ })
+ })
+
+ describe('AllPossibleErrors', () => {
+ it('should return errors', () => {
+ const errors = field.getAllPossibleErrors()
+ expect(errors.baseErrors).not.toBeEmpty()
+ expect(errors.advancedSettingsErrors).toBeEmpty()
+ })
+ })
+ })
+
+ describe('dispatcher and onSubmit', () => {
+ const def = {
+ title: 'Example payment field',
+ name: 'myComponent',
+ type: ComponentType.PaymentField,
+ options: {
+ amount: 100,
+ description: 'Test payment description'
+ }
+ } satisfies PaymentFieldComponent
+
+ const collection = new ComponentCollection([def], { model })
+ const paymentField = collection.fields[0] as PaymentField
+
+ describe('dispatcher', () => {
+ it('should create payment and redirect to gov pay', async () => {
+ const mockYarSet = jest.fn()
+ const mockRequest = {
+ server: {
+ plugins: {
+ // eslint-disable-next-line no-useless-computed-key
+ ['forms-engine-plugin']: {
+ baseUrl: 'base-url'
+ }
+ }
+ },
+ yar: {
+ set: mockYarSet
+ }
+ } as unknown as FormRequestPayload
+ const mockH = {
+ redirect: jest
+ .fn()
+ .mockReturnValueOnce({ code: jest.fn().mockReturnValueOnce('ok') })
+ } as unknown as FormResponseToolkit
+ const args = {
+ controller: {
+ model: {
+ formId: 'formid',
+ basePath: 'base-path',
+ name: 'PaymentModel'
+ },
+ getState: jest
+ .fn()
+ .mockResolvedValueOnce({ $$__referenceNumber: 'pay-ref-123' })
+ },
+ component: paymentField,
+ sourceUrl: 'http://localhost:3009/test-payment',
+ isLive: false,
+ isPreview: true
+ }
+ // @ts-expect-error - partial mock
+ jest.mocked(postJson).mockResolvedValueOnce({
+ payload: {
+ state: {
+ status: 'created'
+ },
+ payment_id: 'new-payment-id',
+ _links: {
+ next_url: {
+ href: '/next-url'
+ }
+ }
+ }
+ })
+
+ const res = await PaymentField.dispatcher(mockRequest, mockH, args)
+ expect(res).toBe('ok')
+ expect(mockYarSet).toHaveBeenCalledWith(expect.any(String), {
+ amount: 100,
+ componentName: 'myComponent',
+ description: 'Test payment description',
+ failureUrl: 'http://localhost:3009/test-payment',
+ formId: 'formid',
+ isLivePayment: false,
+ paymentId: 'new-payment-id',
+ reference: 'pay-ref-123',
+ returnUrl: 'base-url/base-path/summary',
+ uuid: expect.any(String)
+ })
+ })
+
+ it('should redirect to summary if payment is already pre-authorised', async () => {
+ const mockRedirectCode = jest.fn().mockReturnValueOnce('redirected')
+ const mockH = {
+ redirect: jest.fn().mockReturnValueOnce({ code: mockRedirectCode })
+ } as unknown as FormResponseToolkit
+ const mockRequest = {
+ server: {
+ plugins: {
+ // eslint-disable-next-line no-useless-computed-key
+ ['forms-engine-plugin']: {
+ baseUrl: 'base-url'
+ }
+ }
+ },
+ yar: {
+ set: jest.fn()
+ }
+ } as unknown as FormRequestPayload
+ const args = {
+ controller: {
+ model: {
+ formId: 'formid',
+ basePath: 'base-path',
+ name: 'PaymentModel'
+ },
+ getState: jest.fn().mockResolvedValueOnce({
+ $$__referenceNumber: 'pay-ref-123',
+ myComponent: {
+ paymentId: 'existing-payment-id',
+ amount: 100,
+ description: 'Test payment',
+ preAuth: {
+ status: 'success',
+ createdAt: '2026-01-29T12:00:00.000Z'
+ }
+ }
+ })
+ },
+ component: paymentField,
+ sourceUrl: 'http://localhost:3009/test-payment',
+ isLive: false,
+ isPreview: true
+ }
+
+ const res = await PaymentField.dispatcher(mockRequest, mockH, args)
+
+ expect(res).toBe('redirected')
+ expect(mockH.redirect).toHaveBeenCalledWith(
+ 'base-url/base-path/summary'
+ )
+ expect(mockRedirectCode).toHaveBeenCalledWith(303)
+ expect(postJson).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('onSubmit', () => {
+ it('should throw if missing state', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+
+ const error = await paymentField
+ .onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ { state: {} } as FormContext
+ )
+ .catch((e: unknown) => e)
+
+ expect(error).toBeInstanceOf(PaymentPreAuthError)
+ expect((error as PaymentPreAuthError).component).toBe(paymentField)
+ expect((error as PaymentPreAuthError).userMessage).toBe(
+ 'Complete the payment to continue'
+ )
+ })
+
+ it('should ignore if our state says payment already captured', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+
+ await paymentField.onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ capture: {
+ status: 'success'
+ },
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc'
+ }
+ }
+ } as unknown as FormContext
+ )
+ expect(get).not.toHaveBeenCalled()
+ expect(post).not.toHaveBeenCalled()
+ })
+
+ it('should mark payment already captured according to gov pay', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+ jest
+ .mocked(get)
+ // @ts-expect-error - partial mock
+ .mockResolvedValueOnce({
+ payload: { amount: 10000, state: { status: 'success' } }
+ })
+ await paymentField.onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ paymentId: 'payment-id',
+ amount: 100,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'formid'
+ }
+ }
+ } as unknown as FormContext
+ )
+ expect(get).toHaveBeenCalled()
+ expect(post).not.toHaveBeenCalled()
+ })
+
+ it('should throw if bad status', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+ jest
+ .mocked(get)
+ // @ts-expect-error - partial mock
+ .mockResolvedValueOnce({
+ payload: { amount: 10000, state: { status: 'bad' } }
+ })
+ const error = await paymentField
+ .onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ paymentId: 'payment-id',
+ amount: 100,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'formid'
+ }
+ }
+ } as unknown as FormContext
+ )
+ .catch((e: unknown) => e)
+
+ expect(error).toBeInstanceOf(PaymentPreAuthError)
+ expect((error as PaymentPreAuthError).component).toBe(paymentField)
+ expect((error as PaymentPreAuthError).userMessage).toBe(
+ 'Your payment authorisation has expired. Please add your payment details again.'
+ )
+ })
+
+ it('should throw if error during capture', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+ jest
+ .mocked(get)
+ // @ts-expect-error - partial mock
+ .mockResolvedValueOnce({
+ payload: { amount: 10000, state: { status: 'capturable' } }
+ })
+ // @ts-expect-error - partial mock
+ jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 400 } })
+ const error = await paymentField
+ .onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'formid'
+ }
+ }
+ } as unknown as FormContext
+ )
+ .catch((e: unknown) => e)
+
+ expect(error).toBeInstanceOf(PaymentPreAuthError)
+ expect((error as PaymentPreAuthError).component).toBe(paymentField)
+ expect((error as PaymentPreAuthError).userMessage).toBe(
+ 'There was a problem and your form was not submitted. Try submitting the form again.'
+ )
+ })
+
+ it('should throw if amount mismatch', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+ jest
+ .mocked(get)
+ // @ts-expect-error - partial mock
+ .mockResolvedValueOnce({
+ payload: { amount: 5000, state: { status: 'capturable' } }
+ })
+ // @ts-expect-error - partial mock
+ jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 200 } })
+ const error = await paymentField
+ .onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'formid'
+ }
+ }
+ } as unknown as FormContext
+ )
+ .catch((e: unknown) => e)
+
+ expect(error).toBeInstanceOf(PaymentPreAuthError)
+ expect((error as PaymentPreAuthError).component).toBe(paymentField)
+ expect((error as PaymentPreAuthError).userMessage).toBe(
+ 'The pre-authorised payment amount is somehow different from that requested. Try adding payment details again.'
+ )
+ })
+
+ it('should capture payment if no errors', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+ jest
+ .mocked(get)
+ // @ts-expect-error - partial mock
+ .mockResolvedValueOnce({
+ payload: { amount: 10000, state: { status: 'capturable' } }
+ })
+ // @ts-expect-error - partial mock
+ jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 200 } })
+ await paymentField.onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'formid'
+ }
+ }
+ } as unknown as FormContext
+ )
+ expect(get).toHaveBeenCalled()
+ expect(post).toHaveBeenCalled()
+ })
+ })
+
+ describe('getFormValue', () => {
+ it('should return undefined', () => {
+ expect(paymentField.getFormValue({})).toBeUndefined()
+ })
+ it('should return value', () => {
+ const payment = {
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'formid'
+ }
+ expect(paymentField.getFormValue(payment)).toEqual(payment)
+ })
+ })
+
+ describe('isState', () => {
+ it('should return false if not valid state', () => {
+ expect(paymentField.isState({})).toBe(false)
+ })
+ it('should return value', () => {
+ const payment = {
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'formid'
+ }
+ expect(paymentField.isState(payment)).toBe(true)
+ })
+ })
+ })
+})
diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts
new file mode 100644
index 000000000..e056ffc03
--- /dev/null
+++ b/src/server/plugins/engine/components/PaymentField.ts
@@ -0,0 +1,367 @@
+import { randomUUID } from 'node:crypto'
+
+import {
+ type FormMetadata,
+ type PaymentFieldComponent
+} from '@defra/forms-model'
+import { StatusCodes } from 'http-status-codes'
+import joi, { type ObjectSchema } from 'joi'
+
+import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
+import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
+import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js'
+import {
+ PaymentErrorTypes,
+ PaymentPreAuthError,
+ PaymentSubmissionError
+} from '~/src/server/plugins/engine/pageControllers/errors.js'
+import {
+ type AnyFormRequest,
+ type FormContext,
+ type FormRequestPayload,
+ type FormResponseToolkit
+} from '~/src/server/plugins/engine/types/index.js'
+import {
+ type ErrorMessageTemplateList,
+ type FormPayload,
+ type FormState,
+ type FormStateValue,
+ type FormSubmissionError,
+ type FormSubmissionState
+} from '~/src/server/plugins/engine/types.js'
+import { createPaymentService } from '~/src/server/plugins/payment/helper.js'
+
+export class PaymentField extends FormComponent {
+ declare options: PaymentFieldComponent['options']
+ declare formSchema: ObjectSchema
+ declare stateSchema: ObjectSchema
+ isAppendageStateSingleObject = true
+
+ constructor(
+ def: PaymentFieldComponent,
+ props: ConstructorParameters[1]
+ ) {
+ super(def, props)
+
+ this.options = def.options
+
+ const paymentStateSchema = joi
+ .object({
+ paymentId: joi.string().required(),
+ reference: joi.string().required(),
+ amount: joi.number().required(),
+ description: joi.string().required(),
+ uuid: joi.string().uuid().required(),
+ formId: joi.string().required(),
+ isLivePayment: joi.boolean().required(),
+ preAuth: joi
+ .object({
+ status: joi
+ .string()
+ .valid('success', 'failed', 'started')
+ .required(),
+ createdAt: joi.string().isoDate().required()
+ })
+ .required()
+ })
+ .unknown(true)
+ .label(this.label)
+
+ this.formSchema = paymentStateSchema
+ // 'required()' forces the payment page to be invalid until we have valid payment state
+ // i.e. the user will automatically be directed back to the payment page
+ // if they attempt to access future pages when no payment entered yet
+ this.stateSchema = paymentStateSchema.required()
+ }
+
+ /**
+ * Gets the PaymentState from form submission state
+ */
+ getPaymentStateFromState(
+ state: FormSubmissionState
+ ): PaymentState | undefined {
+ const value = state[this.name]
+ return this.isPaymentState(value) ? value : undefined
+ }
+
+ getDisplayStringFromState(state: FormSubmissionState): string {
+ const value = this.getPaymentStateFromState(state)
+
+ if (!value) {
+ return ''
+ }
+
+ return `£${value.amount.toFixed(2)} - ${value.description}`
+ }
+
+ getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
+ const viewModel = super.getViewModel(payload, errors)
+
+ // Payload is pre-populated from state if a payment has already been made
+ const paymentState = this.isPaymentState(payload[this.name] as unknown)
+ ? (payload[this.name] as unknown as PaymentState)
+ : undefined
+
+ // When user initially visits the payment page, there is no payment state yet so the amount is read form the form definition.
+ const amount = paymentState?.amount ?? this.options.amount
+
+ const formattedAmount = amount.toFixed(2)
+
+ return {
+ ...viewModel,
+ amount: formattedAmount,
+ description: this.options.description,
+ paymentState
+ }
+ }
+
+ /**
+ * Type guard to check if value is PaymentState
+ */
+ isPaymentState(value: unknown): value is PaymentState {
+ return PaymentField.isPaymentState(value)
+ }
+
+ /**
+ * Static type guard to check if value is PaymentState
+ */
+ static isPaymentState(value: unknown): value is PaymentState {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false
+ }
+
+ const state = value as PaymentState
+ return (
+ typeof state.paymentId === 'string' &&
+ typeof state.amount === 'number' &&
+ typeof state.description === 'string'
+ )
+ }
+
+ /**
+ * Override base isState to validate PaymentState
+ */
+ isState(value?: FormStateValue | FormState): value is FormState {
+ return this.isPaymentState(value)
+ }
+
+ getFormValue(value?: FormStateValue | FormState) {
+ return this.isPaymentState(value)
+ ? (value as unknown as NonNullable)
+ : undefined
+ }
+
+ getContextValueFromState(state: FormSubmissionState) {
+ return this.isPaymentState(state)
+ ? `Reference: ${state.reference}\nAmount: ${state.amount.toFixed(2)}`
+ : ''
+ }
+
+ /**
+ * For error preview page that shows all possible errors on a component
+ */
+ getAllPossibleErrors(): ErrorMessageTemplateList {
+ return PaymentField.getAllPossibleErrors()
+ }
+
+ /**
+ * Static version of getAllPossibleErrors that doesn't require a component instance.
+ */
+ static getAllPossibleErrors(): ErrorMessageTemplateList {
+ return {
+ baseErrors: [
+ {
+ type: 'paymentRequired',
+ template: 'Complete the payment to continue'
+ }
+ ],
+ advancedSettingsErrors: []
+ }
+ }
+
+ /**
+ * Dispatcher for external redirect to GOV.UK Pay
+ */
+ static async dispatcher(
+ request: FormRequestPayload,
+ h: FormResponseToolkit,
+ args: PaymentDispatcherArgs
+ ): Promise {
+ const { options, name: componentName } = args.component
+ const { model } = args.controller
+
+ const state = await args.controller.getState(request)
+ const { baseUrl } = getPluginOptions(request.server)
+ const summaryUrl = `${baseUrl}/${model.basePath}/summary`
+
+ const existingPaymentState = state[componentName]
+ if (
+ PaymentField.isPaymentState(existingPaymentState) &&
+ existingPaymentState.preAuth?.status === 'success'
+ ) {
+ return h.redirect(summaryUrl).code(StatusCodes.SEE_OTHER)
+ }
+
+ const isLivePayment = args.isLive && !args.isPreview
+ const formId = args.controller.model.formId
+ const paymentService = createPaymentService(isLivePayment, formId)
+
+ const uuid = randomUUID()
+
+ const reference = state.$$__referenceNumber as string
+ const amount = options.amount
+
+ const description = options.description
+
+ const slug = `/${model.basePath}`
+
+ const payCallbackUrl = `${baseUrl}/payment-callback?uuid=${uuid}`
+ const paymentPageUrl = args.sourceUrl
+
+ const amountInPence = Math.round(amount * 100)
+ const payment = await paymentService.createPayment(
+ amountInPence,
+ description,
+ payCallbackUrl,
+ reference,
+ { formId, slug }
+ )
+
+ const sessionData: PaymentSessionData = {
+ uuid,
+ formId,
+ reference,
+ amount,
+ description,
+ paymentId: payment.paymentId,
+ componentName,
+ returnUrl: summaryUrl,
+ failureUrl: paymentPageUrl,
+ isLivePayment
+ }
+
+ request.yar.set(`payment-${uuid}`, sessionData)
+
+ return h.redirect(payment.paymentUrl).code(StatusCodes.SEE_OTHER)
+ }
+
+ /**
+ * Called on form submission to capture the payment
+ * @see https://docs.payments.service.gov.uk/delayed_capture/#delay-taking-a-payment
+ */
+ async onSubmit(
+ request: FormRequestPayload,
+ _metadata: FormMetadata,
+ context: FormContext
+ ): Promise {
+ const paymentState = this.getPaymentStateFromState(context.state)
+
+ if (!paymentState) {
+ throw new PaymentPreAuthError(
+ this,
+ 'Complete the payment to continue',
+ true,
+ PaymentErrorTypes.PaymentIncomplete
+ )
+ }
+
+ if (paymentState.capture?.status === 'success') {
+ return
+ }
+
+ const { paymentId, isLivePayment, formId } = paymentState
+ const paymentService = createPaymentService(isLivePayment, formId)
+
+ /**
+ * @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle
+ */
+ const status = await paymentService.getPaymentStatus(paymentId)
+
+ PaymentSubmissionError.checkPaymentAmount(
+ status.amount,
+ this.options.amount,
+ this
+ )
+
+ if (status.state.status === 'success') {
+ await this.markPaymentCaptured(request, paymentState)
+ return
+ }
+
+ if (status.state.status !== 'capturable') {
+ throw new PaymentPreAuthError(
+ this,
+ 'Your payment authorisation has expired. Please add your payment details again.',
+ true,
+ PaymentErrorTypes.PaymentExpired
+ )
+ }
+
+ const captured = await paymentService.capturePayment(paymentId)
+
+ if (!captured) {
+ throw new PaymentPreAuthError(
+ this,
+ 'There was a problem and your form was not submitted. Try submitting the form again.',
+ false
+ )
+ }
+
+ await this.markPaymentCaptured(request, paymentState)
+ }
+
+ /**
+ * Updates payment state to mark capture as successful
+ * This ensures we don't try to re-capture on submission retry
+ */
+ private async markPaymentCaptured(
+ request: FormRequestPayload,
+ paymentState: PaymentState
+ ): Promise {
+ const updatedState: PaymentState = {
+ ...paymentState,
+ capture: {
+ status: 'success',
+ createdAt: new Date().toISOString()
+ }
+ }
+
+ if (this.page) {
+ const currentState = await this.page.getState(request)
+ await this.page.mergeState(request, currentState, {
+ [this.name]: updatedState
+ })
+ }
+ }
+}
+
+export interface PaymentDispatcherArgs {
+ controller: {
+ model: {
+ formId: string
+ basePath: string
+ name: string
+ }
+ getState: (request: AnyFormRequest) => Promise
+ }
+ component: PaymentField
+ sourceUrl: string
+ isLive: boolean
+ isPreview: boolean
+}
+
+/**
+ * Session data stored when dispatching to GOV.UK Pay
+ */
+export interface PaymentSessionData {
+ uuid: string
+ formId: string
+ reference: string
+ amount: number
+ description: string
+ paymentId: string
+ componentName: string
+ returnUrl: string
+ failureUrl: string
+ isLivePayment: boolean
+}
diff --git a/src/server/plugins/engine/components/PaymentField.types.ts b/src/server/plugins/engine/components/PaymentField.types.ts
new file mode 100644
index 000000000..b68e6aee8
--- /dev/null
+++ b/src/server/plugins/engine/components/PaymentField.types.ts
@@ -0,0 +1,21 @@
+/**
+ * Component state stored in session after pre-auth
+ */
+export interface PaymentState {
+ paymentId: string
+ reference: string
+ amount: number
+ description: string
+ uuid: string
+ formId: string
+ isLivePayment: boolean
+ payerEmail?: string
+ capture?: {
+ status: 'success' | 'failed'
+ createdAt: string
+ }
+ preAuth?: {
+ status: 'success' | 'failed' | 'started'
+ createdAt: string
+ }
+}
diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts
index 66eb2aebf..f21ea4a7e 100644
--- a/src/server/plugins/engine/components/UkAddressField.ts
+++ b/src/server/plugins/engine/components/UkAddressField.ts
@@ -284,7 +284,8 @@ export class UkAddressField extends FormComponent {
)
}
- static dispatcher(
+ // eslint-disable-next-line @typescript-eslint/require-await
+ static async dispatcher(
request: FormRequestPayload,
h: FormResponseToolkit,
args: PostcodeLookupExternalArgs
diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts
index d307e6517..c60b61712 100644
--- a/src/server/plugins/engine/components/helpers/components.ts
+++ b/src/server/plugins/engine/components/helpers/components.ts
@@ -35,6 +35,7 @@ export type Field = InstanceType<
| typeof Components.UkAddressField
| typeof Components.FileUploadField
| typeof Components.HiddenField
+ | typeof Components.PaymentField
>
// Guidance component instances only
@@ -191,6 +192,10 @@ export function createComponent(
case ComponentType.HiddenField:
component = new Components.HiddenField(def, options)
break
+
+ case ComponentType.PaymentField:
+ component = new Components.PaymentField(def, options)
+ break
}
if (typeof component === 'undefined') {
diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts
index da04c5a62..2b621a65f 100644
--- a/src/server/plugins/engine/components/index.ts
+++ b/src/server/plugins/engine/components/index.ts
@@ -29,3 +29,4 @@ export { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRef
export { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js'
export { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js'
export { HiddenField } from '~/src/server/plugins/engine/components/HiddenField.js'
+export { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts
index 7172e07ad..f41fc0d98 100644
--- a/src/server/plugins/engine/models/SummaryViewModel.ts
+++ b/src/server/plugins/engine/models/SummaryViewModel.ts
@@ -1,5 +1,7 @@
import { SchemaVersion, type Section } from '@defra/forms-model'
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
+import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
import {
getAnswer,
type Field
@@ -52,6 +54,8 @@ export class SummaryViewModel {
hasMissingNotificationEmail?: boolean
components?: ComponentViewModel[]
allowSaveAndExit = false
+ paymentState?: PaymentState
+ paymentDetails?: CheckAnswers
constructor(
request: FormContextRequest,
@@ -144,6 +148,10 @@ export class SummaryViewModel {
)
} else {
for (const field of collection.fields) {
+ // PaymentField is rendered in its own section, skip it here
+ if (field instanceof PaymentField) {
+ continue
+ }
items.push(ItemField(page, state, field, { path, errors }))
}
}
diff --git a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts
new file mode 100644
index 000000000..d4f8c5129
--- /dev/null
+++ b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts
@@ -0,0 +1,147 @@
+import { format as dateFormat } from 'date-fns'
+import { outdent } from 'outdent'
+
+import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
+import { FormModel } from '~/src/server/plugins/engine/models/index.js'
+import { format } from '~/src/server/plugins/engine/outputFormatters/human/v1.js'
+import {
+ SummaryPageController,
+ getFormSubmissionData
+} from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
+import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
+import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js'
+import { FormStatus } from '~/src/server/routes/types.js'
+import definitionPayment from '~/test/form/definitions/payment.js'
+
+describe('v1 human formatter', () => {
+ describe('Payment', () => {
+ const modelPayment = new FormModel(definitionPayment, {
+ basePath: 'test'
+ })
+
+ const submitResponse = {
+ message: 'Submit completed',
+ result: {
+ files: {
+ main: '00000000-0000-0000-0000-000000000000',
+ repeaters: {
+ pizza: '11111111-1111-1111-1111-111111111111'
+ }
+ }
+ }
+ }
+
+ const statePayment = {
+ $$__referenceNumber: 'foobar',
+ licenceLength: 365,
+ fullName: 'John Smith',
+ paymentField: {
+ paymentId: 'payment-id',
+ reference: 'payment-ref',
+ amount: 250,
+ description: 'Payment desc',
+ uuid: 'uuid',
+ formId: 'form-id',
+ isLivePayment: false,
+ preAuth: {
+ status: 'success',
+ createdAt: '2026-01-02T11:02:04+0000'
+ }
+ } as PaymentState
+ }
+
+ const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
+
+ const requestPayment = buildFormContextRequest({
+ method: 'get',
+ url: pageUrl,
+ path: pageUrl.pathname,
+ params: {
+ path: 'summary',
+ slug: 'payment'
+ },
+ query: {},
+ app: { model: modelPayment }
+ })
+
+ const pageDefPayment = definitionPayment.pages[2]
+
+ const controllerPayment = new SummaryPageController(
+ modelPayment,
+ pageDefPayment
+ )
+
+ const contextPayment = modelPayment.getFormContext(
+ requestPayment,
+ statePayment as unknown as FormSubmissionState
+ )
+ const summaryViewModelPayment = controllerPayment.getSummaryViewModel(
+ requestPayment,
+ contextPayment
+ )
+
+ const itemsPayment = getFormSubmissionData(
+ summaryViewModelPayment.context,
+ summaryViewModelPayment.details
+ )
+
+ it('should add payment details', () => {
+ const body = format(
+ contextPayment,
+ itemsPayment,
+ modelPayment,
+ submitResponse,
+ {
+ state: FormStatus.Draft,
+ isPreview: true
+ }
+ )
+
+ const dateNow = new Date()
+
+ expect(body).toContain(
+ outdent`
+ ${definitionPayment.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
+
+ ---
+
+ ## Which fishing licence do you want to get?
+
+ 12 months \\(365\\)
+
+ ---
+
+ ## What\\'s your name?
+
+ John Smith
+
+ ---
+
+ [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000)
+
+ ---
+
+ # Your payment of £250.00 was successful
+
+ ## Payment for
+
+ Payment desc
+
+ ---
+
+ ## Total amount
+
+ £250.00
+
+ ---
+
+ ## Date of payment
+
+ 2 January 2026 11:02am
+
+ ---
+ `
+ )
+ })
+ })
+})
diff --git a/src/server/plugins/engine/outputFormatters/human/v1.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.test.ts
index b2c4dac18..d4ebb36d8 100644
--- a/src/server/plugins/engine/outputFormatters/human/v1.test.ts
+++ b/src/server/plugins/engine/outputFormatters/human/v1.test.ts
@@ -11,133 +11,135 @@ import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControl
import { FormStatus } from '~/src/server/routes/types.js'
import definition from '~/test/form/definitions/repeat-mixed.js'
-const itemId1 = 'abc-123'
-const itemId2 = 'xyz-987'
-
-const submitResponse = {
- message: 'Submit completed',
- result: {
- files: {
- main: '00000000-0000-0000-0000-000000000000',
- repeaters: {
- pizza: '11111111-1111-1111-1111-111111111111'
+describe('v1 human formatter', () => {
+ const itemId1 = 'abc-123'
+ const itemId2 = 'xyz-987'
+
+ const submitResponse = {
+ message: 'Submit completed',
+ result: {
+ files: {
+ main: '00000000-0000-0000-0000-000000000000',
+ repeaters: {
+ pizza: '11111111-1111-1111-1111-111111111111'
+ }
}
}
}
-}
-const model = new FormModel(definition, {
- basePath: 'test'
-})
+ const model = new FormModel(definition, {
+ basePath: 'test'
+ })
-const state = {
- $$__referenceNumber: 'foobar',
- orderType: 'delivery',
- pizza: [
- {
- toppings: 'Ham',
- quantity: 2,
- itemId: itemId1
- },
- {
- toppings: 'Pepperoni',
- quantity: 1,
- itemId: itemId2
- }
- ]
-}
-
-const pageDef = definition.pages[2]
-const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
-
-const controller = new SummaryPageController(model, pageDef)
-
-const request = buildFormContextRequest({
- method: 'get',
- url: pageUrl,
- path: pageUrl.pathname,
- params: {
- path: 'pizza-order',
- slug: 'repeat'
- },
- query: {},
- app: { model }
-})
+ const state = {
+ $$__referenceNumber: 'foobar',
+ orderType: 'delivery',
+ pizza: [
+ {
+ toppings: 'Ham',
+ quantity: 2,
+ itemId: itemId1
+ },
+ {
+ toppings: 'Pepperoni',
+ quantity: 1,
+ itemId: itemId2
+ }
+ ]
+ }
-const context = model.getFormContext(request, state)
-const summaryViewModel = controller.getSummaryViewModel(request, context)
+ const pageDef = definition.pages[2]
+ const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
-const items = getFormSubmissionData(
- summaryViewModel.context,
- summaryViewModel.details
-)
+ const controller = new SummaryPageController(model, pageDef)
-describe('getPersonalisation', () => {
- it.each([
- {
- state: FormStatus.Live,
- isPreview: false
+ const request = buildFormContextRequest({
+ method: 'get',
+ url: pageUrl,
+ path: pageUrl.pathname,
+ params: {
+ path: 'pizza-order',
+ slug: 'repeat'
},
- {
- state: FormStatus.Draft,
- isPreview: true
- }
- ])('should personalise $state email', (formStatus) => {
- const body = format(context, items, model, submitResponse, formStatus)
-
- const dateNow = new Date()
- const dateExpiry = addDays(dateNow, 90)
+ query: {},
+ app: { model }
+ })
- // Check for link expiry message
- expect(body).toContain(
- `^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}`
- )
+ const context = model.getFormContext(request, state)
+ const summaryViewModel = controller.getSummaryViewModel(request, context)
+
+ const items = getFormSubmissionData(
+ summaryViewModel.context,
+ summaryViewModel.details
+ )
+
+ describe('getPersonalisation', () => {
+ it.each([
+ {
+ state: FormStatus.Live,
+ isPreview: false
+ },
+ {
+ state: FormStatus.Draft,
+ isPreview: true
+ }
+ ])('should personalise $state email', (formStatus) => {
+ const body = format(context, items, model, submitResponse, formStatus)
- expect(body).toContain(
- outdent`
- ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
+ const dateNow = new Date()
+ const dateExpiry = addDays(dateNow, 90)
- ---
+ // Check for link expiry message
+ expect(body).toContain(
+ `^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}`
+ )
- ## How would you like to receive your pizza?
+ expect(body).toContain(
+ outdent`
+ ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
- Delivery
+ ---
- ---
+ ## How would you like to receive your pizza?
- ## Pizza
+ Delivery
- [Download Pizza \\(CSV\\)](https://forms-designer/file-download/11111111-1111-1111-1111-111111111111)
+ ---
- ---
+ ## Pizza
- [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000)
- `
- )
- })
+ [Download Pizza \\(CSV\\)](https://forms-designer/file-download/11111111-1111-1111-1111-111111111111)
- it('should add test warnings to preview email only', () => {
- const formStatus = {
- state: FormStatus.Draft,
- isPreview: true
- }
+ ---
- const body1 = format(context, items, model, submitResponse, {
- state: FormStatus.Live,
- isPreview: false
+ [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000)
+ `
+ )
})
- const body2 = format(context, items, model, submitResponse, {
- state: FormStatus.Draft,
- isPreview: true
- })
+ it('should add test warnings to preview email only', () => {
+ const formStatus = {
+ state: FormStatus.Draft,
+ isPreview: true
+ }
+
+ const body1 = format(context, items, model, submitResponse, {
+ state: FormStatus.Live,
+ isPreview: false
+ })
- expect(body1).not.toContain(
- `This is a test of the ${definition.name} ${formStatus.state} form`
- )
+ const body2 = format(context, items, model, submitResponse, {
+ state: FormStatus.Draft,
+ isPreview: true
+ })
- expect(body2).toContain(
- `This is a test of the ${definition.name} ${formStatus.state} form`
- )
+ expect(body1).not.toContain(
+ `This is a test of the ${definition.name} ${formStatus.state} form`
+ )
+
+ expect(body2).toContain(
+ `This is a test of the ${definition.name} ${formStatus.state} form`
+ )
+ })
})
})
diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts
index d83bcebc6..9e2c30174 100644
--- a/src/server/plugins/engine/outputFormatters/human/v1.ts
+++ b/src/server/plugins/engine/outputFormatters/human/v1.ts
@@ -7,10 +7,18 @@ import { addDays, format as dateFormat } from 'date-fns'
import { config } from '~/src/config/index.js'
import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers/index.js'
+import { PaymentField } from '~/src/server/plugins/engine/components/index.js'
import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js'
import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
-import { type DetailItem } from '~/src/server/plugins/engine/models/types.js'
+import {
+ type DetailItem,
+ type DetailItemField
+} from '~/src/server/plugins/engine/models/types.js'
import { type FormContext } from '~/src/server/plugins/engine/types.js'
+import {
+ formatPaymentAmount,
+ formatPaymentDate
+} from '~/src/server/plugins/payment/helper.js'
const designerUrl = config.get('designerUrl')
@@ -49,7 +57,10 @@ export function format(
lines.push(`${formName} form received at ${escapeMarkdown(formattedNow)}.\n`)
lines.push('---\n')
- items.forEach((item) => {
+ const regularItems = items.filter((item) => !isPaymentItem(item))
+ const paymentItems = items.filter((item) => isPaymentItem(item))
+
+ regularItems.forEach((item) => {
const label = escapeMarkdown(item.label)
lines.push(`## ${label}\n`)
@@ -73,5 +84,53 @@ export function format(
const filename = escapeMarkdown('Download main form (CSV)')
lines.push(`[${filename}](${designerUrl}/file-download/${files.main})\n`)
+ appendPaymentSection(paymentItems, lines)
+
return lines.join('\n')
}
+
+/**
+ * Check if an item is a PaymentField
+ */
+function isPaymentItem(item: DetailItem): boolean {
+ if ('subItems' in item) {
+ return false
+ }
+ return item.field instanceof PaymentField
+}
+
+/**
+ * Appends the payment details section to the email lines if payment exists
+ */
+function appendPaymentSection(paymentItems: DetailItem[], lines: string[]) {
+ if (paymentItems.length === 0) {
+ return
+ }
+
+ const paymentItem = paymentItems[0] as DetailItemField
+ const paymentField = paymentItem.field as PaymentField
+ const paymentState = paymentField.getPaymentStateFromState(paymentItem.state)
+
+ if (!paymentState) {
+ return
+ }
+
+ const formattedAmount = formatPaymentAmount(paymentState.amount)
+ const dateOfPayment = paymentState.preAuth?.createdAt
+ ? formatPaymentDate(paymentState.preAuth.createdAt)
+ : ''
+
+ lines.push(
+ '---\n',
+ `# Your payment of ${formattedAmount} was successful\n`,
+ '## Payment for\n',
+ `${escapeMarkdown(paymentState.description)}\n`,
+ '---\n',
+ '## Total amount\n',
+ `${formattedAmount}\n`,
+ '---\n',
+ '## Date of payment\n',
+ `${escapeMarkdown(dateOfPayment)}\n`,
+ '---\n'
+ )
+}
diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts
new file mode 100644
index 000000000..c16a81288
--- /dev/null
+++ b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts
@@ -0,0 +1,115 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+
+import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
+import { FormModel } from '~/src/server/plugins/engine/models/index.js'
+import { format } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js'
+import {
+ SummaryPageController,
+ getFormSubmissionData
+} from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
+import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
+import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js'
+import { FormStatus } from '~/src/server/routes/types.js'
+import definition from '~/test/form/definitions/payment.js'
+
+const submitResponse = {
+ message: 'Submit completed',
+ result: {
+ files: {
+ main: '00000000-0000-0000-0000-000000000000',
+ repeaters: {
+ pizza: '11111111-1111-1111-1111-111111111111'
+ }
+ }
+ }
+}
+
+const model = new FormModel(definition, {
+ basePath: 'test'
+})
+
+const formStatus = {
+ isPreview: false,
+ state: FormStatus.Live
+}
+
+const state = {
+ $$__referenceNumber: 'foobar',
+ licenceLength: 365,
+ fullName: 'John Smith',
+ paymentField: {
+ paymentId: 'payment-id',
+ reference: 'payment-ref',
+ amount: 250,
+ description: 'Payment desc',
+ uuid: 'uuid',
+ formId: 'form-id',
+ isLivePayment: false,
+ preAuth: {
+ status: 'success',
+ createdAt: '2026-01-02T11:00:04+0000'
+ }
+ } as PaymentState
+}
+
+const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
+
+const request = buildFormContextRequest({
+ method: 'get',
+ url: pageUrl,
+ path: pageUrl.pathname,
+ params: {
+ path: 'summary',
+ slug: 'payment'
+ },
+ query: {},
+ app: { model }
+})
+
+const context = model.getFormContext(
+ request,
+ state as unknown as FormSubmissionState
+)
+
+const pageDef = definition.pages[2]
+
+const controller = new SummaryPageController(model, pageDef)
+
+const summaryViewModel = controller.getSummaryViewModel(request, context)
+
+const items = getFormSubmissionData(
+ summaryViewModel.context,
+ summaryViewModel.details
+)
+
+describe('getPersonalisation', () => {
+ it('should return the machine output', () => {
+ model.def = definition
+
+ const body = format(context, items, model, submitResponse, formStatus)
+
+ const parsedBody = JSON.parse(body)
+
+ const expectedData = {
+ main: {
+ licenceLength: 365,
+ fullName: 'John Smith'
+ },
+ payment: {
+ amount: 250,
+ createdAt: '2026-01-02T11:00:04+0000',
+ description: 'Payment desc',
+ paymentId: 'payment-id',
+ reference: 'payment-ref'
+ },
+ repeaters: {},
+ files: {}
+ }
+
+ expect(parsedBody.meta.schemaVersion).toBe('2')
+ expect(parsedBody.meta.timestamp).toBeDateString()
+ expect(parsedBody.meta.definition).toEqual(definition)
+ expect(parsedBody.meta.referenceNumber).toBe('foobar')
+ expect(parsedBody.data).toEqual(expectedData)
+ })
+})
diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.ts b/src/server/plugins/engine/outputFormatters/machine/v2.ts
index 6b7ccbe22..7607dd46a 100644
--- a/src/server/plugins/engine/outputFormatters/machine/v2.ts
+++ b/src/server/plugins/engine/outputFormatters/machine/v2.ts
@@ -1,7 +1,10 @@
import { type SubmitResponsePayload } from '@defra/forms-model'
import { config } from '~/src/config/index.js'
-import { FileUploadField } from '~/src/server/plugins/engine/components/index.js'
+import {
+ FileUploadField,
+ PaymentField
+} from '~/src/server/plugins/engine/components/index.js'
import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js'
import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
import {
@@ -16,6 +19,18 @@ import {
type RichFormValue
} from '~/src/server/plugins/engine/types.js'
+/**
+ * Payment data for the machine output format
+ * Defined locally to avoid circular dependency with types.ts
+ */
+interface PaymentOutput {
+ paymentId: string
+ reference: string
+ amount: number
+ description: string
+ createdAt: string
+}
+
const designerUrl = config.get('designerUrl')
export function format(
@@ -71,6 +86,15 @@ export function format(
* userDownloadLink: 'https://forms-designer/file-download/123-456-789'
* }
* ]
+ * },
+ * payments: {
+ * paymentComponentName: {
+ * paymentId: 'abc123',
+ * reference: 'REF-123',
+ * amount: 10.00,
+ * description: 'Application fee',
+ * createdAt: '2025-01-23T10:30:00.000Z'
+ * }
* }
* }
*/
@@ -82,6 +106,7 @@ export function categoriseData(items: DetailItem[]) {
string,
{ fileId: string; fileName: string; userDownloadLink: string }[]
>
+ payment?: PaymentOutput
} = { main: {}, repeaters: {}, files: {} }
items.forEach((item) => {
@@ -91,6 +116,11 @@ export function categoriseData(items: DetailItem[]) {
output.repeaters[name] = extractRepeaters(item)
} else if (isFileUploadFieldItem(item)) {
output.files[name] = extractFileUploads(item)
+ } else if (isPaymentFieldItem(item)) {
+ const payment = extractPayment(item)
+ if (payment) {
+ output.payment = payment
+ }
} else {
output.main[name] = item.field.getFormValueFromState(state)
}
@@ -148,3 +178,32 @@ function isFileUploadFieldItem(
): item is FileUploadFieldDetailitem {
return item.field instanceof FileUploadField
}
+
+function isPaymentFieldItem(item: DetailItemField): item is DetailItemField & {
+ field: PaymentField
+} {
+ return item.field instanceof PaymentField
+}
+
+/**
+ * Returns the "payments" section of the response body
+ * @param item - the payment item in the form
+ * @returns the payment data
+ */
+function extractPayment(
+ item: DetailItemField & { field: PaymentField }
+): PaymentOutput | undefined {
+ const paymentState = item.field.getPaymentStateFromState(item.state)
+
+ if (!paymentState) {
+ return undefined
+ }
+
+ return {
+ paymentId: paymentState.paymentId,
+ reference: paymentState.reference,
+ amount: paymentState.amount,
+ description: paymentState.description,
+ createdAt: paymentState.preAuth?.createdAt ?? ''
+ }
+}
diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts
index a3ee65e62..2d7c8b58b 100644
--- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts
+++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts
@@ -15,12 +15,14 @@ import { type ValidationErrorItem } from 'joi'
import {
COMPONENT_STATE_ERROR,
EXTERNAL_STATE_APPENDAGE,
- EXTERNAL_STATE_PAYLOAD
+ EXTERNAL_STATE_PAYLOAD,
+ PAYMENT_EXPIRED_NOTIFICATION
} from '~/src/server/constants.js'
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
import { optionalText } from '~/src/server/plugins/engine/components/constants.js'
import { type BackLink } from '~/src/server/plugins/engine/components/types.js'
import {
+ checkFormStatus,
getCacheService,
getErrors,
getSaveAndExitHelpers,
@@ -47,6 +49,7 @@ import {
import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js'
import {
FormAction,
+ FormStatus,
type FormRequest,
type FormRequestPayload,
type FormRequestPayloadRefs,
@@ -182,6 +185,16 @@ export class QuestionPageController extends PageController {
}
}
+ const hasIncompletePayment = components.some(({ model }) => {
+ if ('paymentState' in model) {
+ const paymentState = model.paymentState as
+ | { preAuth?: { status?: string } }
+ | undefined
+ return !paymentState?.preAuth?.status
+ }
+ return false
+ })
+
return {
...viewModel,
backLink: this.getBackLink(request, context),
@@ -189,7 +202,8 @@ export class QuestionPageController extends PageController {
showTitle,
components,
errors,
- allowSaveAndExit: this.shouldShowSaveAndExit(request.server)
+ allowSaveAndExit: this.shouldShowSaveAndExit(request.server),
+ showSubmitButton: !hasIncompletePayment
}
}
@@ -423,6 +437,12 @@ export class QuestionPageController extends PageController {
viewModel.errors = (viewModel.errors ?? []).concat(flashedErrors)
+ const paymentExpiredFlash = request.yar.flash(
+ PAYMENT_EXPIRED_NOTIFICATION
+ )
+ viewModel.showPaymentExpiredNotification =
+ !Array.isArray(paymentExpiredFlash)
+
/**
* Content components can be hidden based on a condition. If the condition evaluates to true, it is safe to be kept, otherwise discard it
*/
@@ -517,7 +537,7 @@ export class QuestionPageController extends PageController {
const action = request.payload.action
if (action?.startsWith(FormAction.External)) {
- return this.dispatchExternal(request, h, context)
+ return await this.dispatchExternal(request, h, context)
}
/**
@@ -551,7 +571,7 @@ export class QuestionPageController extends PageController {
}
}
- private dispatchExternal(
+ private async dispatchExternal(
request: FormRequestPayload,
h: FormResponseToolkit,
context: FormContext
@@ -602,11 +622,17 @@ export class QuestionPageController extends PageController {
// Clear any previous state appendage
request.yar.clear(EXTERNAL_STATE_APPENDAGE)
- return selectedComponent.dispatcher(request, h, {
+ // Determine if this is a live form (not preview/draft)
+ const { state, isPreview } = checkFormStatus(request.params)
+ const isLive = state === FormStatus.Live
+
+ return await selectedComponent.dispatcher(request, h, {
component,
controller: this,
sourceUrl: request.url.toString(),
- actionArgs: args
+ actionArgs: args,
+ isLive,
+ isPreview
})
}
diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts
index d97f54631..8446a287a 100644
--- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts
+++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts
@@ -7,9 +7,12 @@ import {
import Boom from '@hapi/boom'
import { type RouteOptions } from '@hapi/hapi'
-import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js'
+import {
+ COMPONENT_STATE_ERROR,
+ PAYMENT_EXPIRED_NOTIFICATION
+} from '~/src/server/constants.js'
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
-import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
import {
checkEmailAddressForLiveFormSubmission,
checkFormStatus,
@@ -22,15 +25,30 @@ import {
} from '~/src/server/plugins/engine/models/index.js'
import {
type Detail,
- type DetailItem
+ type DetailItem,
+ type DetailItemField
} from '~/src/server/plugins/engine/models/types.js'
import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
-import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
+import {
+ InvalidComponentStateError,
+ PaymentErrorTypes,
+ PaymentPreAuthError,
+ PaymentSubmissionError
+} from '~/src/server/plugins/engine/pageControllers/errors.js'
+import {
+ buildMainRecords,
+ buildRepeaterRecords
+} from '~/src/server/plugins/engine/pageControllers/helpers/submission.js'
import {
type FormConfirmationState,
type FormContext,
type FormContextRequest
} from '~/src/server/plugins/engine/types.js'
+import {
+ DEFAULT_PAYMENT_HELP_URL,
+ formatPaymentAmount,
+ formatPaymentDate
+} from '~/src/server/plugins/payment/helper.js'
import {
FormAction,
type FormRequest,
@@ -65,11 +83,25 @@ export class SummaryPageController extends QuestionPageController {
const viewModel = new SummaryViewModel(request, this, context)
const { query } = request
- const { payload, errors } = context
+ const { payload, errors, state } = context
+
+ const paymentField = context.relevantPages
+ .flatMap((page) => page.collection.fields)
+ .find((field): field is PaymentField => field instanceof PaymentField)
+
+ if (paymentField) {
+ const paymentState = paymentField.getPaymentStateFromState(state)
+ if (paymentState) {
+ viewModel.paymentState = paymentState
+ viewModel.paymentDetails = this.buildPaymentDetails(
+ paymentField,
+ paymentState
+ )
+ }
+ }
+
const components = this.collection.getViewModel(payload, errors, query)
- // We already figure these out in the base page controller. Take them and apply them to our page-specific model.
- // This is a stop-gap until we can add proper inheritance in place.
viewModel.backLink = this.getBackLink(request, context)
viewModel.feedbackLink = this.feedbackLink
viewModel.phaseTag = this.phaseTag
@@ -80,6 +112,40 @@ export class SummaryPageController extends QuestionPageController {
return viewModel
}
+ private buildPaymentDetails(
+ paymentField: PaymentField,
+ paymentState: NonNullable<
+ ReturnType
+ >
+ ) {
+ const rows = [
+ {
+ key: { text: 'Payment for' },
+ value: { text: paymentState.description }
+ },
+ {
+ key: { text: 'Total amount' },
+ value: { text: formatPaymentAmount(paymentState.amount) }
+ },
+ {
+ key: { text: 'Reference' },
+ value: { text: paymentState.reference }
+ }
+ ]
+
+ if (paymentState.preAuth?.createdAt) {
+ rows.push({
+ key: { text: 'Date of payment' },
+ value: { text: formatPaymentDate(paymentState.preAuth.createdAt) }
+ })
+ }
+
+ return {
+ title: { text: 'Payment details' },
+ summaryList: { rows }
+ }
+ }
+
/**
* Returns an async function. This is called in plugin.ts when there is a GET request at `/{id}/{path*}`,
*/
@@ -110,7 +176,6 @@ export class SummaryPageController extends QuestionPageController {
context: FormContext,
h: FormResponseToolkit
) => {
- // Check if this is a save-and-exit action
const { action } = request.payload
if (action === FormAction.SaveAndExit) {
return this.handleSaveAndExit(request, context, h)
@@ -133,14 +198,12 @@ export class SummaryPageController extends QuestionPageController {
const { formsService } = this.model.services
const { getFormMetadata } = formsService
- // Get the form metadata using the `slug` param
const formMetadata = await getFormMetadata(params.slug)
const { notificationEmail } = formMetadata
const { isPreview } = checkFormStatus(request.params)
checkEmailAddressForLiveFormSubmission(notificationEmail, isPreview)
- // Send submission email
if (notificationEmail) {
const viewModel = this.getSummaryViewModel(request, context)
@@ -155,20 +218,7 @@ export class SummaryPageController extends QuestionPageController {
formMetadata
)
} catch (error) {
- if (error instanceof InvalidComponentStateError) {
- const govukError = createError(
- error.component.name,
- error.userMessage
- )
-
- request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
-
- await cacheService.resetComponentStates(request, error.getStateKeys())
-
- return this.proceed(request, h, error.component.page?.path)
- }
-
- throw error
+ return this.handleSubmissionError(error, request, h)
}
}
@@ -178,12 +228,103 @@ export class SummaryPageController extends QuestionPageController {
referenceNumber: context.referenceNumber
} as FormConfirmationState)
- // Clear all form data
await cacheService.clearState(request)
return this.proceed(request, h, this.getStatusPath())
}
+ /**
+ * Handles errors during form submission
+ */
+ private async handleSubmissionError(
+ error: unknown,
+ request: FormRequestPayload,
+ h: FormResponseToolkit
+ ) {
+ if (error instanceof InvalidComponentStateError) {
+ return this.handleInvalidComponentStateError(error, request, h)
+ }
+
+ if (error instanceof PaymentPreAuthError) {
+ return this.handlePaymentPreAuthError(error, request, h)
+ }
+
+ if (error instanceof PaymentSubmissionError) {
+ return this.handlePaymentSubmissionError(error, request, h)
+ }
+
+ throw error
+ }
+
+ /**
+ * Handles InvalidComponentStateError during submission
+ */
+ private async handleInvalidComponentStateError(
+ error: InvalidComponentStateError,
+ request: FormRequestPayload,
+ h: FormResponseToolkit
+ ) {
+ const cacheService = getCacheService(request.server)
+
+ const govukError = createError(error.component.name, error.userMessage)
+
+ request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
+
+ await cacheService.resetComponentStates(request, error.getStateKeys())
+
+ return this.proceed(request, h, error.component.page?.path)
+ }
+
+ /**
+ * Handles PaymentPreAuthError during submission
+ */
+ private async handlePaymentPreAuthError(
+ error: PaymentPreAuthError,
+ request: FormRequestPayload,
+ h: FormResponseToolkit
+ ) {
+ const cacheService = getCacheService(request.server)
+
+ if (error.shouldResetState) {
+ await cacheService.resetComponentStates(request, error.getStateKeys())
+
+ if (error.errorType === PaymentErrorTypes.PaymentExpired) {
+ request.yar.flash(PAYMENT_EXPIRED_NOTIFICATION, true, true)
+ return this.proceed(request, h, error.component.page?.path)
+ }
+ }
+
+ const govukError = createError(error.component.name, error.userMessage)
+ request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
+
+ const redirectPath = error.shouldResetState
+ ? error.component.page?.path
+ : undefined
+
+ return this.proceed(request, h, redirectPath)
+ }
+
+ /**
+ * Handles PaymentSubmissionError during submission
+ */
+ private handlePaymentSubmissionError(
+ error: PaymentSubmissionError,
+ request: FormRequestPayload,
+ h: FormResponseToolkit
+ ) {
+ const helpUrl = error.helpLink ?? DEFAULT_PAYMENT_HELP_URL
+ const helpLinkHtml = ` or you can contact us (opens in new tab) and quote your reference number to arrange a refund`
+
+ const govukError = createError(
+ 'submission',
+ `There was a problem and your form was not submitted. Try submitting the form again${helpLinkHtml}.`
+ )
+
+ request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
+
+ return this.proceed(request, h)
+ }
+
get postRouteOptions(): RouteOptions {
return {
ext: {
@@ -208,39 +349,66 @@ export async function submitForm(
) {
await finaliseComponents(request, metadata, context)
+ const paymentWasCaptured = hasPaymentBeenCaptured(context)
+
const formStatus = checkFormStatus(request.params)
const logTags = ['submit', 'submissionApi']
request.logger.info(logTags, 'Preparing email', formStatus)
- // Get detail items
const items = getFormSubmissionData(
summaryViewModel.context,
summaryViewModel.details
)
- // Submit data
- request.logger.info(logTags, 'Submitting data')
- const submitResponse = await submitData(
- model,
- items,
- emailAddress,
- request.yar.id
- )
+ try {
+ request.logger.info(logTags, 'Submitting data')
+ const submitResponse = await submitData(
+ model,
+ items,
+ emailAddress,
+ request.yar.id
+ )
- if (submitResponse === undefined) {
- throw Boom.badRequest('Unexpected empty response from submit api')
+ if (submitResponse === undefined) {
+ throw Boom.badRequest('Unexpected empty response from submit api')
+ }
+
+ await model.services.outputService.submit(
+ context,
+ request,
+ model,
+ emailAddress,
+ items,
+ submitResponse,
+ formMetadata
+ )
+ } catch (err) {
+ if (paymentWasCaptured) {
+ throw new PaymentSubmissionError(
+ context.referenceNumber,
+ formMetadata.contact?.online?.url
+ )
+ }
+ throw err
}
+}
- return model.services.outputService.submit(
- context,
- request,
- model,
- emailAddress,
- items,
- submitResponse,
- formMetadata
- )
+/**
+ * Checks if any payment component has been captured
+ */
+function hasPaymentBeenCaptured(context: FormContext): boolean {
+ for (const page of context.relevantPages) {
+ for (const field of page.collection.fields) {
+ if (field instanceof PaymentField) {
+ const paymentState = field.getPaymentStateFromState(context.state)
+ if (paymentState?.capture?.status === 'success') {
+ return true
+ }
+ }
+ }
+ }
+ return false
}
/**
@@ -280,43 +448,50 @@ function submitData(
const payload: SubmitPayload = {
sessionId,
retrievalKey,
-
- // Main form answers
- main: items
- .filter((item) => 'field' in item)
- .map((item) => ({
- name: item.name,
- title: item.label,
- value: getAnswer(item.field, item.state, { format: 'data' })
- })),
-
- // Repeater form answers
- repeaters: items
- .filter((item) => 'subItems' in item)
- .map((item) => ({
- name: item.name,
- title: item.label,
-
- // Repeater item values
- value: item.subItems.map((detailItems) =>
- detailItems.map((subItem) => ({
- name: subItem.name,
- title: subItem.label,
- value: getAnswer(subItem.field, subItem.state, { format: 'data' })
- }))
- )
- }))
+ main: buildMainRecords(items),
+ repeaters: buildRepeaterRecords(items)
}
return submit(payload)
}
export function getFormSubmissionData(context: FormContext, details: Detail[]) {
- return context.relevantPages
+ const items = context.relevantPages
.map(({ href }) =>
details.flatMap(({ items }) =>
items.filter(({ page }) => page.href === href)
)
)
.flat()
+
+ const paymentItems = getPaymentFieldItems(context)
+
+ return [...items, ...paymentItems]
+}
+
+/**
+ * Gets DetailItems for PaymentField components
+ * PaymentField is excluded from summaryDetails for UI but needs to be in submission data
+ */
+function getPaymentFieldItems(context: FormContext): DetailItemField[] {
+ const items: DetailItemField[] = []
+
+ for (const page of context.relevantPages) {
+ for (const field of page.collection.fields) {
+ if (field instanceof PaymentField) {
+ items.push({
+ name: field.name,
+ page,
+ title: field.title,
+ label: field.label,
+ field,
+ state: context.state,
+ href: page.href,
+ value: field.getDisplayStringFromState(context.state)
+ })
+ }
+ }
+ }
+
+ return items
}
diff --git a/src/server/plugins/engine/pageControllers/errors.test.ts b/src/server/plugins/engine/pageControllers/errors.test.ts
index f74709180..5f116dd57 100644
--- a/src/server/plugins/engine/pageControllers/errors.test.ts
+++ b/src/server/plugins/engine/pageControllers/errors.test.ts
@@ -3,7 +3,10 @@ import { ComponentType } from '@defra/forms-model'
import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js'
import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
-import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
+import {
+ InvalidComponentStateError,
+ PaymentSubmissionError
+} from '~/src/server/plugins/engine/pageControllers/errors.js'
import definition from '~/test/form/definitions/file-upload-basic.js'
describe('InvalidComponentStateError', () => {
@@ -63,4 +66,13 @@ describe('InvalidComponentStateError', () => {
expect(stateKeys).toEqual(['textField'])
})
})
+
+ describe('PaymentSubmissionError', () => {
+ it('should instantiate', () => {
+ const error = new PaymentSubmissionError('reference-number', '/help-link')
+ expect(error).toBeDefined()
+ expect(error.referenceNumber).toBe('reference-number')
+ expect(error.helpLink).toBe('/help-link')
+ })
+ })
})
diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts
index c96fb76f7..5e796eec7 100644
--- a/src/server/plugins/engine/pageControllers/errors.ts
+++ b/src/server/plugins/engine/pageControllers/errors.ts
@@ -1,5 +1,83 @@
import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
+export enum PaymentErrorTypes {
+ PaymentExpired = 'PaymentExpired',
+ PaymentIncomplete = 'PaymentIncomplete',
+ PaymentAmountMismatch = 'PaymentAmountMismatch'
+}
+
+function getStateKeys(component: FormComponent) {
+ const extraStateKeys = component.page?.getStateKeys(component) ?? []
+
+ return [component.name].concat(extraStateKeys)
+}
+
+export class PaymentPreAuthError extends Error {
+ public readonly component: FormComponent
+ public readonly userMessage: string
+
+ /**
+ * Whether to reset the component state and redirect to the component's page.
+ * - `true`: Reset state and redirect (e.g., payment expired - user must re-enter)
+ * - `false`: Keep state and stay on current page with error (e.g., capture failed - user can retry)
+ */
+ public readonly shouldResetState: boolean
+
+ /**
+ * When supplied, an "Important" notification banner will be shown based on the value.
+ */
+ public readonly errorType: PaymentErrorTypes | undefined
+
+ constructor(
+ component: FormComponent,
+ userMessage: string,
+ shouldResetState: boolean,
+ errorType?: PaymentErrorTypes
+ ) {
+ super('Payment capture failed')
+ this.name = 'PaymentPreAuthError'
+ this.component = component
+ this.userMessage = userMessage
+ this.shouldResetState = shouldResetState
+ this.errorType = errorType
+ }
+
+ getStateKeys() {
+ return getStateKeys(this.component)
+ }
+}
+
+/**
+ * Thrown when form submission fails after payment has been captured.
+ * User needs to retry or contact support for a refund.
+ */
+export class PaymentSubmissionError extends Error {
+ public readonly referenceNumber: string
+ public readonly helpLink?: string
+
+ constructor(referenceNumber: string, helpLink?: string) {
+ super('Form submission failed after payment capture')
+ this.name = 'PaymentSubmissionError'
+ this.referenceNumber = referenceNumber
+ this.helpLink = helpLink
+ }
+
+ static checkPaymentAmount(
+ stateAmount: number,
+ definitionAmount: number | undefined,
+ component: FormComponent
+ ) {
+ if (stateAmount / 100 !== definitionAmount) {
+ throw new PaymentPreAuthError(
+ component,
+ 'The pre-authorised payment amount is somehow different from that requested. Try adding payment details again.',
+ true,
+ PaymentErrorTypes.PaymentIncomplete
+ )
+ }
+ }
+}
+
/**
* Thrown when a component has an invalid state. This is typically only required where state needs
* to be checked against an external source upon submission of a form. For example: file upload
@@ -21,9 +99,6 @@ export class InvalidComponentStateError extends Error {
}
getStateKeys() {
- const extraStateKeys =
- this.component.page?.getStateKeys(this.component) ?? []
-
- return [this.component.name].concat(extraStateKeys)
+ return getStateKeys(this.component)
}
}
diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts
new file mode 100644
index 000000000..62688bd85
--- /dev/null
+++ b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts
@@ -0,0 +1,299 @@
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
+import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
+import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js'
+import {
+ buildMainRecords,
+ buildPaymentRecords,
+ buildRepeaterRecords
+} from '~/src/server/plugins/engine/pageControllers/helpers/submission.js'
+import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js'
+
+describe('Submission helpers', () => {
+ describe('buildPaymentRecords', () => {
+ it('should return empty array when no payment state exists', () => {
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(undefined)
+
+ const item = {
+ name: 'payment',
+ label: 'Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ } as unknown as DetailItemField
+
+ const result = buildPaymentRecords(item)
+
+ expect(result).toEqual([])
+ expect(mockPaymentField.getPaymentStateFromState).toHaveBeenCalledWith(
+ item.state
+ )
+ })
+
+ it('should return four records when payment state exists', () => {
+ const mockPaymentState = {
+ paymentId: 'pay_123',
+ description: 'Application fee',
+ amount: 150,
+ reference: 'REF-ABC-123',
+ preAuth: {
+ status: 'success',
+ createdAt: '2026-01-26T14:30:00.000Z'
+ }
+ }
+
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(mockPaymentState)
+
+ const item = {
+ name: 'payment',
+ label: 'Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ } as unknown as DetailItemField
+
+ const result = buildPaymentRecords(item)
+
+ expect(result).toHaveLength(4)
+ expect(result[0]).toEqual({
+ name: 'payment_paymentDescription',
+ title: 'Payment description',
+ value: 'Application fee'
+ })
+ expect(result[1]).toEqual({
+ name: 'payment_paymentAmount',
+ title: 'Payment amount',
+ value: '£150.00'
+ })
+ expect(result[2]).toEqual({
+ name: 'payment_paymentReference',
+ title: 'Payment reference',
+ value: 'REF-ABC-123'
+ })
+ expect(result[3].name).toBe('payment_paymentDate')
+ expect(result[3].title).toBe('Payment date')
+ // Date will be formatted, just check it's not empty
+ expect(result[3].value).not.toBe('')
+ })
+
+ it('should return empty date when preAuth.createdAt is missing', () => {
+ const mockPaymentState = {
+ paymentId: 'pay_123',
+ description: 'Application fee',
+ amount: 150,
+ reference: 'REF-ABC-123',
+ preAuth: {
+ status: 'success'
+ // createdAt is missing
+ }
+ }
+
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(mockPaymentState)
+
+ const item = {
+ name: 'payment',
+ label: 'Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ } as unknown as DetailItemField
+
+ const result = buildPaymentRecords(item)
+
+ expect(result[3]).toEqual({
+ name: 'payment_paymentDate',
+ title: 'Payment date',
+ value: ''
+ })
+ })
+ })
+
+ describe('buildMainRecords', () => {
+ it('should return empty array for empty items', () => {
+ const result = buildMainRecords([])
+ expect(result).toEqual([])
+ })
+
+ it('should process regular fields correctly', () => {
+ const mockTextField = Object.create(TextField.prototype)
+ mockTextField.getDisplayStringFromState = jest
+ .fn()
+ .mockReturnValue('John Doe')
+ mockTextField.getContextValueFromState = jest
+ .fn()
+ .mockReturnValue('John Doe')
+
+ const items = [
+ {
+ name: 'fullName',
+ label: 'Full name',
+ field: mockTextField,
+ state: { fullName: 'John Doe' } as FormSubmissionState
+ }
+ ] as unknown as DetailItemField[]
+
+ const result = buildMainRecords(items)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({
+ name: 'fullName',
+ title: 'Full name',
+ value: 'John Doe'
+ })
+ })
+
+ it('should expand PaymentField into four records', () => {
+ const mockPaymentState = {
+ paymentId: 'pay_123',
+ description: 'Licence fee',
+ amount: 75.5,
+ reference: 'LIC-999',
+ preAuth: {
+ status: 'success',
+ createdAt: '2026-01-26T10:00:00.000Z'
+ }
+ }
+
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(mockPaymentState)
+
+ const items = [
+ {
+ name: 'licencePayment',
+ label: 'Licence Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ }
+ ] as unknown as DetailItemField[]
+
+ const result = buildMainRecords(items)
+
+ expect(result).toHaveLength(4)
+ expect(result.map((r) => r.name)).toEqual([
+ 'licencePayment_paymentDescription',
+ 'licencePayment_paymentAmount',
+ 'licencePayment_paymentReference',
+ 'licencePayment_paymentDate'
+ ])
+ })
+
+ it('should handle mixed regular and payment fields', () => {
+ const mockTextField = Object.create(TextField.prototype)
+ mockTextField.getDisplayStringFromState = jest
+ .fn()
+ .mockReturnValue('test@example.com')
+ mockTextField.getContextValueFromState = jest
+ .fn()
+ .mockReturnValue('test@example.com')
+
+ const mockPaymentState = {
+ paymentId: 'pay_456',
+ description: 'Registration fee',
+ amount: 25,
+ reference: 'REG-001',
+ preAuth: { status: 'success', createdAt: '2026-01-26T12:00:00.000Z' }
+ }
+
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(mockPaymentState)
+
+ const items = [
+ {
+ name: 'email',
+ label: 'Email address',
+ field: mockTextField,
+ state: { email: 'test@example.com' } as FormSubmissionState
+ },
+ {
+ name: 'payment',
+ label: 'Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ }
+ ] as unknown as DetailItemField[]
+
+ const result = buildMainRecords(items)
+
+ // 1 regular field + 4 payment fields = 5 records
+ expect(result).toHaveLength(5)
+ expect(result[0].name).toBe('email')
+ expect(result[1].name).toBe('payment_paymentDescription')
+ })
+
+ it('should skip repeater items (items with subItems)', () => {
+ const repeaterItem = {
+ name: 'addresses',
+ label: 'Addresses',
+ subItems: [[]]
+ }
+
+ const result = buildMainRecords([
+ repeaterItem as unknown as DetailItemField
+ ])
+
+ expect(result).toEqual([])
+ })
+ })
+
+ describe('buildRepeaterRecords', () => {
+ it('should return empty array when no repeater items', () => {
+ const mockField = Object.create(TextField.prototype)
+
+ const items = [
+ {
+ name: 'textField',
+ label: 'Text',
+ field: mockField,
+ state: {} as FormSubmissionState
+ }
+ ]
+
+ const result = buildRepeaterRecords(items as unknown as DetailItemField[])
+
+ expect(result).toEqual([])
+ })
+
+ it('should process repeater items correctly', () => {
+ const mockSubField = Object.create(TextField.prototype)
+ mockSubField.getDisplayStringFromState = jest
+ .fn()
+ .mockReturnValue('123 Main St')
+ mockSubField.getContextValueFromState = jest
+ .fn()
+ .mockReturnValue('123 Main St')
+
+ const items = [
+ {
+ name: 'addresses',
+ label: 'Addresses',
+ subItems: [
+ [
+ {
+ name: 'street',
+ label: 'Street',
+ field: mockSubField,
+ state: { street: '123 Main St' } as FormSubmissionState
+ }
+ ]
+ ]
+ }
+ ]
+
+ const result = buildRepeaterRecords(items as unknown as DetailItemField[])
+
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('addresses')
+ expect(result[0].title).toBe('Addresses')
+ expect(result[0].value).toHaveLength(1)
+ })
+ })
+})
diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.ts b/src/server/plugins/engine/pageControllers/helpers/submission.ts
new file mode 100644
index 000000000..879b26ad2
--- /dev/null
+++ b/src/server/plugins/engine/pageControllers/helpers/submission.ts
@@ -0,0 +1,110 @@
+import { type SubmitPayload } from '@defra/forms-model'
+
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
+import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
+import {
+ type DetailItem,
+ type DetailItemField
+} from '~/src/server/plugins/engine/models/types.js'
+import {
+ formatPaymentAmount,
+ formatPaymentDate
+} from '~/src/server/plugins/payment/helper.js'
+
+export interface SubmitRecord {
+ name: string
+ title: string
+ value: string
+}
+
+/**
+ * Builds the main submission records from field items.
+ * Regular fields are converted to single records, while PaymentField
+ * components are expanded into four separate records.
+ */
+export function buildMainRecords(items: DetailItem[]): SubmitRecord[] {
+ const fieldItems = items.filter(
+ (item): item is DetailItemField => 'field' in item
+ )
+
+ const records: SubmitRecord[] = []
+
+ for (const item of fieldItems) {
+ if (item.field instanceof PaymentField) {
+ records.push(...buildPaymentRecords(item))
+ } else {
+ records.push({
+ name: item.name,
+ title: item.label,
+ value: getAnswer(item.field, item.state, { format: 'data' })
+ })
+ }
+ }
+
+ return records
+}
+
+/**
+ * Expands a PaymentField into four submission records:
+ * - Payment description
+ * - Payment amount (formatted with currency symbol)
+ * - Payment reference
+ * - Payment date (formatted date/time)
+ *
+ * Returns an empty array if no payment state exists.
+ */
+export function buildPaymentRecords(item: DetailItemField): SubmitRecord[] {
+ const paymentState = (item.field as PaymentField).getPaymentStateFromState(
+ item.state
+ )
+
+ if (!paymentState) {
+ return []
+ }
+
+ return [
+ {
+ name: `${item.name}_paymentDescription`,
+ title: 'Payment description',
+ value: paymentState.description
+ },
+ {
+ name: `${item.name}_paymentAmount`,
+ title: 'Payment amount',
+ value: formatPaymentAmount(paymentState.amount)
+ },
+ {
+ name: `${item.name}_paymentReference`,
+ title: 'Payment reference',
+ value: paymentState.reference
+ },
+ {
+ name: `${item.name}_paymentDate`,
+ title: 'Payment date',
+ value: paymentState.preAuth?.createdAt
+ ? formatPaymentDate(paymentState.preAuth.createdAt)
+ : ''
+ }
+ ]
+}
+
+/**
+ * Builds the repeater submission records from repeater items.
+ */
+export function buildRepeaterRecords(
+ items: DetailItem[]
+): SubmitPayload['repeaters'] {
+ return items
+ .filter((item) => 'subItems' in item)
+ .map((item) => ({
+ name: item.name,
+ title: item.label,
+ value: item.subItems.map((detailItems) =>
+ detailItems.map((subItem) => ({
+ name: subItem.name,
+ title: subItem.label,
+ value: getAnswer(subItem.field, subItem.state, { format: 'data' })
+ }))
+ )
+ }))
+}
diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts
index 01574cad3..1856a038e 100644
--- a/src/server/plugins/engine/plugin.ts
+++ b/src/server/plugins/engine/plugin.ts
@@ -10,6 +10,7 @@ import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
import { validatePluginOptions } from '~/src/server/plugins/engine/options.js'
import { getRoutes as getFileUploadStatusRoutes } from '~/src/server/plugins/engine/routes/file-upload.js'
import { makeLoadFormPreHandler } from '~/src/server/plugins/engine/routes/index.js'
+import { getRoutes as getPaymentRoutes } from '~/src/server/plugins/engine/routes/payment.js'
import { getRoutes as getQuestionRoutes } from '~/src/server/plugins/engine/routes/questions.js'
import { getRoutes as getRepeaterItemDeleteRoutes } from '~/src/server/plugins/engine/routes/repeaters/item-delete.js'
import { getRoutes as getRepeaterSummaryRoutes } from '~/src/server/plugins/engine/routes/repeaters/summary.js'
@@ -39,6 +40,7 @@ export const plugin = {
preparePageEventRequestOptions,
onRequest,
ordnanceSurveyApiKey,
+ baseUrl,
ordnanceSurveyApiSecret
} = options
@@ -74,6 +76,7 @@ export const plugin = {
server.expose('viewContext', viewContext)
server.expose('cacheService', cacheService)
server.expose('saveAndExit', saveAndExit)
+ server.expose('baseUrl', baseUrl)
server.app.model = model
@@ -106,19 +109,21 @@ export const plugin = {
}
const routes = [
- ...getQuestionRoutes(
+ ...getPaymentRoutes(),
+ ...getFileUploadStatusRoutes(),
+ ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions, onRequest),
+ ...getRepeaterItemDeleteRoutes(
getRouteOptions,
postRouteOptions,
- preparePageEventRequestOptions,
onRequest
),
- ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions, onRequest),
- ...getRepeaterItemDeleteRoutes(
+
+ ...getQuestionRoutes(
getRouteOptions,
postRouteOptions,
+ preparePageEventRequestOptions,
onRequest
- ),
- ...getFileUploadStatusRoutes()
+ )
]
server.route(routes as unknown as ServerRoute[]) // TODO
diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts
index 543aa766e..f3caaf0eb 100644
--- a/src/server/plugins/engine/routes/index.ts
+++ b/src/server/plugins/engine/routes/index.ts
@@ -119,6 +119,7 @@ async function importExternalComponentState(
const typedStateAppendage = externalComponentData as ExternalStateAppendage
const componentName = typedStateAppendage.component
const stateAppendage = typedStateAppendage.data
+
const component = request.app.model?.componentMap.get(componentName)
if (!component) {
@@ -137,22 +138,22 @@ async function importExternalComponentState(
throw new Error(`State for component ${componentName} is invalid`)
}
- const componentState = isFormState(stateAppendage)
- ? Object.fromEntries(
- Object.entries(stateAppendage).map(([key, value]) => [
- `${componentName}__${key}`,
- value
- ])
- )
- : { [componentName]: stateAppendage }
-
- // Save the external component state immediately
- const pageState = page.getStateFromValidForm(
- request,
- state,
- componentState as FormPayload
- )
- const savedState = await page.mergeState(request, state, pageState)
+ // Create state structure from appendage state
+ // Some components use a record structure with properties of the format of '__'
+ // e.g. UKAddressField
+ // Some components use a single object structure e.g. PaymentField
+ const componentState =
+ isFormState(stateAppendage) && !component.isAppendageStateSingleObject
+ ? Object.fromEntries(
+ Object.entries(stateAppendage).map(([key, value]) => [
+ `${componentName}__${key}`,
+ value
+ ])
+ )
+ : { [componentName]: stateAppendage }
+
+ // Save the external component state directly (already has correct key format)
+ const savedState = await page.mergeState(request, state, componentState)
// Merge any stashed payload into the local state
const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD)
diff --git a/src/server/plugins/engine/routes/payment-helper.js b/src/server/plugins/engine/routes/payment-helper.js
new file mode 100644
index 000000000..f2ce83b24
--- /dev/null
+++ b/src/server/plugins/engine/routes/payment-helper.js
@@ -0,0 +1,39 @@
+import Boom from '@hapi/boom'
+
+import { PAYMENT_SESSION_PREFIX } from '~/src/server/plugins/engine/routes/payment.js'
+import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js'
+import { PaymentService } from '~/src/server/plugins/payment/service.js'
+
+/**
+ * Validates session data and retrieves payment status
+ * @param {Request} request - the request
+ * @param {string} uuid - the payment UUID
+ * @returns {Promise<{ session: PaymentSessionData, sessionKey: string, paymentStatus: GetPaymentResponse }>}
+ */
+export async function getPaymentContext(request, uuid) {
+ const sessionKey = `${PAYMENT_SESSION_PREFIX}${uuid}`
+ const session = /** @type {PaymentSessionData | null} */ (
+ request.yar.get(sessionKey)
+ )
+
+ if (!session) {
+ throw Boom.badRequest(`No payment session found for uuid=${uuid}`)
+ }
+
+ const { paymentId, isLivePayment, formId } = session
+
+ if (!paymentId) {
+ throw Boom.badRequest('No paymentId in session')
+ }
+
+ const apiKey = getPaymentApiKey(isLivePayment, formId)
+ const paymentService = new PaymentService(apiKey)
+ const paymentStatus = await paymentService.getPaymentStatus(paymentId)
+
+ return { session, sessionKey, paymentStatus }
+}
+
+/**
+ * @import { Request } from '@hapi/hapi'
+ * @import { GetPaymentResponse, PaymentSessionData } from '~/src/server/plugins/payment/types.js'
+ */
diff --git a/src/server/plugins/engine/routes/payment-helper.test.js b/src/server/plugins/engine/routes/payment-helper.test.js
new file mode 100644
index 000000000..d18fb3aa4
--- /dev/null
+++ b/src/server/plugins/engine/routes/payment-helper.test.js
@@ -0,0 +1,90 @@
+import { getPaymentContext } from '~/src/server/plugins/engine/routes/payment-helper.js'
+import { get } from '~/src/server/services/httpService.js'
+
+jest.mock('~/src/server/services/httpService.ts')
+
+describe('payment helper', () => {
+ const uuid = '5a54c2fe-da49-4202-8cd3-2121eaca03c3'
+ it('should throw if no session', async () => {
+ const mockRequest = {
+ yar: {
+ get: jest.fn().mockReturnValueOnce(undefined)
+ }
+ }
+ // @ts-expect-error - partial request mock
+ await expect(() => getPaymentContext(mockRequest, uuid)).rejects.toThrow(
+ 'No payment session found for uuid=5a54c2fe-da49-4202-8cd3-2121eaca03c3'
+ )
+ })
+
+ it('should throw if no payment id', async () => {
+ const mockRequest = {
+ yar: {
+ get: jest.fn().mockReturnValueOnce({})
+ }
+ }
+ // @ts-expect-error - partial request mock
+ await expect(() => getPaymentContext(mockRequest, uuid)).rejects.toThrow(
+ 'No paymentId in session'
+ )
+ })
+
+ it('should get context successfully', async () => {
+ const mockRequest = {
+ yar: {
+ get: jest.fn().mockReturnValueOnce({
+ paymentId: 'payment-id',
+ isLivePayment: false,
+ formId: 'formid'
+ })
+ }
+ }
+
+ const getPaymentStatusApiResult = {
+ payment_id: 'payment-id-12345',
+ _links: {
+ next_url: {
+ href: 'http://next-url-href/payment'
+ }
+ },
+ state: {
+ status: 'created'
+ }
+ }
+
+ jest.mocked(get).mockResolvedValueOnce({
+ res: /** @type {IncomingMessage} */ ({
+ statusCode: 200,
+ headers: {}
+ }),
+ payload: getPaymentStatusApiResult,
+ error: undefined
+ })
+
+ // @ts-expect-error - partial request mock
+ const res = await getPaymentContext(mockRequest, uuid)
+ expect(res).toEqual({
+ paymentStatus: {
+ paymentId: 'payment-id-12345',
+ _links: {
+ next_url: {
+ href: 'http://next-url-href/payment'
+ }
+ },
+ state: {
+ status: 'created'
+ }
+ },
+ session: {
+ formId: 'formid',
+ isLivePayment: false,
+ paymentId: 'payment-id'
+ },
+ sessionKey: 'payment-5a54c2fe-da49-4202-8cd3-2121eaca03c3'
+ })
+ })
+})
+
+/**
+ * @import { IncomingMessage } from 'node:http'
+ */
diff --git a/src/server/plugins/engine/routes/payment.js b/src/server/plugins/engine/routes/payment.js
new file mode 100644
index 000000000..5c360e38e
--- /dev/null
+++ b/src/server/plugins/engine/routes/payment.js
@@ -0,0 +1,151 @@
+import Boom from '@hapi/boom'
+import { StatusCodes } from 'http-status-codes'
+import Joi from 'joi'
+
+import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js'
+import { getPaymentContext } from '~/src/server/plugins/engine/routes/payment-helper.js'
+
+export const PAYMENT_RETURN_PATH = '/payment-callback'
+export const PAYMENT_SESSION_PREFIX = 'payment-'
+
+/**
+ * Flash form component state after successful payment
+ * @param {Request} request - the request
+ * @param {PaymentSessionData} session - the session data containing payment state
+ * @param {GetPaymentResponse} paymentStatus - the payment status response from GOV.UK Pay
+ */
+function flashComponentState(request, session, paymentStatus) {
+ /** @type {PaymentState} */
+ const paymentState = {
+ paymentId: paymentStatus.paymentId,
+ reference: session.reference,
+ amount: session.amount,
+ description: session.description,
+ uuid: session.uuid,
+ formId: session.formId,
+ isLivePayment: session.isLivePayment,
+ payerEmail: paymentStatus.email,
+ preAuth: {
+ status: 'success',
+ createdAt: new Date().toISOString()
+ }
+ }
+
+ /** @type {ExternalStateAppendage} */
+ const appendage = {
+ component: session.componentName,
+ data: /** @type {FormState} */ (/** @type {unknown} */ (paymentState))
+ }
+
+ request.yar.flash(EXTERNAL_STATE_APPENDAGE, appendage, true)
+}
+
+/**
+ * Gets the payment routes for handling GOV.UK Pay callbacks
+ * @returns {ServerRoute[]}
+ */
+export function getRoutes() {
+ return [getReturnRoute()]
+}
+
+/**
+ * Handles successful payment states (capturable/success)
+ * @param {Request} request - the request
+ * @param {ResponseToolkit} h - the response toolkit
+ * @param {PaymentSessionData} session - the session data
+ * @param {string} sessionKey - the session key
+ * @param {GetPaymentResponse} paymentStatus - the payment status from GOV.UK Pay
+ */
+function handlePaymentSuccess(request, h, session, sessionKey, paymentStatus) {
+ flashComponentState(request, session, paymentStatus)
+ request.yar.clear(sessionKey)
+ return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER)
+}
+
+/**
+ * Handles failed/cancelled/error payment states
+ * @param {Request} request - the request
+ * @param {ResponseToolkit} h - the response toolkit
+ * @param {PaymentSessionData} session - the session data
+ * @param {string} sessionKey - the session key
+ */
+function handlePaymentFailure(request, h, session, sessionKey) {
+ request.yar.clear(sessionKey)
+ return h.redirect(session.failureUrl).code(StatusCodes.SEE_OTHER)
+}
+
+/**
+ * Route handler for payment return URL
+ * This is called when GOV.UK Pay redirects the user back after payment
+ * @returns {ServerRoute}
+ */
+function getReturnRoute() {
+ return {
+ method: 'GET',
+ path: PAYMENT_RETURN_PATH,
+ async handler(request, h) {
+ const { uuid } = /** @type {{ uuid: string }} */ (request.query)
+ const { session, sessionKey, paymentStatus } = await getPaymentContext(
+ request,
+ uuid
+ )
+
+ /**
+ * @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle
+ */
+ const { status } = paymentStatus.state
+
+ switch (status) {
+ case 'capturable':
+ case 'success':
+ return handlePaymentSuccess(
+ request,
+ h,
+ session,
+ sessionKey,
+ paymentStatus
+ )
+
+ case 'cancelled':
+ case 'failed':
+ case 'error':
+ return handlePaymentFailure(request, h, session, sessionKey)
+
+ case 'created':
+ case 'started':
+ case 'submitted': {
+ const nextUrl = paymentStatus._links.next_url?.href
+
+ if (!nextUrl) {
+ throw Boom.badRequest(
+ `Payment in state '${status}' but no next_url available`
+ )
+ }
+
+ return h.redirect(nextUrl).code(StatusCodes.SEE_OTHER)
+ }
+
+ default: {
+ const unknownStatus = /** @type {string} */ (status)
+ throw Boom.internal(`Unknown payment status: ${unknownStatus}`)
+ }
+ }
+ },
+ options: {
+ validate: {
+ query: Joi.object()
+ .keys({
+ uuid: Joi.string().uuid().required()
+ })
+ .required()
+ }
+ }
+ }
+}
+
+/**
+ * @import { Request, ResponseToolkit, ServerRoute } from '@hapi/hapi'
+ * @import { GetPaymentResponse, PaymentSessionData } from '~/src/server/plugins/payment/types.js'
+ * @import { PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
+ * @import { ExternalStateAppendage, FormState } from '~/src/server/plugins/engine/types.js'
+ */
diff --git a/src/server/plugins/engine/routes/payment.test.js b/src/server/plugins/engine/routes/payment.test.js
new file mode 100644
index 000000000..9ae926297
--- /dev/null
+++ b/src/server/plugins/engine/routes/payment.test.js
@@ -0,0 +1,180 @@
+import { StatusCodes } from 'http-status-codes'
+
+import { createServer } from '~/src/server/index.js'
+import { getPaymentContext } from '~/src/server/plugins/engine/routes/payment-helper.js'
+import { renderResponse } from '~/test/helpers/component-helpers.js'
+
+jest.mock('~/src/server/plugins/engine/routes/payment-helper.js')
+
+describe('Payment routes', () => {
+ /** @type {Server} */
+ let server
+
+ beforeAll(async () => {
+ server = await createServer()
+ await server.initialize()
+ })
+
+ beforeEach(() => {
+ jest.resetAllMocks()
+ })
+
+ describe('Return route /payment-callback', () => {
+ const uuid = '06a5b11e-e3e0-48a2-8ac3-56c0fcb6c20d'
+ const options = {
+ method: 'get',
+ url: `/payment-callback?uuid=${uuid}`
+ }
+
+ const paymentSessionData = {
+ uuid,
+ formId: 'form-id',
+ reference: 'form-ref-123',
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ componentName: 'my-component',
+ returnUrl: 'http://host.com/return-url',
+ failureUrl: 'http://host.com/failure-url'
+ }
+ const sessionKey = 'session-key'
+
+ test.each([
+ { status: 'capturable', finalUrl: 'http://host.com/return-url' },
+ { status: 'success', finalUrl: 'http://host.com/return-url' },
+ { status: 'cancelled', finalUrl: 'http://host.com/failure-url' },
+ { status: 'failed', finalUrl: 'http://host.com/failure-url' },
+ { status: 'error', finalUrl: 'http://host.com/failure-url' },
+ { status: 'created', finalUrl: '/next-url' },
+ { status: 'started', finalUrl: '/next-url' },
+ { status: 'submitted', finalUrl: '/next-url' }
+ ])('should handle payment status of $row.status', async (row) => {
+ const paymentStatus = {
+ paymentId: 'new-payment-id',
+ amount: 125,
+ _links: {
+ next_url: {
+ href: '/next-url',
+ method: 'get'
+ },
+ self: {
+ href: '/self',
+ method: 'get'
+ }
+ },
+ state: /** @type {PaymentResponseState} */ ({
+ status: row.status,
+ finished: true
+ })
+ }
+ jest.mocked(getPaymentContext).mockResolvedValueOnce({
+ session: paymentSessionData,
+ sessionKey,
+ paymentStatus
+ })
+ const { response } = await renderResponse(server, options)
+
+ expect(response.statusCode).toBe(StatusCodes.SEE_OTHER)
+ expect(response.headers.location).toBe(row.finalUrl)
+ })
+
+ it('should throw if nextUrl is missing', async () => {
+ const paymentStatus = {
+ paymentId: 'new-payment-id',
+ _links: {
+ next_url: {},
+ self: {
+ href: '/self',
+ method: 'get'
+ }
+ },
+ state: /** @type {PaymentResponseState} */ ({
+ status: 'created',
+ finished: true
+ })
+ }
+ jest.mocked(getPaymentContext).mockResolvedValueOnce({
+ session: paymentSessionData,
+ sessionKey,
+ // @ts-expect-error - deliberate missing element from object
+ paymentStatus
+ })
+ const { response } = await renderResponse(server, options)
+
+ expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST)
+ // @ts-expect-error - error object
+ expect(response.result?.message).toBe(
+ "Payment in state 'created' but no next_url available"
+ )
+ })
+
+ it('should throw if invalid status', async () => {
+ const paymentStatus = {
+ paymentId: 'new-payment-id',
+ _links: {
+ next_url: {
+ href: '/next-url',
+ method: 'get'
+ },
+ self: {
+ href: '/self',
+ method: 'get'
+ }
+ },
+ state: {
+ status: 'invalid',
+ finished: true
+ }
+ }
+ jest.mocked(getPaymentContext).mockResolvedValueOnce({
+ session: paymentSessionData,
+ sessionKey,
+ // @ts-expect-error - deliberate invalid value which doesnt meet type
+ paymentStatus
+ })
+ const { response } = await renderResponse(server, options)
+
+ expect(response.statusCode).toBe(StatusCodes.INTERNAL_SERVER_ERROR)
+ // @ts-expect-error - error object
+ expect(response.result?.message).toBe('Unknown payment status: invalid')
+ })
+
+ it('should handle payment with email from GOV.UK Pay response', async () => {
+ const paymentStatus = {
+ paymentId: 'new-payment-id',
+ payment_id: 'new-payment-id',
+ amount: 125,
+ email: 'payer@example.com',
+ _links: {
+ next_url: {
+ href: '/next-url',
+ method: 'get'
+ },
+ self: {
+ href: '/self',
+ method: 'get'
+ }
+ },
+ state: /** @type {PaymentResponseState} */ ({
+ status: 'success',
+ finished: true
+ })
+ }
+ jest.mocked(getPaymentContext).mockResolvedValueOnce({
+ session: paymentSessionData,
+ sessionKey,
+ paymentStatus
+ })
+ const { response } = await renderResponse(server, options)
+
+ expect(response.statusCode).toBe(StatusCodes.SEE_OTHER)
+ expect(response.headers.location).toBe('http://host.com/return-url')
+ })
+ })
+})
+
+/**
+ * @import { Server } from '@hapi/hapi'
+ * @import { PaymentResponseState } from '~/src/server/plugins/payment/types.js'
+ */
diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js
index 6dc0d2d3f..c3fbfaef4 100644
--- a/src/server/plugins/engine/services/localFormsService.js
+++ b/src/server/plugins/engine/services/localFormsService.js
@@ -58,5 +58,12 @@ export const formsService = async () => {
slug: 'simple-form'
})
+ await loader.addForm('src/server/forms/payment-test.yaml', {
+ ...metadata,
+ id: 'b2c3d4e5-f6a7-8901-bcde-f01234567890',
+ title: 'Payment Test Form',
+ slug: 'payment-test'
+ })
+
return loader.toFormsService()
}
diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts
index c071dc9f1..901950e2e 100644
--- a/src/server/plugins/engine/types.ts
+++ b/src/server/plugins/engine/types.ts
@@ -17,7 +17,10 @@ import { type JoiExpression, type ValidationErrorItem } from 'joi'
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
import { type UkAddressState } from '~/src/server/plugins/engine/components/UkAddressField.js'
import { type Component } from '~/src/server/plugins/engine/components/helpers/components.js'
-import { type FileUploadField } from '~/src/server/plugins/engine/components/index.js'
+import {
+ type FileUploadField,
+ type PaymentField
+} from '~/src/server/plugins/engine/components/index.js'
import {
type BackLink,
type ComponentText,
@@ -329,6 +332,8 @@ export interface FormPageViewModel extends PageViewModelBase {
errors?: FormSubmissionError[]
hasMissingNotificationEmail?: boolean
allowSaveAndExit: boolean
+ showSubmitButton?: boolean
+ showPaymentExpiredNotification?: boolean
}
export interface RepeaterSummaryPageViewModel extends PageViewModelBase {
@@ -393,6 +398,8 @@ export interface ExternalArgs {
controller: QuestionPageController
sourceUrl: string
actionArgs: Record
+ isLive: boolean
+ isPreview: boolean
}
export interface PostcodeLookupExternalArgs extends ExternalArgs {
@@ -454,6 +461,14 @@ export interface FormAdapterFile {
userDownloadLink: string
}
+export interface FormAdapterPayment {
+ paymentId: string
+ reference: string
+ amount: number
+ description: string
+ createdAt: string
+}
+
export interface FormAdapterSubmissionMessageResult {
files: {
main: string
@@ -467,6 +482,13 @@ export interface FormAdapterSubmissionMessageResult {
export type FileUploadFieldDetailitem = Omit & {
field: FileUploadField
}
+
+/**
+ * A detail item specifically for payments
+ */
+export type PaymentFieldDetailItem = Omit & {
+ field: PaymentField
+}
export type RichFormValue =
| FormValue
| FormPayload
@@ -480,6 +502,7 @@ export interface FormAdapterSubmissionMessageData {
main: Record
repeaters: Record[]>
files: Record
+ payment?: FormAdapterPayment
}
export interface FormAdapterSubmissionMessagePayload {
diff --git a/src/server/plugins/engine/types/schema.ts b/src/server/plugins/engine/types/schema.ts
index 203c4f1ec..b7d18f600 100644
--- a/src/server/plugins/engine/types/schema.ts
+++ b/src/server/plugins/engine/types/schema.ts
@@ -43,6 +43,15 @@ export const formAdapterSubmissionMessageDataSchema =
Joi.object().keys({
main: Joi.object(),
repeaters: Joi.object(),
+ payment: Joi.object()
+ .keys({
+ paymentId: Joi.string().required(),
+ reference: Joi.string().required(),
+ amount: Joi.number().required(),
+ description: Joi.string().required(),
+ createdAt: Joi.string().required()
+ })
+ .optional(),
files: Joi.object().pattern(
Joi.string(),
Joi.array().items(
diff --git a/src/server/plugins/engine/validationHelpers.ts b/src/server/plugins/engine/validationHelpers.ts
index 268cf5792..fa5fcfe9b 100644
--- a/src/server/plugins/engine/validationHelpers.ts
+++ b/src/server/plugins/engine/validationHelpers.ts
@@ -20,7 +20,7 @@ export interface ExternalComponent {
request: FormRequestPayload,
h: FormResponseToolkit,
args: ExternalArgs
- ): ResponseObject
+ ): Promise
}
/**
diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html
new file mode 100644
index 000000000..b75ff5024
--- /dev/null
+++ b/src/server/plugins/engine/views/components/paymentfield.html
@@ -0,0 +1,42 @@
+{% from "govuk/components/warning-text/macro.njk" import govukWarningText %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+
+{% macro PaymentField(component) %}
+ {% set model = component.model %}
+ {% set amount = model.amount %}
+ {% set description = model.description %}
+ {% set paymentState = model.paymentState %}
+ {% set isPreAuthorised = paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %}
+
+
+ {% if isPreAuthorised %}
+ {# Payment already pre-authorised - show confirmation message #}
+
You have already authorised a payment for this form
+
+
Continue to submit the form. You will not be charged twice.
+ {% else %}
+ {# No pre-authorisation - show payment form #}
+
{{ model.label.text if model.label and model.label.text else "Payment details required" }}
+
+
{{ description }}
+
+ {{ govukWarningText({
+ text: "You may see a pending transaction in your bank account but you will only be charged when you submit the form.",
+ iconFallbackText: "Warning"
+ }) }}
+
+
You can submit the form after you have added your payment details.
+
+
Total amount:
+
£{{ amount }}
+
+ {{ govukButton({
+ text: "Add payment details",
+ attributes: {
+ name: "action",
+ value: "external-" + model.name
+ }
+ }) }}
+ {% endif %}
+
+{% endmacro %}
diff --git a/src/server/plugins/engine/views/index.html b/src/server/plugins/engine/views/index.html
index c5cc8d8f3..2c142e569 100644
--- a/src/server/plugins/engine/views/index.html
+++ b/src/server/plugins/engine/views/index.html
@@ -1,6 +1,7 @@
{% extends baseLayoutPath %}
{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %}
{% from "partials/components.html" import componentList with context %}
{% block content %}
@@ -10,7 +11,14 @@
{% include "partials/preview-banner.html" %}
{% endif %}
- {% if errors | length %}
+ {% if showPaymentExpiredNotification %}
+ {{ govukNotificationBanner({
+ titleText: "Important",
+ html: 'Your payment has been cancelled
Your payment details were deleted because the form was inactive for 5 days.
Add your payment details again.
'
+ }) }}
+ {% endif %}
+
+ {% if errors | length and not showPaymentExpiredNotification %}
{{ govukErrorSummary({
titleText: "There is a problem",
errorList: checkErrorTemplates(errors)
diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html
index a2732ba39..fb19e3b0f 100644
--- a/src/server/plugins/engine/views/partials/form.html
+++ b/src/server/plugins/engine/views/partials/form.html
@@ -1,6 +1,19 @@
{% from "govuk/components/button/macro.njk" import govukButton %}
{% from "govuk/components/summary-list/macro.njk" import govukSummaryList -%}
+{% set noPaymentFields = true %}
+{% set hasIncompletePayment = false %}
+
+{% for comp in components %}
+ {% if comp.type == 'PaymentField' %}
+ {% set noPaymentFields = false %}
+ {# Check if payment is incomplete (no preAuth status) #}
+ {% if not comp.model.paymentState or not comp.model.paymentState.preAuth or comp.model.paymentState.preAuth.status != 'success' %}
+ {% set hasIncompletePayment = true %}
+ {% endif %}
+ {% endif %}
+{% endfor %}
+