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 %} +
@@ -15,11 +28,13 @@ {% endif %}
- {{ govukButton({ - text: buttonText, - isStartButton: isStartPage, - preventDoubleClick: true - }) }} + {% if showSubmitButton !== false and not hasIncompletePayment %} + {{ govukButton({ + text: buttonText, + isStartButton: isStartPage, + preventDoubleClick: true + }) }} + {% endif %} {% if allowSaveAndExit %} {{ govukButton({ diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index 765c52487..100c952dc 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -3,6 +3,7 @@ {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} {% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} {% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} {% from "partials/components.html" import componentList with context %} {% from "govuk/components/input/macro.njk" import govukInput %} @@ -13,6 +14,14 @@ {% include "partials/preview-banner.html" %} {% endif %} + {% if paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %} + {{ govukNotificationBanner({ + type: "success", + titleText: "Success", + html: "

We have your payment details

Your payment is on hold. We will charge you when you submit the form.

" + }) }} + {% endif %} + {% if errors %} {{ govukErrorSummary({ titleText: "There is a problem", @@ -41,6 +50,13 @@

{% endif %} {% endfor %} + {% if paymentDetails %} +

+ {{ paymentDetails.title.text }} +

+ {{ govukSummaryList(paymentDetails.summaryList) }} + {% endif %} + @@ -59,7 +75,7 @@

Declaration

{% set isDeclaration = declaration or components | length %} {{ govukButton({ - text: "Accept and send" if isDeclaration else "Send", + text: "Accept and submit" if isDeclaration else "Submit", name: "action", value: "send", preventDoubleClick: true diff --git a/src/server/plugins/payment/helper.js b/src/server/plugins/payment/helper.js new file mode 100644 index 000000000..042ca97d9 --- /dev/null +++ b/src/server/plugins/payment/helper.js @@ -0,0 +1,56 @@ +import { format } from 'date-fns' + +import { PaymentService } from '~/src/server/plugins/payment/service.js' + +export const DEFAULT_PAYMENT_HELP_URL = + 'https://www.gov.uk/government/organisations/department-for-environment-food-rural-affairs' + +/** + * Determine which payment API key value to use. + * If a draft preview form or a live preview form, read the TEST API key value specific to that form. + * If a live (non-preview) form, read the LIVE API key value specific to that form. + * @param {boolean} isLivePayment - true if this is a live payment (as opposed to a test one) + * @param {string} formId - id of the form + * @returns {string} + */ +export function getPaymentApiKey(isLivePayment, formId) { + const apiKeyValue = isLivePayment + ? process.env[`PAYMENT_PROVIDER_API_KEY_LIVE_${formId}`] + : process.env[`PAYMENT_PROVIDER_API_KEY_TEST_${formId}`] + + if (!apiKeyValue) { + throw new Error( + `Missing payment api key for ${isLivePayment ? 'live' : 'test'} form id ${formId}` + ) + } + return apiKeyValue +} + +/** + * Creates a PaymentService instance with the appropriate API key + * @param {boolean} isLivePayment - true if this is a live payment + * @param {string} formId - id of the form + * @returns {PaymentService} + */ +export function createPaymentService(isLivePayment, formId) { + const apiKey = getPaymentApiKey(isLivePayment, formId) + return new PaymentService(apiKey) +} + +/** + * Formats a payment date for display + * @param {string} isoString - ISO date string + * @returns {string} Formatted date string (e.g., "26 January 2026 5:01pm") + */ +export function formatPaymentDate(isoString) { + return format(new Date(isoString), 'd MMMM yyyy h:mmaaa') +} + +/** + * Formats a payment amount with two decimal places + * @param {number} amount - amount in pounds + * @returns {string} Formatted amount (e.g., "£10.00") + */ +export function formatPaymentAmount(amount) { + return `£${amount.toFixed(2)}` +} diff --git a/src/server/plugins/payment/helper.test.js b/src/server/plugins/payment/helper.test.js new file mode 100644 index 000000000..f8c0a9322 --- /dev/null +++ b/src/server/plugins/payment/helper.test.js @@ -0,0 +1,52 @@ +import { config } from '~/src/config/index.js' +import { + formatPaymentAmount, + formatPaymentDate, + getPaymentApiKey +} from '~/src/server/plugins/payment/helper.js' + +describe('getPaymentApiKey', () => { + config.set('paymentProviderApiKeyTest', 'TEST-API-KEY') + const formId = 'form-id' + process.env['PAYMENT_PROVIDER_API_KEY_LIVE_form-id'] = 'LIVE-API-KEY' + process.env['PAYMENT_PROVIDER_API_KEY_TEST_form-id'] = 'TEST-API-KEY' + + it('should read test key when non-live form', () => { + const apiKey = getPaymentApiKey(false, formId) + expect(apiKey).toBe('TEST-API-KEY') + }) + + it('should read live key when live form', () => { + const apiKey = getPaymentApiKey(true, formId) + expect(apiKey).toBe('LIVE-API-KEY') + }) + + it('should throw if TEST key is missing', () => { + expect(() => getPaymentApiKey(false, 'form-id-missing')).toThrow( + 'Missing payment api key for test form id form-id-missing' + ) + }) + + it('should throw if LIVE key is missing', () => { + expect(() => getPaymentApiKey(true, 'form-id-missing')).toThrow( + 'Missing payment api key for live form id form-id-missing' + ) + }) +}) + +describe('formatPaymentDate', () => { + it('should format ISO date string to en-GB format', () => { + const result = formatPaymentDate('2025-11-10T17:01:29.000Z') + expect(result).toBe('10 November 2025 5:01pm') + }) +}) + +describe('formatPaymentAmount', () => { + it('should format whole number with two decimal places', () => { + expect(formatPaymentAmount(10)).toBe('£10.00') + }) + + it('should format decimal amount', () => { + expect(formatPaymentAmount(99.5)).toBe('£99.50') + }) +}) diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js new file mode 100644 index 000000000..9247355b5 --- /dev/null +++ b/src/server/plugins/payment/service.js @@ -0,0 +1,171 @@ +import { StatusCodes } from 'http-status-codes' + +import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { get, post, postJson } from '~/src/server/services/httpService.js' + +const PAYMENT_BASE_URL = 'https://publicapi.payments.service.gov.uk' +const PAYMENT_ENDPOINT = '/v1/payments' + +const logger = createLogger() + +/** + * @param {string} apiKey + * @returns {{ Authorization: string }} + */ +function getAuthHeaders(apiKey) { + return { + Authorization: `Bearer ${apiKey}` + } +} + +export class PaymentService { + /** @type {string} */ + #apiKey + + /** + * @param {string} apiKey - API key to use (global config for test value, per-form config for live value) + */ + constructor(apiKey) { + this.#apiKey = apiKey + } + + /** + * Creates a payment with delayed capture (pre-authorisation) + * @param {number} amount - in pence + * @param {string} description + * @param {string} returnUrl + * @param {string} reference + * @param {{ formId: string, slug: string }} metadata + */ + async createPayment(amount, description, returnUrl, reference, metadata) { + const response = await this.postToPayProvider({ + amount, + description, + reference, + metadata, + return_url: returnUrl, + delayed_capture: true + }) + + return { + paymentId: response.payment_id, + paymentUrl: response._links.next_url.href + } + } + + /** + * @param {string} paymentId + * @returns {Promise} + */ + async getPaymentStatus(paymentId) { + const getByType = /** @type {typeof get} */ (get) + + try { + const response = await getByType( + `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}/${paymentId}`, + { + headers: getAuthHeaders(this.#apiKey), + json: true + } + ) + + if (response.error) { + const errorMessage = + response.error instanceof Error + ? response.error.message + : JSON.stringify(response.error) + throw new Error(`Failed to get payment status: ${errorMessage}`) + } + + return { + state: response.payload.state, + _links: response.payload._links, + email: response.payload.email, + paymentId: response.payload.payment_id, + amount: response.payload.amount + } + } catch (err) { + const error = /** @type {Error} */ (err) + logger.error( + error, + `[payment] Error getting payment status for paymentId=${paymentId}: ${error.message}` + ) + throw err + } + } + + /** + * Captures a payment that is in 'capturable' status + * @param {string} paymentId + * @returns {Promise} + */ + async capturePayment(paymentId) { + try { + const response = await post( + `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}/${paymentId}/capture`, + { + headers: getAuthHeaders(this.#apiKey) + } + ) + + const statusCode = response.res.statusCode + + if ( + statusCode === StatusCodes.OK || + statusCode === StatusCodes.NO_CONTENT + ) { + logger.info(`[payment] Successfully captured payment ${paymentId}`) + return true + } + + logger.error( + `[payment] Capture failed for paymentId=${paymentId}: HTTP ${statusCode}` + ) + return false + } catch (err) { + const error = /** @type {Error} */ (err) + logger.error( + error, + `[payment] Error capturing payment for paymentId=${paymentId}: ${error.message}` + ) + throw err + } + } + + /** + * @param {CreatePaymentRequest} payload + */ + async postToPayProvider(payload) { + const postJsonByType = + /** @type {typeof postJson} */ (postJson) + + try { + const response = await postJsonByType( + `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}`, + { + payload, + headers: getAuthHeaders(this.#apiKey) + } + ) + + if (response.payload?.state.status !== 'created') { + throw new Error( + `Failed to create payment for reference=${payload.reference}` + ) + } + + return response.payload + } catch (err) { + const error = /** @type {Error} */ (err) + logger.error( + error, + `[payment] Error creating payment for reference=${payload.reference}: ${error.message}` + ) + throw err + } + } +} + +/** + * @import { CreatePaymentRequest, CreatePaymentResponse, GetPaymentApiResponse, GetPaymentResponse } from '~/src/server/plugins/payment/types.js' + */ diff --git a/src/server/plugins/payment/service.test.js b/src/server/plugins/payment/service.test.js new file mode 100644 index 000000000..f28018db6 --- /dev/null +++ b/src/server/plugins/payment/service.test.js @@ -0,0 +1,205 @@ +import { PaymentService } from '~/src/server/plugins/payment/service.js' +import { get, post, postJson } from '~/src/server/services/httpService.js' + +jest.mock('~/src/server/services/httpService.ts') + +describe('payment service', () => { + const service = new PaymentService('my-api-key') + describe('constructor', () => { + it('should create instance', () => { + expect(service).toBeDefined() + }) + }) + + describe('createPayment', () => { + it('should create a payment', async () => { + const createPaymentResult = { + payment_id: 'payment-id-12345', + _links: { + next_url: { + href: 'http://next-url-href/payment' + } + }, + state: { + status: 'created' + } + } + jest.mocked(postJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: createPaymentResult, + error: undefined + }) + + const referenceNumber = 'ABC-DEF-123' + const returnUrl = 'http://localhost:3009/payment-callback-handler' + const metadata = { formId: 'form-id', slug: 'my-form-slug' } + const payment = await service.createPayment( + 100, + 'Payment description', + returnUrl, + referenceNumber, + metadata + ) + expect(payment.paymentId).toBe('payment-id-12345') + expect(payment.paymentUrl).toBe('http://next-url-href/payment') + }) + + it('should throw if fails to create a payment - failed API call', async () => { + jest + .mocked(postJson) + .mockRejectedValueOnce(new Error('internal creation error')) + + const referenceNumber = 'ABC-DEF-123' + const returnUrl = 'http://localhost:3009/payment-callback-handler' + const metadata = { formId: 'form-id', slug: 'my-form-slug' } + await expect(() => + service.createPayment( + 100, + 'Payment description', + returnUrl, + referenceNumber, + metadata + ) + ).rejects.toThrow('internal creation error') + }) + + it('should throw if fails to create a payment - bad result from API call', async () => { + const createPaymentResult = { + state: { + status: 'failed' + } + } + jest.mocked(postJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: createPaymentResult, + error: undefined + }) + + const referenceNumber = 'ABC-DEF-123' + const returnUrl = 'http://localhost:3009/payment-callback-handler' + const metadata = { formId: 'form-id', slug: 'my-form-slug' } + await expect(() => + service.createPayment( + 100, + 'Payment description', + returnUrl, + referenceNumber, + metadata + ) + ).rejects.toThrow('Failed to create payment') + }) + }) + + describe('getPaymentStatus', () => { + it('should get payment status if exists', async () => { + const getPaymentStatusResult = { + 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: getPaymentStatusResult, + error: undefined + }) + + const paymentStatus = await service.getPaymentStatus('payment-id-12345') + expect(paymentStatus.paymentId).toBe('payment-id-12345') + expect(paymentStatus._links.next_url?.href).toBe( + 'http://next-url-href/payment' + ) + }) + + it('should handle payment status error', async () => { + jest.mocked(get).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: undefined, + error: new Error('some-error') + }) + + await expect(() => + service.getPaymentStatus('payment-id-12345') + ).rejects.toThrow('Failed to get payment status: some-error') + }) + }) + + describe('capturePayment', () => { + it('should return true when successful capture with statusCode 200', async () => { + const capturePaymentResult = {} + jest.mocked(post).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: capturePaymentResult, + error: undefined + }) + + const captureResult = await service.capturePayment('payment-id-12345') + expect(captureResult).toBe(true) + }) + + it('should return true when successful capture with statusCode 204', async () => { + const capturePaymentResult = {} + jest.mocked(post).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 204, + headers: {} + }), + payload: capturePaymentResult, + error: undefined + }) + + const captureResult = await service.capturePayment('payment-id-12345') + expect(captureResult).toBe(true) + }) + + it('should return false when status code not 200 or 204', async () => { + const capturePaymentResult = {} + jest.mocked(post).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 500, + headers: {} + }), + payload: capturePaymentResult, + error: undefined + }) + + const captureResult = await service.capturePayment('payment-id-12345') + expect(captureResult).toBe(false) + }) + + it('should throw when internal error', async () => { + jest + .mocked(post) + .mockRejectedValueOnce(new Error('internal capture error')) + + await expect(() => + service.capturePayment('payment-id-12345') + ).rejects.toThrow('internal capture error') + }) + }) +}) + +/** + * @import { IncomingMessage } from 'node:http' + */ diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js new file mode 100644 index 000000000..d58783d4a --- /dev/null +++ b/src/server/plugins/payment/types.js @@ -0,0 +1,77 @@ +/** + * @typedef {object} PaymentResponseState + * @property {'created' | 'started' | 'submitted' | 'capturable' | 'success' | 'failed' | 'cancelled' | 'error'} status - Current status of the payment + * @property {boolean} finished - Whether the payment process has completed + * @property {string} [message] - Human-readable message about the payment state + * @property {string} [code] - Error or status code for the payment state + */ + +/** + * @typedef {object} PaymentLink + * @property {string} href - URL of the linked resource + * @property {string} method - HTTP method to use for the link + */ + +/** + * @typedef {object} CreatePaymentRequest + * @property {number} amount - Payment amount in pence + * @property {string} reference - Unique reference for the payment + * @property {string} description - Human-readable description of the payment + * @property {string} return_url - URL to redirect the user to after payment + * @property {boolean} [delayed_capture] - Whether to delay capturing the payment + * @property {{ formId: string, slug: string }} [metadata] - Additional metadata for the payment + */ + +/** + * @typedef {object} CreatePaymentResponse + * @property {string} payment_id - Unique identifier for the created payment + * @property {PaymentResponseState} state - Current state of the payment + * @property {{ next_url: PaymentLink }} _links - HATEOAS links for the payment + */ + +/** + * Base response from GOV.UK Pay GET /v1/payments/{PAYMENT_ID} endpoint + * @typedef {object} GetPaymentResponseBase + * @property {PaymentResponseState} state - Current state of the payment + * @property {{ self: PaymentLink, next_url?: PaymentLink }} _links - HATEOAS links for the payment + * @property {string} [email] - The paying user's email address + */ + +/** + * Response from GOV.UK Pay GET /v1/payments/{PAYMENT_ID} endpoint - not underscore in property name + * @typedef {object} GetPaymentApiResponsePaymentProp + * @property {string} payment_id - Unique identifier for the payment + * @property {number} amount - amount of the payment + */ + +/** + * Response from GOV.UK Pay GET /v1/payments/{PAYMENT_ID} endpoint + * @typedef {GetPaymentResponseBase & GetPaymentApiResponsePaymentProp} GetPaymentApiResponse + */ + +/** + * Response returned from getPaymentStatus - subtley different from GetPaymentApiResponse + * @typedef {object} GetPaymentResponsePaymentProp + * @property {string} paymentId - Unique identifier for the payment - note no underscore in property name + * @property {number} amount - amount of the payment + */ + +/** + * Response returned from getPaymentStatus - subtley different from GetPaymentApiResponse + * @typedef {GetPaymentResponseBase & GetPaymentResponsePaymentProp} GetPaymentResponse + */ + +/** + * Payment session data stored when dispatching to GOV.UK Pay + * @typedef {object} PaymentSessionData + * @property {string} uuid - unique identifier for this payment attempt + * @property {string} formId - id of the form + * @property {string} reference - form reference number + * @property {number} amount - amount in pounds + * @property {string} description - payment description + * @property {string} paymentId - GOV.UK Pay payment ID + * @property {string} componentName - name of the PaymentField component + * @property {string} returnUrl - URL to redirect to after successful payment + * @property {string} failureUrl - URL to redirect to after failed/cancelled payment + * @property {boolean} isLivePayment - whether the payment is using live API key + */ diff --git a/src/server/types.ts b/src/server/types.ts index e37e347c6..ab90d4cce 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -15,6 +15,7 @@ import { type PluginOptions, type PreparePageEventRequestOptions } from '~/src/server/plugins/engine/types.js' +import { type PaymentService } from '~/src/server/plugins/payment/service.js' import { type FormRequestPayload, type FormStatus @@ -42,6 +43,7 @@ export interface Services { formsService: FormsService formSubmissionService: FormSubmissionService outputService: OutputService + paymentService?: PaymentService } export interface RouteConfig { diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index ef364f63b..64334e517 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -40,6 +40,7 @@ declare module '@hapi/hapi' { request: AnyFormRequest | null ) => Record | Promise> saveAndExit?: PluginOptions['saveAndExit'] + baseUrl: string } } diff --git a/test/form/definitions/payment.js b/test/form/definitions/payment.js new file mode 100644 index 000000000..479bf0799 --- /dev/null +++ b/test/form/definitions/payment.js @@ -0,0 +1,128 @@ +import { + ComponentType, + ControllerPath, + ControllerType +} from '@defra/forms-model' + +import { + createListFromFactory, + createListItemFactory +} from '~/test/form/factory.js' + +export default /** @satisfies {FormDefinition} */ ({ + name: 'Simple payment', + startPage: '/licence', + pages: /** @type {const} */ ([ + { + title: 'Buy a rod fishing licence', + path: '/licence', + components: [ + { + options: { + bold: true + }, + type: ComponentType.RadiosField, + name: 'licenceLength', + title: 'Which fishing licence do you want to get?', + list: 'licenceLengthDays' + } + ], + section: 'licenceDetails', + next: [ + { + path: '/full-name' + } + ] + }, + { + title: "What's your name?", + path: '/full-name', + components: [ + { + schema: { + max: 70 + }, + options: {}, + type: ComponentType.TextField, + name: 'fullName', + title: "What's your name?" + } + ], + section: 'personalDetails', + next: [ + { + path: '/payment' + } + ] + }, + { + path: '/payment', + title: 'Payment', + components: [ + { + options: { + amount: 250, + description: 'Pay for your licence' + }, + type: ComponentType.PaymentField, + name: 'paymentField', + title: "What's your name?" + } + ], + next: [ + { + path: ControllerPath.Summary + } + ] + }, + { + path: ControllerPath.Summary, + controller: ControllerType.Summary, + title: 'Summary' + } + ]), + sections: [ + { + name: 'licenceDetails', + title: 'Licence details' + }, + { + name: 'personalDetails', + title: 'Personal details' + } + ], + conditions: [], + lists: [ + createListFromFactory({ + name: 'licenceLengthDays', + title: 'Licence length (days)', + type: 'number', + items: [ + createListItemFactory({ + id: '52fc51fc-c75a-4b08-9c9e-6bd99b9bc49b', + text: '1 day', + value: 1, + description: 'Valid for 24 hours from the start time that you select' + }), + createListItemFactory({ + id: '56b7b34f-23b3-4446-ac8e-b2443d18588e', + text: '8 day', + value: 8, + description: + 'Valid for 8 consecutive days from the start time that you select' + }), + createListItemFactory({ + id: '1af54fbc-eec2-4e1e-bd53-2415abf62677', + text: '12 months', + value: 365, + description: + '12-month licences are now valid for 365 days from their start date and can be purchased at any time during the year' + }) + ] + }) + ] +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */