Repository: DEFRA/grants-ui
Pull request #50: Send land grants application to GAS
Author: login: dcapilla
Created: 2025-04-22T14:50:17Z | Merged: 2025-04-28T08:37:01Z



Diff:
diff --git a/src/config/config.js b/src/config/config.js
index f23e6310b..426124b8d 100644
--- a/src/config/config.js
+++ b/src/config/config.js
@@ -87,6 +87,20 @@ export const config = convict({
     format: Boolean,
     default: isTest
   },
+  gas: {
+    apiEndpoint: {
+      doc: 'GAS API endpoint',
+      format: String,
+      default: '',
+      env: 'GAS_API_URL'
+    },
+    frpsGrantCode: {
+      doc: 'GAS Future RPS grant code',
+      format: String,
+      default: '',
+      env: 'GAS_FRPS_GRANT_CODE'
+    }
+  },
   landGrants: {
     apiEndpoint: {
       format: String,
diff --git a/src/server/common/forms/definitions/find-funding-for-land-or-farms.json b/src/server/common/forms/definitions/find-funding-for-land-or-farms.json
index 732e76444..2da1e54c4 100644
--- a/src/server/common/forms/definitions/find-funding-for-land-or-farms.json
+++ b/src/server/common/forms/definitions/find-funding-for-land-or-farms.json
@@ -53,7 +53,7 @@
     },
     {
       "title": "Select Land Parcel",
-      "controller": "LandParcelController",
+      "controller": "LandParcelPageController",
       "path": "/select-land-parcel",
       "next": [{ "path": "/select-land-actions" }],
       "components": [
@@ -70,7 +70,7 @@
     },
     {
       "title": "Select Land Actions",
-      "controller": "LandActionsController",
+      "controller": "LandActionsPageController",
       "path": "/select-land-actions",
       "next": [{ "path": "/summary" }],
       "components": [
@@ -101,12 +101,15 @@
         }
       ]
     },
-
     {
+      "title": "Funding Details",
       "path": "/summary",
-      "controller": "SummaryPageController",
-      "title": "Funding details",
-      "components": []
+      "controller": "SubmissionPageController"
+    },
+    {
+      "title": "Confirmation",
+      "path": "/confirmation",
+      "controller": "ConfirmationPageController"
     }
   ],
   "lists": [],
diff --git a/src/server/common/helpers/grant-application-service/state-to-gas-payload-mapper.js b/src/server/common/helpers/grant-application-service/state-to-gas-payload-mapper.js
new file mode 100644
index 000000000..dc7f82a18
--- /dev/null
+++ b/src/server/common/helpers/grant-application-service/state-to-gas-payload-mapper.js
@@ -0,0 +1,39 @@
+import crypto from 'crypto'
+
+/**
+ * @typedef {object} GASMetadata
+ * @property {string} [sbi] - Standard Business Identifier
+ * @property {string} [frn] - FRN
+ * @property {string} [crn] - Customer Reference Number
+ * @property {string} [defraId] - Defra ID
+ * @property {string} [clientRef] - Client Reference
+ * @property {string} [submittedAt] - Submission date
+ */
+
+/**
+ * @typedef {object} GASPayload
+ * @property {GASMetadata} [metadata] - GAS Metadata
+ * @property {object} [answers] - GAS Answers
+ */
+
+/**
+ * Transforms FormContext object into a GAS Application payload for Land Grants.
+ * @param {object} state - the DXT state object containing application details
+ * @param {Function} transformAnswers - a function to transform the state object into the desired answers format
+ * @returns {GASPayload}
+ */
+export const transformStateObjectToGasApplication = (
+  { sbi, frn, crn, defraId },
+  state,
+  transformAnswers
+) => ({
+  metadata: {
+    sbi,
+    frn,
+    crn,
+    defraId,
+    clientRef: crypto.randomUUID(),
+    submittedAt: new Date().toISOString()
+  },
+  answers: transformAnswers(state)
+})
diff --git a/src/server/common/helpers/grant-application-service/state-to-gas-payload-mapper.test.js b/src/server/common/helpers/grant-application-service/state-to-gas-payload-mapper.test.js
new file mode 100644
index 000000000..1541d95ba
--- /dev/null
+++ b/src/server/common/helpers/grant-application-service/state-to-gas-payload-mapper.test.js
@@ -0,0 +1,275 @@
+import { transformStateObjectToGasApplication } from '~/src/server/common/helpers/grant-application-service/state-to-gas-payload-mapper.js'
+import { validateGasPayload } from '~/src/server/common/schemas/gas-payload.schema.js'
+
+jest.mock('crypto', () => ({ randomUUID: () => 'CLIENT-REF-456' }))
+
+const mockDate = new Date('2025-04-22T12:00:00Z')
+const originalDate = global.Date
+
+beforeAll(() => {
+  global.Date = class extends Date {
+    constructor() {
+      return mockDate
+    }
+
+    static now() {
+      return mockDate.getTime()
+    }
+
+    toISOString() {
+      return mockDate.toISOString()
+    }
+  }
+})
+
+afterAll(() => {
+  global.Date = originalDate
+})
+
+describe('transformStateObjectToGasApplication', () => {
+  it('should transform a state object with basic properties', () => {
+    const identifiers = {
+      sbi: '12345678',
+      frn: 'FRN123456',
+      crn: 'CRN789012',
+      defraId: 'DEFRA-ID-123',
+      clientRef: 'CLIENT-REF-456'
+    }
+    const state = {}
+
+    const mockAnswersTransformer = jest.fn().mockReturnValue({
+      scheme: 'Test Scheme',
+      year: 2025
+    })
+
+    const result = transformStateObjectToGasApplication(
+      identifiers,
+      state,
+      mockAnswersTransformer
+    )
+
+    expect(result).toEqual({
+      metadata: {
+        sbi: '12345678',
+        frn: 'FRN123456',
+        crn: 'CRN789012',
+        defraId: 'DEFRA-ID-123',
+        clientRef: 'CLIENT-REF-456',
+        submittedAt: mockDate.toISOString()
+      },
+      answers: {
+        scheme: 'Test Scheme',
+        year: 2025
+      }
+    })
+
+    expect(mockAnswersTransformer).toHaveBeenCalledWith(state)
+  })
+
+  it('should handle missing optional properties', () => {
+    const identifiers = {
+      sbi: '12345678'
+    }
+
+    const state = {}
+
+    const mockAnswersTransformer = jest.fn().mockReturnValue({
+      scheme: 'Test Scheme'
+    })
+
+    const result = transformStateObjectToGasApplication(
+      identifiers,
+      state,
+      mockAnswersTransformer
+    )
+
+    expect(result).toEqual({
+      metadata: {
+        sbi: '12345678',
+        frn: undefined,
+        crn: undefined,
+        defraId: undefined,
+        clientRef: 'CLIENT-REF-456',
+        submittedAt: mockDate.toISOString()
+      },
+      answers: {
+        scheme: 'Test Scheme'
+      }
+    })
+  })
+
+  it('should transform a state with action applications', () => {
+    const identifiers = {
+      sbi: '12345678',
+      frn: 'FRN123456',
+      crn: 'CRN789012',
+      defraId: 'DEFRA-ID-123',
+      clientRef: 'CLIENT-REF-456'
+    }
+
+    const state = {
+      actionApplications: [
+        {
+          parcelId: 'PARCEL-001',
+          sheetId: 'SHEET-001',
+          code: 'ACTION-001',
+          appliedFor: {
+            unit: 'ha',
+            quantity: 25.5
+          }
+        },
+        {
+          parcelId: 'PARCEL-002',
+          sheetId: 'SHEET-002',
+          code: 'ACTION-002',
+          appliedFor: {
+            unit: 'm2',
+            quantity: 10000
+          }
+        }
+      ]
+    }
+
+    const mockAnswersTransformer = jest.fn().mockImplementation((state) => ({
+      scheme: 'Land Grants',
+      year: 2025,
+      hasCheckedLandIsUpToDate: true,
+      actionApplications: state.actionApplications
+    }))
+
+    const result = transformStateObjectToGasApplication(
+      identifiers,
+      state,
+      mockAnswersTransformer
+    )
+
+    expect(result).toEqual({
+      metadata: {
+        sbi: '12345678',
+        frn: 'FRN123456',
+        crn: 'CRN789012',
+        defraId: 'DEFRA-ID-123',
+        clientRef: 'CLIENT-REF-456',
+        submittedAt: mockDate.toISOString()
+      },
+      answers: {
+        scheme: 'Land Grants',
+        year: 2025,
+        hasCheckedLandIsUpToDate: true,
+        actionApplications: [
+          {
+            parcelId: 'PARCEL-001',
+            sheetId: 'SHEET-001',
+            code: 'ACTION-001',
+            appliedFor: {
+              unit: 'ha',
+              quantity: 25.5
+            }
+          },
+          {
+            parcelId: 'PARCEL-002',
+            sheetId: 'SHEET-002',
+            code: 'ACTION-002',
+            appliedFor: {
+              unit: 'm2',
+              quantity: 10000
+            }
+          }
+        ]
+      }
+    })
+  })
+
+  it('should call the answers transformer with the state object', () => {
+    const identifiers = {
+      sbi: '12345678',
+      frn: 'FRN123456',
+      crn: 'CRN789012',
+      defraId: 'DEFRA-ID-123',
+      clientRef: 'CLIENT-REF-456'
+    }
+
+    const state = { sbi: '12345678' }
+    const mockAnswersTransformer = jest.fn().mockReturnValue({})
+
+    transformStateObjectToGasApplication(
+      identifiers,
+      state,
+      mockAnswersTransformer
+    )
+
+    expect(mockAnswersTransformer).toHaveBeenCalledTimes(1)
+    expect(mockAnswersTransformer).toHaveBeenCalledWith(state)
+  })
+})
+
+describe('schema validation', () => {
+  it('output always conforms to GASPayload schema structure', () => {
+    const identifiers = {
+      sbi: '12345678',
+      frn: 'FRN123456',
+      crn: 'CRN789012',
+      defraId: 'DEFRA-ID-123',
+      clientRef: 'CLIENT-REF-456'
+    }
+    const stateObjectTestCases = [
+      // Complete object being set
+      {
+        scheme: 'SFI',
+        year: 2025,
+        hasCheckedLandIsUpToDate: true,
+        landParcel: 'SX0679-9238',
+        actionsObj: {
+          CSAM1: {
+            value: '44',
+            unit: 'ha'
+          }
+        }
+      },
+      // Minimal object with actions
+      {
+        landParcel: 'SX0679-9238',
+        actionsObj: {
+          CSAM1: {
+            value: '44',
+            unit: 'ha'
+          }
+        }
+      },
+      // Multiple actions with different formats
+      {
+        landParcel: 'SX0679-9238',
+        actionsObj: {
+          CSAM1: {
+            value: '44',
+            unit: 'ha'
+          },
+          CSAM2: {
+            value: 'not-a-number',
+            unit: 'm2'
+          },
+          CSAM3: {}
+        }
+      },
+      // Only basic props
+      {
+        sbi: 'sbi-1234',
+        frn: 'frn-1234'
+      },
+      // Empty object
+      {}
+    ]
+
+    stateObjectTestCases.forEach((testCase) => {
+      const result = transformStateObjectToGasApplication(
+        identifiers,
+        testCase,
+        (a) => a
+      )
+      const { error } = validateGasPayload(result)
+
+      // We check here that the output always adheres to GasPayload expectations in terms of format
+      expect(error).toBeUndefined()
+    })
+  })
+})
diff --git a/src/server/common/schemas/gas-payload.schema.js b/src/server/common/schemas/gas-payload.schema.js
new file mode 100644
index 000000000..53746a5f9
--- /dev/null
+++ b/src/server/common/schemas/gas-payload.schema.js
@@ -0,0 +1,35 @@
+import Joi from 'joi'
+
+/**
+ * Joi schema definitions for generic GAS application payload validation
+ */
+
+const gasPayloadSchema = Joi.object({
+  metadata: {
+    clientRef: Joi.string(),
+    sbi: Joi.string(),
+    frn: Joi.string(),
+    crn: Joi.string(),
+    defraId: Joi.string(),
+    submittedAt: Joi.string().isoDate()
+  },
+  answers: Joi.object().unknown(true)
+})
+
+const defaultValidationOptions = {
+  presence: 'optional'
+}
+
+/**
+ * Validates an object against the GASPayload schema structure
+ * Only validates types of properties that are present, doesn't require any fields
+ * @param {object} payload - The object to validate
+ * @param {object} options - Joi validation options (optional)
+ * @returns {object} - Joi validation result
+ */
+export function validateGasPayload(
+  payload,
+  options = defaultValidationOptions
+) {
+  return gasPayloadSchema.validate(payload, options)
+}
diff --git a/src/server/common/services/consolidated-view.service.js b/src/server/common/services/consolidated-view.service.js
index 3d04bdd90..e41fb4862 100644
--- a/src/server/common/services/consolidated-view.service.js
+++ b/src/server/common/services/consolidated-view.service.js
@@ -18,7 +18,7 @@ const logger = createLogger()
  * @property {object} [data] - The response data object
  * @property {object} [data.business] - Business information
  * @property {object} [data.business.land] - Land information
- * @property {Array<LandParcel>} [data.business.land.parcels] - parcels information
+ * @property {Array}  [data.business.land.parcels] - parcels information
  * @property {string} [data.business.sbi] - Standard Business Identifier
  * @property {string} [data.business.organisationId] - Organisation identifier
  * @property {object} [data.business.customer] - Customer details
@@ -27,72 +27,84 @@ const logger = createLogger()
  * @property {string} [data.business.customer.role] - Customer's role
  */
 
+class ConsolidatedViewApiError extends Error {
+  constructor(message, statusCode, responseBody, sbi, crn) {
+    super(message)
+    this.name = 'ConsolidatedViewApiError'
+    this.status = statusCode
+    this.responseBody = responseBody
+    this.sbi = sbi
+    this.crn = crn
+  }
+}
+
 /**
  * Fetches business details from Consolidated View
  * @param {number} sbi - Standard Business Identifier
  * @param {number} crn - Customer Reference Number
- * @returns {Promise<BusinessResponse>} - Promise that resolves to the business details
- * @throws {Error} - If the request fails
+ * @returns {Promise} - Promise that resolves to the business details
+ * @throws {ConsolidatedViewApiError} - If the API request fails
+ * @throws {Error} - For other unexpected errors
  */
 export async function fetchParcelDataForBusiness(sbi, crn) {
-  const now = new Date().toISOString()
-  const query = `
-    query Business {
-      business(sbi: "${sbi}") {
-        sbi
-        organisationId
-        land {
-          parcels(date: "${now}") {
-            parcelId
-            sheetId
-            area
-          }
-        }
-        customer(crn: "${crn}") {
-            firstName
-            lastName
-            role
+  try {
+    const now = new Date().toISOString()
+    const query = `
+  query Business {
+    business(sbi: "${sbi}") {
+      sbi
+      organisationId
+      land {
+        parcels(date: "${now}") {
+          parcelId
+          sheetId
+          area
         }
       }
+      customer(crn: "${crn}") {
+          firstName
+          lastName
+          role
+      }
+    }
     }`
 
-  const token = await getValidToken()
-
-  const response = await fetch(CV_API_ENDPOINT, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      Authorization: `Bearer ${token}`,
-      email: CV_API_AUTH_EMAIL
-    },
-    body: JSON.stringify({
-      query
+    const token = await getValidToken()
+    const response = await fetch(CV_API_ENDPOINT, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${token}`,
+        email: CV_API_AUTH_EMAIL
+      },
+      body: JSON.stringify({
+        query
+      })
     })
-  })
-
-  if (!response.ok) {
-    const errorText = await response.text()
-    const error = new Error(
-      `Failed to fetch business data: ${response.status} ${response.statusText}`
-    )
-    error.code = response.status
-    error.responseBody = errorText
 
-    logger.error(
-      {
-        err: error,
-        statusCode: response.status,
-        responseText: errorText,
+    if (!response.ok) {
+      const errorText = await response.text()
+      throw new ConsolidatedViewApiError(
+        `Failed to fetch business data: ${response.status} ${response.statusText}`,
+        response.status,
+        errorText,
         sbi,
         crn
-      },
-      'Failed to fetch business data from Consolidated View API'
-    )
+      )
+    }
 
-    throw error
+    return response.json()
+  } catch (error) {
+    logger.error(
+      { err: error },
+      `Unexpected error fetching business data from Consolidated View API`
+    )
+    throw new ConsolidatedViewApiError(
+      'Failed to fetch business data: ' + error.message,
+      error.status,
+      error.message,
+      sbi,
+      crn
+    )
   }
-
-  const data = /** @type {Promise<BusinessResponse>} */ (response.json())
-
-  return data
 }
diff --git a/src/server/common/services/consolidated-view.service.test.js b/src/server/common/services/consolidated-view.service.test.js
index ed82282c9..142862fe5 100644
--- a/src/server/common/services/consolidated-view.service.test.js
+++ b/src/server/common/services/consolidated-view.service.test.js
@@ -74,15 +74,17 @@ describe('fetchParcelDataForBusiness', () => {
     mockFetch.mockResolvedValueOnce({
       ok: false,
       status: 500,
-      statusText: 'Internal Server Error',
+      statusText: errorText,
       text: () => Promise.resolve(errorText)
     })
 
     const error = await fetchParcelDataForBusiness(mockSbi, mockCrn).catch(
       (e) => e
     )
-    expect(error.code).toBe(500)
-    expect(error.responseBody).toBe(errorText)
+    expect(error.status).toBe(500)
+    expect(error.responseBody).toBe(
+      `Failed to fetch business data: 500 ${errorText}`
+    )
   })
 
   it('should handle network errors during fetch', async () => {
diff --git a/src/server/common/services/grant-application.service.js b/src/server/common/services/grant-application.service.js
new file mode 100644
index 000000000..118ad4ae3
--- /dev/null
+++ b/src/server/common/services/grant-application.service.js
@@ -0,0 +1,60 @@
+import { config } from '~/src/config/config.js'
+import { createLogger } from '~/src/server/common/helpers/logging/logger.js'
+
+const GAS_API_ENDPOINT = config.get('gas.apiEndpoint')
+const logger = createLogger()
+
+class GrantApplicationServiceApiError extends Error {
+  constructor(message, statusCode, responseBody, code) {
+    super(message)
+    this.name = 'GrantApplicationServiceApiError'
+    this.status = statusCode
+    this.responseBody = responseBody
+    this.grantCode = code
+  }
+}
+
+/**
+ * Submits a grant application to the Grant Application Service (GAS)
+ * @param {string} code - Grant code
+ * @param {object} payload - Application payload
+ * @returns {Promise} - Promise that resolves to the submission response
+ * @throws {GrantApplicationServiceApiError} - If the API request fails
+ */
+export async function submitGrantApplication(code, payload) {
+  try {
+    const response = await fetch(
+      `${GAS_API_ENDPOINT}/grants/${code}/applications`,
+      {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify(payload)
+      }
+    )
+
+    if (!response.ok) {
+      const errorText = await response.text()
+      throw new GrantApplicationServiceApiError(
+        `${response.status} ${response.statusText}`,
+        response.status,
+        errorText,
+        code
+      )
+    }
+
+    return response.json()
+  } catch (error) {
+    logger.error(
+      { err: error },
+      `Unexpected error submitting grant application: ${error.message}`
+    )
+    throw new GrantApplicationServiceApiError(
+      'Failed to submit grant application: ' + error.message,
+      error.status,
+      error.message,
+      code
+    )
+  }
+}
diff --git a/src/server/common/services/grant-application.service.test.js b/src/server/common/services/grant-application.service.test.js
new file mode 100644
index 000000000..b3534f4be
--- /dev/null
+++ b/src/server/common/services/grant-application.service.test.js
@@ -0,0 +1,111 @@
+import { config } from '~/src/config/config.js'
+import { submitGrantApplication } from '~/src/server/common/services/grant-application.service.js'
+
+const gasApi = config.get('gas.apiEndpoint')
+const code = config.get('gas.frpsGrantCode')
+
+describe('submitGrantApplication', () => {
+  const payload = {
+    metadata: {
+      clientRef: 'abc123',
+      submittedAt: '2025-04-22T12:00:00Z',
+      sbi: '1234567890',
+      frn: '1234567890',
+      crn: '1234567890',
+      defraId: '1234567890'
+    },
+    answers: {
+      scheme: 'SFI',
+      year: 2025,
+      hasCheckedLandIsUpToDate: true,
+      actionApplications: [
+        {
+          parcelId: '9238',
+          sheetId: 'SX0679',
+          code: 'CSAM1',
+          appliedFor: {
+            unit: 'ha',
+            quantity: 20.23
+          }
+        }
+      ]
+    }
+  }
+  const mockResponse = {
+    id: '12345',
+    status: 'submitted',
+    applicationRef: 'APP-2025-001'
+  }
+
+  beforeEach(() => {
+    jest.resetAllMocks()
+  })
+
+  test('should successfully submit a grant application', async () => {
+    fetch.mockResolvedValueOnce({
+      ok: true,
+      json: jest.fn().mockResolvedValueOnce(mockResponse)
+    })
+
+    const result = await submitGrantApplication(code, payload)
+
+    expect(fetch).toHaveBeenCalledWith(
+      `${gasApi}/grants/${code}/applications`,
+      {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify(payload)
+      }
+    )
+    expect(result).toEqual(mockResponse)
+  })
+
+  test('should throw an error when the request fails', async () => {
+    const mockMessage = 'Bad Request'
+
+    fetch.mockResolvedValue({
+      ok: false,
+      status: 400,
+      text: jest.fn().mockResolvedValueOnce(mockMessage),
+      statusText: 'Bad Request',
+      json: jest.fn().mockResolvedValueOnce({ message: mockMessage })
+    })
+
+    await expect(submitGrantApplication(code, payload)).rejects.toThrow(
+      mockMessage
+    )
+
+    expect(fetch).toHaveBeenCalledWith(
+      `${gasApi}/grants/${code}/applications`,
+      {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify(payload)
+      }
+    )
+  })
+
+  test('should handle network errors', async () => {
+    const networkError = new Error('Network error')
+    fetch.mockRejectedValueOnce(networkError)
+
+    await expect(submitGrantApplication(code, payload)).rejects.toThrow(
+      'Network error'
+    )
+
+    expect(fetch).toHaveBeenCalledWith(
+      `${gasApi}/grants/${code}/applications`,
+      {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify(payload)
+      }
+    )
+  })
+})
diff --git a/src/server/controllers/confirmation/controller.test.js b/src/server/controllers/confirmation/confirmation.controller.test.js
similarity index 100%
rename from src/server/controllers/confirmation/controller.test.js
rename to src/server/controllers/confirmation/confirmation.controller.test.js
diff --git a/src/server/index.js b/src/server/index.js
index f3a88312b..75d1f443c 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -26,8 +26,9 @@ import { getCacheEngine } from '~/src/server/common/helpers/session-cache/cache-
 import { sessionCache } from '~/src/server/common/helpers/session-cache/session-cache.js'
 import ConfirmationPageController from '~/src/server/controllers/confirmation/controller.js'
 import DeclarationPageController from '~/src/server/controllers/declaration/controller.js'
-import LandActionsController from '~/src/server/land-grants/actions/actions.controller.js'
-import LandParcelController from '~/src/server/land-grants/parcels/parcel.controller.js'
+import LandActionsPageController from '~/src/server/land-grants/actions/land-actions.controller.js'
+import LandParcelPageController from '~/src/server/land-grants/parcel/land-parcel-page.controller.js'
+import SubmissionPageController from '~/src/server/land-grants/submission/submission-page.controller.js'
 import { formatCurrency } from '../config/nunjucks/filters/format-currency.js'
 import { router } from './router.js'
 
@@ -39,10 +40,10 @@ const getViewPaths = () => {
   const basePath = isRunningBuiltCode ? '.server/server' : 'src/server'
   return [
     `${basePath}/land-grants/actions`,
-    `${basePath}/land-grants/parcels`,
+    `${basePath}/land-grants/parcel`,
+    `${basePath}/land-grants/submission`,
     `${basePath}/views`,
-    `${basePath}/common/templates`,
-    `${basePath}/common/components`
+    `${basePath}/common/templates`
   ]
 }
 
@@ -107,8 +108,9 @@ const registerFormsPlugin = async (server) => {
       controllers: {
         ConfirmationPageController,
         DeclarationPageController,
-        LandParcelController,
-        LandActionsController
+        SubmissionPageController,
+        LandParcelPageController,
+        LandActionsPageController
       }
     }
   })
diff --git a/src/server/land-grants/actions/actions.controller.js b/src/server/land-grants/actions/land-actions.controller.js
similarity index 98%
rename from src/server/land-grants/actions/actions.controller.js
rename to src/server/land-grants/actions/land-actions.controller.js
index 5a95b500c..72234f1c1 100644
--- a/src/server/land-grants/actions/actions.controller.js
+++ b/src/server/land-grants/actions/land-actions.controller.js
@@ -3,10 +3,10 @@ import {
   calculateApplicationPayment,
   fetchLandSheetDetails,
   validateLandActions
-} from '~/src/server/land-grants/services/land-grants.service.js'
+} from '~/src/server/land-grants/actions/land-actions.service.js'
 
 export default class LandActionsController extends QuestionPageController {
-  viewName = 'actions'
+  viewName = 'land-actions'
   areaPrefix = 'area-'
   availableActions = []
 
diff --git a/src/server/land-grants/actions/actions.controller.test.js b/src/server/land-grants/actions/land-actions.controller.test.js
similarity index 96%
rename from src/server/land-grants/actions/actions.controller.test.js
rename to src/server/land-grants/actions/land-actions.controller.test.js
index dfb829b23..f203dd903 100644
--- a/src/server/land-grants/actions/actions.controller.test.js
+++ b/src/server/land-grants/actions/land-actions.controller.test.js
@@ -3,11 +3,11 @@ import {
   calculateApplicationPayment,
   fetchLandSheetDetails,
   validateLandActions
-} from '~/src/server/land-grants/services/land-grants.service.js'
-import LandActionsController from './actions.controller.js'
+} from '~/src/server/land-grants/actions/land-actions.service.js'
+import LandActionsController from './land-actions.controller.js'
 
 jest.mock('@defra/forms-engine-plugin/controllers/QuestionPageController.js')
-jest.mock('~/src/server/land-grants/services/land-grants.service.js')
+jest.mock('~/src/server/land-grants/actions/land-actions.service.js')
 
 describe('LandActionsController', () => {
   let controller
@@ -84,7 +84,7 @@ describe('LandActionsController', () => {
   })
 
   test('should have the correct viewName', () => {
-    expect(controller.viewName).toBe('actions')
+    expect(controller.viewName).toBe('land-actions')
   })
 
   describe('extractActionsObjectFromPayload', () => {
@@ -163,7 +163,7 @@ describe('LandActionsController', () => {
 
       expect(fetchLandSheetDetails).toHaveBeenCalledWith('parcel1', 'sheet1')
       expect(mockH.view).toHaveBeenCalledWith(
-        'actions',
+        'land-actions',
         expect.objectContaining({
           landParcel: 'sheet1-parcel1',
           availableActions: ['action1', 'action2', 'action3'],
@@ -180,7 +180,7 @@ describe('LandActionsController', () => {
       await handler(mockRequest, mockContext, mockH)
 
       expect(mockH.view).toHaveBeenCalledWith(
-        'actions',
+        'land-actions',
         expect.objectContaining({
           availableActions
         })
@@ -215,7 +215,7 @@ describe('LandActionsController', () => {
 
       expect(fetchLandSheetDetails).toHaveBeenCalledWith(undefined, '')
       expect(mockH.view).toHaveBeenCalledWith(
-        'actions',
+        'land-actions',
         expect.objectContaining({
           availableActions: []
         })
@@ -234,7 +234,7 @@ describe('LandActionsController', () => {
       const result = await handler(mockRequest, mockContext, mockH)
 
       expect(mockH.view).toHaveBeenCalledWith(
-        'actions',
+        'land-actions',
         expect.objectContaining({
           availableActions: [],
           landParcel: 'sheet1-parcel1'
@@ -348,7 +348,7 @@ describe('LandActionsController', () => {
       )
 
       expect(mockH.view).toHaveBeenCalledWith(
-        'actions',
+        'land-actions',
         expect.objectContaining({
           errors: errorMessages,
           availableActions
diff --git a/src/server/land-grants/actions/actions.html b/src/server/land-grants/actions/land-actions.html
similarity index 100%
rename from src/server/land-grants/actions/actions.html
rename to src/server/land-grants/actions/land-actions.html
diff --git a/src/server/land-grants/services/land-grants.service.js b/src/server/land-grants/actions/land-actions.service.js
similarity index 100%
rename from src/server/land-grants/services/land-grants.service.js
rename to src/server/land-grants/actions/land-actions.service.js
diff --git a/src/server/land-grants/services/land-grants.service.test.js b/src/server/land-grants/actions/land-actions.service.test.js
similarity index 99%
rename from src/server/land-grants/services/land-grants.service.test.js
rename to src/server/land-grants/actions/land-actions.service.test.js
index fb218d23d..153c95b5d 100644
--- a/src/server/land-grants/services/land-grants.service.test.js
+++ b/src/server/land-grants/actions/land-actions.service.test.js
@@ -3,7 +3,7 @@ import {
   calculateApplicationPayment,
   fetchLandSheetDetails,
   validateLandActions
-} from '~/src/server/land-grants/services/land-grants.service.js'
+} from '~/src/server/land-grants/actions/land-actions.service.js'
 
 const landGrantsApi = config.get('landGrants.apiEndpoint')
 
diff --git a/src/server/land-grants/parcels/parcel.controller.js b/src/server/land-grants/parcel/land-parcel-page.controller.js
similarity index 64%
rename from src/server/land-grants/parcels/parcel.controller.js
rename to src/server/land-grants/parcel/land-parcel-page.controller.js
index 1cf8b9019..addd547a6 100644
--- a/src/server/land-grants/parcels/parcel.controller.js
+++ b/src/server/land-grants/parcel/land-parcel-page.controller.js
@@ -4,16 +4,16 @@ import { fetchParcelDataForBusiness } from '~/src/server/common/services/consoli
 
 const logger = createLogger()
 
-export default class LandParcelController extends QuestionPageController {
-  viewName = 'parcel'
+export default class LandParcelPageController extends QuestionPageController {
+  viewName = 'land-parcel'
 
   makePostRouteHandler() {
     /**
      * Handle POST requests to the land parcel page.
      * @param {FormRequest} request
      * @param {FormContext} context
-     * @param {Pick<ResponseToolkit, 'redirect' | 'view'>} h
-     * @returns {Promise<import('@hapi/boom').Boom<any> | import('@hapi/hapi').ResponseObject>}
+     * @param {Pick} h
+     * @returns {Promise | import('@hapi/hapi').ResponseObject>}
      */
     const fn = async (request, context, h) => {
       const { state } = context
@@ -40,29 +40,54 @@ export default class LandParcelController extends QuestionPageController {
      * Handle GET requests to the land parcel page.
      * @param {FormRequest} request
      * @param {FormContext} context
-     * @param {Pick<ResponseToolkit, 'redirect' | 'view'>} h
+     * @param {Pick} h
      */
     const fn = async (request, context, h) => {
       const { landParcel = '' } = context.state || {}
       const sbi = 117235001
       const crn = 1100598138
       const { viewName } = this
+      const baseViewModel = super.getViewModel(request, context)
 
       try {
         const response = await fetchParcelDataForBusiness(sbi, crn)
         const business = response.data?.business
         const viewModel = {
-          ...super.getViewModel(request, context),
+          ...baseViewModel,
           business,
           landParcel
         }
 
         return h.view(viewName, viewModel)
-      } catch (e) {
-        logger.error(e, `Failed to fetch business details ${sbi}`)
+      } catch (error) {
+        // Log specific error details based on error type
+        if (error.name === 'ConsolidatedViewApiError') {
+          logger.error(
+            {
+              err: error,
+              statusCode: error.statusCode,
+              sbi,
+              crn
+            },
+            'Consolidated View API error when fetching parcel data'
+          )
+        } else {
+          logger.error(
+            {
+              err: error,
+              sbi,
+              crn
+            },
+            'Unexpected error when fetching parcel data'
+          )
+        }
+
+        const errorMessage =
+          'Unable to find parcel information, please try again later.'
+
         return h.view(viewName, {
-          ...super.getViewModel(request, context),
-          errors: ['Unable to find parcel information, please try again later.']
+          ...baseViewModel,
+          errors: [errorMessage]
         })
       }
     }
diff --git a/src/server/land-grants/parcels/parcel.controller.test.js b/src/server/land-grants/parcel/land-parcel-page.controller.test.js
similarity index 93%
rename from src/server/land-grants/parcels/parcel.controller.test.js
rename to src/server/land-grants/parcel/land-parcel-page.controller.test.js
index 2b6e4e637..3d7b45bb7 100644
--- a/src/server/land-grants/parcels/parcel.controller.test.js
+++ b/src/server/land-grants/parcel/land-parcel-page.controller.test.js
@@ -1,7 +1,6 @@
 import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js'
-import { createLogger } from '~/src/server/common/helpers/logging/logger.js'
 import { fetchParcelDataForBusiness } from '~/src/server/common/services/consolidated-view.service.js'
-import LandParcelController from './parcel.controller.js'
+import LandParcelPageController from './land-parcel-page.controller.js'
 
 jest.mock('@defra/forms-engine-plugin/controllers/QuestionPageController.js')
 jest.mock('~/src/server/common/helpers/logging/logger.js', () => ({
@@ -14,7 +13,7 @@ jest.mock('~/src/server/common/services/consolidated-view.service.js', () => ({
   fetchParcelDataForBusiness: jest.fn()
 }))
 
-describe('LandParcelController', () => {
+describe('LandParcelPageController', () => {
   let controller
   let mockRequest
   let mockContext
@@ -40,7 +39,7 @@ describe('LandParcelController', () => {
       pageTitle: 'Select Land Parcel'
     })
 
-    controller = new LandParcelController()
+    controller = new LandParcelPageController()
     controller.proceed = jest.fn().mockResolvedValue('next')
     controller.getNextPath = jest.fn().mockReturnValue('/next-page')
     controller.setState = jest.fn()
@@ -69,7 +68,7 @@ describe('LandParcelController', () => {
   afterEach(jest.clearAllMocks)
 
   it('should have the correct viewName', () => {
-    expect(controller.viewName).toBe('parcel')
+    expect(controller.viewName).toBe('land-parcel')
   })
 
   describe('GET route handler', () => {
@@ -85,7 +84,7 @@ describe('LandParcelController', () => {
         1100598138
       )
       expect(mockH.view).toHaveBeenCalledWith(
-        'parcel',
+        'land-parcel',
         expect.objectContaining({
           pageTitle: 'Select Land Parcel',
           business: expect.anything(),
@@ -104,9 +103,8 @@ describe('LandParcelController', () => {
         mockH
       )
 
-      expect(createLogger().error).toHaveBeenCalled()
       expect(mockH.view).toHaveBeenCalledWith(
-        'parcel',
+        'land-parcel',
         expect.objectContaining({
           pageTitle: 'Select Land Parcel',
           errors: ['Unable to find parcel information, please try again later.']
@@ -125,7 +123,7 @@ describe('LandParcelController', () => {
       )
 
       expect(mockH.view).toHaveBeenCalledWith(
-        'parcel',
+        'land-parcel',
         expect.objectContaining({
           landParcel: '',
           business: expect.anything()
@@ -144,7 +142,7 @@ describe('LandParcelController', () => {
       )
 
       expect(mockH.view).toHaveBeenCalledWith(
-        'parcel',
+        'land-parcel',
         expect.objectContaining({
           landParcel: 'sheet123',
           business: expect.anything(),
diff --git a/src/server/land-grants/parcels/parcel.html b/src/server/land-grants/parcel/land-parcel.html
similarity index 100%
rename from src/server/land-grants/parcels/parcel.html
rename to src/server/land-grants/parcel/land-parcel.html
diff --git a/src/server/land-grants/services/gas-payload.schema.js b/src/server/land-grants/submission/gas-answers.schema.js
similarity index 70%
rename from src/server/land-grants/services/gas-payload.schema.js
rename to src/server/land-grants/submission/gas-answers.schema.js
index 22e3714af..521d61766 100644
--- a/src/server/land-grants/services/gas-payload.schema.js
+++ b/src/server/land-grants/submission/gas-answers.schema.js
@@ -1,7 +1,7 @@
 import Joi from 'joi'
 
 /**
- * Joi schema definitions for GAS application payload validation
+ * Joi schema definitions for GAS application land grant answers validation
  */
 
 const actionAreaSchema = Joi.object({
@@ -16,11 +16,7 @@ const actionApplicationSchema = Joi.object({
   appliedFor: actionAreaSchema
 })
 
-const gasPayloadSchema = Joi.object({
-  sbi: Joi.string(),
-  frn: Joi.string(),
-  crn: Joi.string(),
-  defraId: Joi.string(),
+const gasAnswersSchema = Joi.object({
   scheme: Joi.string(),
   year: Joi.number().integer(),
   hasCheckedLandIsUpToDate: Joi.boolean(),
@@ -32,15 +28,15 @@ const defaultValidationOptions = {
 }
 
 /**
- * Validates an object against the GASPayload schema structure
+ * Validates an object against the gasAnswersSchema structure
  * Only validates types of properties that are present, doesn't require any fields
  * @param {object} payload - The object to validate
  * @param {object} options - Joi validation options (optional)
  * @returns {object} - Joi validation result
  */
-export function validateGasPayload(
+export function validateGasAnswersForLandGrants(
   payload,
   options = defaultValidationOptions
 ) {
-  return gasPayloadSchema.validate(payload, options)
+  return gasAnswersSchema.validate(payload, options)
 }
diff --git a/src/server/land-grants/services/state-to-payload-mapper.js b/src/server/land-grants/submission/state-to-gas-answers-mapper.js
similarity index 60%
rename from src/server/land-grants/services/state-to-payload-mapper.js
rename to src/server/land-grants/submission/state-to-gas-answers-mapper.js
index 1394e484f..db7b60ee3 100644
--- a/src/server/land-grants/services/state-to-payload-mapper.js
+++ b/src/server/land-grants/submission/state-to-gas-answers-mapper.js
@@ -13,11 +13,7 @@
  */
 
 /**
- * @typedef {object} GASPayload
- * @property {string} [sbi] - Standard Business Identifier
- * @property {string} [frn] - FRN
- * @property {string} [crn] - Customer Reference Number
- * @property {string} [defraId] - Defra ID
+ * @typedef {object} GASAnswers
  * @property {string} [scheme] - Scheme
  * @property {number} [year] - Scheme year
  * @property {boolean} [hasCheckedLandIsUpToDate] - Land details are up to date
@@ -25,32 +21,22 @@
  */
 
 /**
- * Transforms FormContext object into a GAS Application payload for Land Grants.
- * @param {object} stateObj
- * @returns {GASPayload}
+ * Transforms FormContext object into a GAS Application answers object for Land Grants.
+ * @param {object} state
+ * @returns {GASAnswers}
  */
-export function transformStateObjectToGasApplication(stateObj) {
-  const result = {}
-  const basicProps = [
-    'sbi',
-    'frn',
-    'crn',
-    'defraId',
-    'scheme',
-    'year',
-    'hasCheckedLandIsUpToDate'
-  ]
-  basicProps.forEach((prop) => {
-    if (stateObj[prop] != null) {
-      result[prop] = stateObj[prop]
-    }
-  })
+export function stateToLandGrantsGasAnswers(state) {
+  const result = {
+    hasCheckedLandIsUpToDate: state.hasCheckedLandIsUpToDate,
+    scheme: 'SFI',
+    year: 2025
+  }
 
-  if (stateObj.landParcel && stateObj.actionsObj) {
-    const [sheetId, parcelId] = stateObj?.landParcel?.split('-') ?? []
+  if (state.landParcel && state.actionsObj) {
+    const [sheetId, parcelId] = state?.landParcel?.split('-') ?? []
     result.actionApplications = []
 
-    Object.entries(stateObj.actionsObj).forEach(([actionCode, actionData]) => {
+    Object.entries(state.actionsObj).forEach(([actionCode, actionData]) => {
       const actionApplication = {
         code: actionCode,
         sheetId,
diff --git a/src/server/land-grants/services/state-to-payload-mapper.test.js b/src/server/land-grants/submission/state-to-gas-answers-mapper.test.js
similarity index 79%
rename from src/server/land-grants/services/state-to-payload-mapper.test.js
rename to src/server/land-grants/submission/state-to-gas-answers-mapper.test.js
index da4018447..b233e1885 100644
--- a/src/server/land-grants/services/state-to-payload-mapper.test.js
+++ b/src/server/land-grants/submission/state-to-gas-answers-mapper.test.js
@@ -1,18 +1,13 @@
-import { validateGasPayload } from './gas-payload.schema.js'
-import { transformStateObjectToGasApplication } from './state-to-payload-mapper.js'
+import { stateToLandGrantsGasAnswers } from '~/src/server/land-grants/submission/state-to-gas-answers-mapper.js'
+import { validateGasAnswersForLandGrants } from './gas-answers.schema.js'
 
-describe('transformStateObjectToGasApplication', () => {
+describe('stateToLandGrantsGasAnswers', () => {
   it('should transform a complete object correctly', () => {
     const input = {
-      sbi: 'sbi-1234',
-      frn: 'frn-1234',
-      crn: 'crn-1234',
-      defraId: 'defra-id-1234',
-      scheme: 'SFI',
-      year: 2025,
       hasCheckedLandIsUpToDate: true,
       landParcel: 'SX0679-9238',
-      applicationValue: '£16,467.49',
+      scheme: 'SFI',
+      year: 2025,
       actionsObj: {
         CSAM1: {
           value: '44',
@@ -22,10 +17,6 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
-      sbi: 'sbi-1234',
-      frn: 'frn-1234',
-      crn: 'crn-1234',
-      defraId: 'defra-id-1234',
       scheme: 'SFI',
       year: 2025,
       hasCheckedLandIsUpToDate: true,
@@ -42,7 +33,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should handle multiple actions with different units', () => {
@@ -65,6 +56,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -96,13 +89,14 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should not include actionApplications when landParcel is missing', () => {
     const input = {
-      sbi: 'sbi-1234',
       hasCheckedLandIsUpToDate: true,
+      scheme: 'SFI',
+      year: 2025,
       actionsObj: {
         CSAM1: {
           value: '44',
@@ -112,26 +106,29 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
-      sbi: 'sbi-1234',
-      hasCheckedLandIsUpToDate: true
+      hasCheckedLandIsUpToDate: true,
+      scheme: 'SFI',
+      year: 2025
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should not include actionApplications when actionsObj is missing', () => {
     const input = {
-      sbi: 'sbi-1234',
+      scheme: 'SFI',
+      year: 2025,
       hasCheckedLandIsUpToDate: true,
       landParcel: 'SX0679-9238'
     }
 
     const expected = {
-      sbi: 'sbi-1234',
+      scheme: 'SFI',
+      year: 2025,
       hasCheckedLandIsUpToDate: true
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should handle decimal values correctly', () => {
@@ -146,6 +143,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -159,7 +158,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should omit unit in appliedFor when unit is missing', () => {
@@ -174,6 +173,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -186,7 +187,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should omit quantity in appliedFor when value is missing', () => {
@@ -201,6 +202,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -213,7 +216,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should omit quantity when value is not a valid number', () => {
@@ -228,6 +231,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -240,7 +245,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should omit appliedFor when action data is empty', () => {
@@ -252,6 +257,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -261,7 +268,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should omit appliedFor when action data is not an object', () => {
@@ -273,6 +280,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -282,30 +291,31 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should return only basic properties when no action data is provided', () => {
     const input = {
-      sbi: 'sbi-1234',
-      frn: 'frn-1234',
-      crn: 'crn-1234'
+      scheme: 'SFI',
+      year: 2025
     }
 
     const expected = {
-      sbi: 'sbi-1234',
-      frn: 'frn-1234',
-      crn: 'crn-1234'
+      scheme: 'SFI',
+      year: 2025
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
-  it('should return empty object when input is empty', () => {
+  it('should return minimal object when input is empty', () => {
     const input = {}
-    const expected = {}
+    const expected = {
+      scheme: 'SFI',
+      year: 2025
+    }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should handle landParcel without dash', () => {
@@ -320,6 +330,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           sheetId: 'SX06799238', // The entire value becomes sheetId
@@ -332,7 +344,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should include zero as a valid quantity', () => {
@@ -347,6 +359,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -360,7 +374,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should handle landParcel with multiple dashes correctly', () => {
@@ -375,6 +389,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           sheetId: 'SX0679',
@@ -388,7 +404,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should handle null values in actionsObj', () => {
@@ -400,6 +416,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -409,7 +427,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should handle mixed action data types', () => {
@@ -427,6 +445,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -455,7 +475,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should handle string values with spaces', () => {
@@ -470,6 +490,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -483,7 +505,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should handle numeric strings with leading zeros', () => {
@@ -498,6 +520,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -511,7 +535,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 
   it('should handle undefined values in actionsObj', () => {
@@ -523,6 +547,8 @@ describe('transformStateObjectToGasApplication', () => {
     }
 
     const expected = {
+      scheme: 'SFI',
+      year: 2025,
       actionApplications: [
         {
           parcelId: '9238',
@@ -532,7 +558,7 @@ describe('transformStateObjectToGasApplication', () => {
       ]
     }
 
-    expect(transformStateObjectToGasApplication(input)).toEqual(expected)
+    expect(stateToLandGrantsGasAnswers(input)).toEqual(expected)
   })
 })
 
@@ -591,8 +617,8 @@ describe('schema validation', () => {
     ]
 
     stateObjectTestCases.forEach((testCase) => {
-      const result = transformStateObjectToGasApplication(testCase)
-      const { error } = validateGasPayload(result)
+      const result = stateToLandGrantsGasAnswers(testCase)
+      const { error } = validateGasAnswersForLandGrants(result)
 
       // We check here that the output always adheres to GasPayload expectations in terms of format
       expect(error).toBeUndefined()
diff --git a/src/server/land-grants/submission/submission-page.controller.js b/src/server/land-grants/submission/submission-page.controller.js
new file mode 100644
index 000000000..ffd5840ce
--- /dev/null
+++ b/src/server/land-grants/submission/submission-page.controller.js
@@ -0,0 +1,56 @@
+import { SummaryPageController } from '@defra/forms-engine-plugin/controllers/SummaryPageController.js'
+import { config } from '~/src/config/config.js'
+import { submitGrantApplication } from '~/src/server/common/services/grant-application.service.js'
+import { transformStateObjectToGasApplication } from '../../common/helpers/grant-application-service/state-to-gas-payload-mapper.js'
+import { stateToLandGrantsGasAnswers } from './state-to-gas-answers-mapper.js'
+
+export default class SubmissionPageController extends SummaryPageController {
+  /**
+   * @param {FormModel} model
+   * @param {PageSummary} pageDef
+   */
+  constructor(model, pageDef) {
+    super(model, pageDef)
+    this.viewName = 'submission'
+    this.grantCode = config.get('gas.frpsGrantCode')
+  }
+
+  /**
+   * Gets the path to the status page (in this case /confirmation page) for the POST handler.
+   * @returns {string} path to the status page
+   */
+  getStatusPath() {
+    return '/find-funding-for-land-or-farms/confirmation'
+  }
+
+  async submitLandGrantApplication(context) {
+    const identifiers = {
+      sbi: 'sbi',
+      frn: 'frn',
+      crn: 'crn',
+      defraId: 'defraId'
+    }
+    const applicationData = transformStateObjectToGasApplication(
+      identifiers,
+      context.state,
+      stateToLandGrantsGasAnswers
+    )
+    return submitGrantApplication(this.grantCode, applicationData)
+  }
+
+  makePostRouteHandler() {
+    const fn = async (request, context, h) => {
+      const result = await this.submitLandGrantApplication(context)
+      request.logger.info('Form submission completed', result)
+
+      return h.redirect(this.getStatusPath())
+    }
+
+    return fn
+  }
+}
+
+/**
+ * @import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
+ * @import { type PageSummary } from '@defra/forms-model'
+ */
diff --git a/src/server/land-grants/submission/submission-page.controller.test.js b/src/server/land-grants/submission/submission-page.controller.test.js
new file mode 100644
index 000000000..d3f57251f
--- /dev/null
+++ b/src/server/land-grants/submission/submission-page.controller.test.js
@@ -0,0 +1,148 @@
+import { jest } from '@jest/globals'
+import { config } from '~/src/config/config.js'
+import { submitGrantApplication } from '~/src/server/common/services/grant-application.service.js'
+import { transformStateObjectToGasApplication } from '../../common/helpers/grant-application-service/state-to-gas-payload-mapper.js'
+import { stateToLandGrantsGasAnswers } from './state-to-gas-answers-mapper.js'
+import SubmissionPageController from './submission-page.controller.js'
+
+jest.mock('~/src/server/common/services/grant-application.service.js')
+jest.mock(
+  '../../common/helpers/grant-application-service/state-to-gas-payload-mapper.js'
+)
+jest.mock('./state-to-gas-answers-mapper.js')
+
+jest.mock(
+  '@defra/forms-engine-plugin/controllers/SummaryPageController.js',
+  () => {
+    return {
+      SummaryPageController: jest.fn().mockImplementation(() => {
+        return {}
+      })
+    }
+  }
+)
+
+const code = config.get('gas.frpsGrantCode')
+
+describe('SubmissionPageController', () => {
+  let controller
+  let mockModel
+  let mockPageDef
+
+  beforeEach(() => {
+    jest.resetAllMocks()
+
+    mockModel = {}
+    mockPageDef = {}
+
+    controller = new SubmissionPageController(mockModel, mockPageDef)
+  })
+
+  describe('constructor', () => {
+    it('should set viewName to "submission"', () => {
+      expect(controller.viewName).toBe('submission')
+    })
+
+    it('should set grantCode', () => {
+      expect(controller.grantCode).toBe(code)
+    })
+  })
+
+  describe('getStatusPath', () => {
+    it('should return the correct status path', () => {
+      const result = controller.getStatusPath()
+
+      expect(result).toBe('/find-funding-for-land-or-farms/confirmation')
+    })
+  })
+
+  describe('submitLandGrantApplication', () => {
+    it('should transform state and submit grant application', async () => {
+      const mockContext = {
+        state: { key: 'value' }
+      }
+      const mockApplicationData = { transformed: 'data' }
+      const mockResult = { success: true }
+
+      transformStateObjectToGasApplication.mockReturnValue(mockApplicationData)
+      submitGrantApplication.mockResolvedValue(mockResult)
+
+      const result = await controller.submitLandGrantApplication(mockContext)
+
+      expect(transformStateObjectToGasApplication).toHaveBeenCalledWith(
+        {
+          sbi: 'sbi',
+          frn: 'frn',
+          crn: 'crn',
+          defraId: 'defraId'
+        },
+        mockContext.state,
+        stateToLandGrantsGasAnswers
+      )
+      expect(submitGrantApplication).toHaveBeenCalledWith(
+        code,
+        mockApplicationData
+      )
+      expect(result).toEqual(mockResult)
+    })
+  })
+
+  describe('makePostRouteHandler', () => {
+    it('should return a function that submits the application and redirects on success', async () => {
+      const mockRequest = {
+        logger: {
+          info: jest.fn(),
+          error: jest.fn()
+        }
+      }
+      const mockContext = { state: {} }
+      const mockH = {
+        redirect: jest.fn().mockReturnValue('redirected')
+      }
+      const mockResult = { success: true }
+
+      jest
+        .spyOn(controller, 'submitLandGrantApplication')
+        .mockResolvedValue(mockResult)
+      jest.spyOn(controller, 'getStatusPath').mockReturnValue('/mock-path')
+
+      const handler = controller.makePostRouteHandler()
+      const result = await handler(mockRequest, mockContext, mockH)
+
+      expect(controller.submitLandGrantApplication).toHaveBeenCalledWith(
+        mockContext
+      )
+      expect(mockRequest.logger.info).toHaveBeenCalledWith(
+        'Form submission completed',
+        mockResult
+      )
+      expect(controller.getStatusPath).toHaveBeenCalled()
+      expect(mockH.redirect).toHaveBeenCalledWith('/mock-path')
+      expect(result).toBe('redirected')
+    })
+
+    it('should handle errors and rethrow them', async () => {
+      const mockError = new Error('Submission failed')
+      const mockRequest = {
+        logger: {
+          info: jest.fn(),
+          error: jest.fn()
+        }
+      }
+      const mockContext = { state: {} }
+      const mockH = {
+        redirect: jest.fn()
+      }
+
+      jest
+        .spyOn(controller, 'submitLandGrantApplication')
+        .mockRejectedValue(mockError)
+
+      const handler = controller.makePostRouteHandler()
+      await expect(handler(mockRequest, mockContext, mockH)).rejects.toThrow(
+        mockError
+      )
+      expect(mockH.redirect).not.toHaveBeenCalled()
+    })
+  })
+})
diff --git a/src/server/land-grants/submission/submission.html b/src/server/land-grants/submission/submission.html
new file mode 100644
index 000000000..014adfce9
--- /dev/null
+++ b/src/server/land-grants/submission/submission.html
@@ -0,0 +1,50 @@
+{% extends "layout.html" %}
+
+{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %}
+
+{% block content %}
+  <div class="govuk-grid-row">
+    <div class="govuk-grid-column-two-thirds">
+      {% if previewMode %}
+        {% include "partials/preview-banner.html" %}
+      {% endif %}
+
+      {% if hasMissingNotificationEmail %}
+        {% include "partials/warn-missing-notification-email.html" %}
+      {% endif %}
+
+      <h1 class="govuk-heading-l">
+        {{ pageTitle }}
+      </h1>
+
+      {% for section in checkAnswers %}
+        {% if section.title.text %}
+          <h2 class="govuk-heading-m">
+            {{ section.title.text }}
+          </h2>
+        {% endif %}
+
+        {{ govukSummaryList(section.summaryList) }}
+      {% endfor %}
+
+      <form method="post" novalidate>
+        <input type="hidden" name="crumb" value="{{ crumb }}">
+        <input type="hidden" name="action" value="send">
+
+        {% if declaration %}
+          <h2 class="govuk-heading-m" id="declaration">Declaration</h2>
+          {{ declaration | safe }}
+        {% endif %}
+
+        <button data-prevent-double-click="true" class="govuk-button" data-module="govuk-button">
+          {{ "Accept and send" if declaration else "Send" }}
+        </button>
+      </form>
+    </div>
+  </div>
+
+  {% if config.cdpEnvironment == 'local' and context | length and not context.isForceAccess %}
+    {% include "partials/debug.html" %}
+  {% endif %}
+{% endblock %}


Reviews:
user: login: alanplatt
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T16:05:25Z
user: login: alanplatt
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T06:57:33Z

Review comments:
diff_hunk: @@ -0,0 +1,33 @@
+import { config } from '~/src/config/config.js'
+
+const GAS_API_URL = config.get('gas.apiEndpoint')
+
+/**
+ * Submits a grant application to Grants Application service
+ * @param {string} code - Grant code
+ * @param {object} payload - Application payload
+ * @returns {Promise<object>} - Promise that resolves to the validation result
+ * @throws {Error} - If the request fails
+ */
+export async function submitGrantApplication(code, payload) {
+  const response = await fetch(`${GAS_API_URL}/grants/${code}/applications`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify(payload)
+  })
+
+  if (!response.ok) {
+    /**
+     * @type {Error & {code?: number}}
+     */
+
+    const data = await response.json()
+    const error = new Error(data.message)
+    error.code = response.status
+    throw error
path: src/server/common/services/grant-application.service.js
user: login: alanplatt
body: Mayeb catch and log if there is an error ?
created_at: 2025-04-25T16:05:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-26T17:47:58Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DEFRA_grants-ui&pullRequest=50) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DEFRA_grants-ui&pullRequest=50&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DEFRA_grants-ui&pullRequest=50&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DEFRA_grants-ui&pullRequest=50&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [97.8% Coverage on New Code](https://sonarcloud.io/component_measures?id=DEFRA_grants-ui&pullRequest=50&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DEFRA_grants-ui&pullRequest=50&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=DEFRA_grants-ui&pullRequest=50)


author_association: NONE