Skip to content

Commit caa6073

Browse files
committed
WIP: progress commit
1 parent 1fa9b37 commit caa6073

11 files changed

Lines changed: 380 additions & 68 deletions

File tree

src/config/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,14 @@ export const config = convict({
268268
nullable: true,
269269
default: undefined,
270270
env: 'PAYMENT_PROVIDER_API_KEY_TEST'
271+
} as SchemaObj<string | undefined>,
272+
273+
paymentProviderApiKeyLive: {
274+
doc: 'A live API key for integrating with a payment provider',
275+
format: String,
276+
nullable: true,
277+
default: undefined,
278+
env: 'PAYMENT_PROVIDER_API_KEY_LIVE'
271279
} as SchemaObj<string | undefined>
272280
})
273281

src/server/plugins/engine/components/PaymentField.ts

Lines changed: 92 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import joi, { type ObjectSchema } from 'joi'
1010
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
1111
import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
1212
import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js'
13+
import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
1314
import {
1415
type AnyFormRequest,
1516
type FormContext,
@@ -47,6 +48,7 @@ export class PaymentField extends FormComponent {
4748
amount: joi.number().required(),
4849
description: joi.string().required(),
4950
uuid: joi.string().uuid().required(),
51+
isLive: joi.boolean().required(),
5052
preAuth: joi
5153
.object({
5254
status: joi
@@ -155,7 +157,8 @@ export class PaymentField extends FormComponent {
155157
h: FormResponseToolkit,
156158
args: PaymentDispatcherArgs
157159
): Promise<unknown> {
158-
const paymentService = new PaymentService()
160+
const { isLive } = args
161+
const paymentService = new PaymentService({ isLive })
159162

160163
// 1. Generate UUID token
161164
const uuid = randomUUID()
@@ -173,18 +176,19 @@ export class PaymentField extends FormComponent {
173176

174177
// 2. Build the return URL for GOV.UK Pay
175178
const { baseUrl } = getPluginOptions(request.server)
176-
const returnUrl = `${baseUrl}/payment-callback?uuid=${uuid}`
179+
const payCallbackUrl = `${baseUrl}/payment-callback?uuid=${uuid}`
177180

178-
// Build the summary URL to redirect to after payment
181+
// Build URLs for redirect after payment
179182
const summaryUrl = `${baseUrl}/${model.basePath}/summary`
183+
const paymentPageUrl = args.sourceUrl
180184

181185
// 3. Call paymentService.createPayment()
182186
// GOV.UK Pay expects amount in pence, so multiply pounds by 100
183187
const amountInPence = Math.round(amount * 100)
184188
const payment = await paymentService.createPayment(
185189
amountInPence,
186190
description,
187-
returnUrl,
191+
payCallbackUrl,
188192
reference,
189193
{ formId, slug }
190194
)
@@ -197,7 +201,9 @@ export class PaymentField extends FormComponent {
197201
description,
198202
paymentId: payment.paymentId,
199203
componentName,
200-
sourceUrl: summaryUrl
204+
returnUrl: summaryUrl,
205+
failureUrl: paymentPageUrl,
206+
isLive
201207
}
202208

203209
request.yar.set(`payment-${uuid}`, sessionData)
@@ -208,21 +214,86 @@ export class PaymentField extends FormComponent {
208214

209215
/**
210216
* Called on form submission to capture the payment
211-
* STUB - Jez to implement
217+
* @see https://docs.payments.service.gov.uk/delayed_capture/#delay-taking-a-payment
212218
*/
213-
onSubmit(
214-
_request: FormRequestPayload,
219+
async onSubmit(
220+
request: FormRequestPayload,
215221
_metadata: FormMetadata,
216-
_context: FormContext
222+
context: FormContext
217223
): Promise<void> {
218-
// TODO: Implement
219-
// 1. Get payment state from context
220-
// 2. If already captured, skip
221-
// 3. Call paymentService.getPaymentStatus() to validate pre-auth
222-
// 4. Call paymentService.capturePayment()
223-
// 5. Update payment state with capture status
224-
// 6. If capture fails, throw InvalidComponentStateError
225-
return Promise.resolve()
224+
const paymentState = this.getPaymentStateFromState(context.state)
225+
226+
if (!paymentState) {
227+
// No payment state - redirect to payment page to complete payment
228+
throw new InvalidComponentStateError(
229+
this,
230+
'Complete the payment to continue',
231+
{ shouldResetState: true }
232+
)
233+
}
234+
235+
// Skip if already captured
236+
if (paymentState.capture?.status === 'success') {
237+
return
238+
}
239+
240+
const { paymentId, isLive } = paymentState
241+
const paymentService = new PaymentService({ isLive })
242+
243+
// Verify payment is still in capturable state
244+
const status = await paymentService.getPaymentStatus(paymentId)
245+
246+
// If already captured (success state), mark as captured and continue
247+
if (status.state.status === 'success') {
248+
await this.markPaymentCaptured(request, paymentState)
249+
return
250+
}
251+
252+
if (status.state.status !== 'capturable') {
253+
throw new InvalidComponentStateError(
254+
this,
255+
'Your payment authorisation has expired. Please add your payment details again.',
256+
{ shouldResetState: true }
257+
)
258+
}
259+
260+
// Capture the payment
261+
const captured = await paymentService.capturePayment(paymentId)
262+
263+
if (!captured) {
264+
throw new InvalidComponentStateError(
265+
this,
266+
'There was a problem and your form was not submitted. Try submitting the form again.',
267+
{ shouldResetState: false }
268+
)
269+
}
270+
271+
await this.markPaymentCaptured(request, paymentState)
272+
}
273+
274+
/**
275+
* Updates payment state to mark capture as successful
276+
* This ensures we don't try to re-capture on submission retry
277+
*/
278+
private async markPaymentCaptured(
279+
request: FormRequestPayload,
280+
paymentState: PaymentState
281+
): Promise<void> {
282+
const updatedState: PaymentState = {
283+
...paymentState,
284+
capture: {
285+
status: 'success',
286+
createdAt: new Date().toISOString()
287+
}
288+
}
289+
290+
// Update the state in the page controller
291+
if (this.page) {
292+
const currentState = await this.page.getState(request)
293+
await this.page.mergeState(request, currentState, {
294+
[this.name]: updatedState
295+
})
296+
}
226297
}
227298
}
228299

@@ -237,7 +308,7 @@ export interface PaymentDispatcherArgs {
237308
}
238309
component: PaymentField
239310
sourceUrl: string
240-
paymentService: PaymentService
311+
isLive: boolean
241312
}
242313

243314
/**
@@ -250,5 +321,7 @@ export interface PaymentSessionData {
250321
description: string
251322
paymentId: string
252323
componentName: string
253-
sourceUrl: string
324+
returnUrl: string
325+
failureUrl: string
326+
isLive: boolean
254327
}

src/server/plugins/engine/components/PaymentField.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface PaymentState {
77
amount: number
88
description: string
99
uuid: string
10+
isLive: boolean
1011
capture?: {
1112
status: 'success' | 'failed'
1213
createdAt: string

src/server/plugins/engine/outputFormatters/machine/v2.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { type SubmitResponsePayload } from '@defra/forms-model'
22

33
import { config } from '~/src/config/index.js'
4-
import { FileUploadField } from '~/src/server/plugins/engine/components/index.js'
4+
import {
5+
FileUploadField,
6+
PaymentField
7+
} from '~/src/server/plugins/engine/components/index.js'
58
import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js'
69
import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
710
import {
@@ -12,7 +15,9 @@ import {
1215
import {
1316
type FileUploadFieldDetailitem,
1417
type FormAdapterFile,
18+
type FormAdapterPayment,
1519
type FormContext,
20+
type PaymentFieldDetailItem,
1621
type RichFormValue
1722
} from '~/src/server/plugins/engine/types.js'
1823

@@ -71,6 +76,14 @@ export function format(
7176
* userDownloadLink: 'https://forms-designer/file-download/123-456-789'
7277
* }
7378
* ]
79+
* },
80+
* payments: {
81+
* paymentComponentName: {
82+
* paymentId: 'abc123',
83+
* reference: 'REF-123',
84+
* amount: 10.00,
85+
* description: 'Application fee'
86+
* }
7487
* }
7588
* }
7689
*/
@@ -82,7 +95,16 @@ export function categoriseData(items: DetailItem[]) {
8295
string,
8396
{ fileId: string; fileName: string; userDownloadLink: string }[]
8497
>
85-
} = { main: {}, repeaters: {}, files: {} }
98+
payments: Record<
99+
string,
100+
{
101+
paymentId: string
102+
reference: string
103+
amount: number
104+
description: string
105+
}
106+
>
107+
} = { main: {}, repeaters: {}, files: {}, payments: {} }
86108

87109
items.forEach((item) => {
88110
const { name, state } = item
@@ -91,6 +113,11 @@ export function categoriseData(items: DetailItem[]) {
91113
output.repeaters[name] = extractRepeaters(item)
92114
} else if (isFileUploadFieldItem(item)) {
93115
output.files[name] = extractFileUploads(item)
116+
} else if (isPaymentFieldItem(item)) {
117+
const payment = extractPayment(item)
118+
if (payment) {
119+
output.payments[name] = payment
120+
}
94121
} else {
95122
output.main[name] = item.field.getFormValueFromState(state)
96123
}
@@ -148,3 +175,31 @@ function isFileUploadFieldItem(
148175
): item is FileUploadFieldDetailitem {
149176
return item.field instanceof FileUploadField
150177
}
178+
179+
function isPaymentFieldItem(
180+
item: DetailItemField
181+
): item is PaymentFieldDetailItem {
182+
return item.field instanceof PaymentField
183+
}
184+
185+
/**
186+
* Returns the "payments" section of the response body
187+
* @param item - the payment item in the form
188+
* @returns the payment data
189+
*/
190+
function extractPayment(
191+
item: PaymentFieldDetailItem
192+
): FormAdapterPayment | undefined {
193+
const paymentState = item.field.getPaymentStateFromState(item.state)
194+
195+
if (!paymentState) {
196+
return undefined
197+
}
198+
199+
return {
200+
paymentId: paymentState.paymentId,
201+
reference: paymentState.reference,
202+
amount: paymentState.amount,
203+
description: paymentState.description
204+
}
205+
}

src/server/plugins/engine/pageControllers/QuestionPageController.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ComponentCollection } from '~/src/server/plugins/engine/components/Comp
2121
import { optionalText } from '~/src/server/plugins/engine/components/constants.js'
2222
import { type BackLink } from '~/src/server/plugins/engine/components/types.js'
2323
import {
24+
checkFormStatus,
2425
getCacheService,
2526
getErrors,
2627
getSaveAndExitHelpers,
@@ -47,6 +48,7 @@ import {
4748
import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js'
4849
import {
4950
FormAction,
51+
FormStatus,
5052
type FormRequest,
5153
type FormRequestPayload,
5254
type FormRequestPayloadRefs,
@@ -616,11 +618,16 @@ export class QuestionPageController extends PageController {
616618
// Clear any previous state appendage
617619
request.yar.clear(EXTERNAL_STATE_APPENDAGE)
618620

621+
// Determine if this is a live form (not preview/draft)
622+
const { state } = checkFormStatus(request.params)
623+
const isLive = state === FormStatus.Live
624+
619625
return await selectedComponent.dispatcher(request, h, {
620626
component,
621627
controller: this,
622628
sourceUrl: request.url.toString(),
623-
actionArgs: args
629+
actionArgs: args,
630+
isLive
624631
})
625632
}
626633

0 commit comments

Comments
 (0)