Skip to content

Commit 9c316c3

Browse files
committed
WIP
1 parent 6b6d396 commit 9c316c3

14 files changed

Lines changed: 484 additions & 180 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ export class FormComponent extends ComponentBase {
191191
return value.filter(isFormValue)
192192
}
193193

194-
return this.isValue(value) ? value : null
194+
return this.isValue(value) ? (value as Item['value']) : null
195195
}
196196

197197
getContextValueFromState(

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

Lines changed: 82 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
type PaymentFieldComponent
66
} from '@defra/forms-model'
77
import { StatusCodes } from 'http-status-codes'
8+
import joi, { type ObjectSchema } from 'joi'
89

910
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
1011
import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
12+
import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js'
1113
import {
1214
type AnyFormRequest,
1315
type FormContext,
@@ -17,13 +19,17 @@ import {
1719
import {
1820
type ErrorMessageTemplateList,
1921
type FormPayload,
22+
type FormState,
23+
type FormStateValue,
2024
type FormSubmissionError,
2125
type FormSubmissionState
2226
} from '~/src/server/plugins/engine/types.js'
2327
import { PaymentService } from '~/src/server/plugins/payment/service.js'
2428

2529
export class PaymentField extends FormComponent {
2630
declare options: PaymentFieldComponent['options']
31+
declare formSchema: ObjectSchema
32+
declare stateSchema: ObjectSchema
2733

2834
constructor(
2935
def: PaymentFieldComponent,
@@ -32,6 +38,30 @@ export class PaymentField extends FormComponent {
3238
super(def, props)
3339

3440
this.options = def.options
41+
42+
// Payment state is validated as an object with the required fields
43+
const paymentStateSchema = joi
44+
.object({
45+
paymentId: joi.string().required(),
46+
reference: joi.string().required(),
47+
amount: joi.number().required(),
48+
description: joi.string().required(),
49+
uuid: joi.string().uuid().required(),
50+
preAuth: joi
51+
.object({
52+
status: joi
53+
.string()
54+
.valid('success', 'failed', 'started')
55+
.required(),
56+
createdAt: joi.string().isoDate().required()
57+
})
58+
.required()
59+
})
60+
.unknown(true)
61+
.label(this.label)
62+
63+
this.formSchema = paymentStateSchema
64+
this.stateSchema = paymentStateSchema.default(null).allow(null)
3565
}
3666

3767
/**
@@ -40,7 +70,7 @@ export class PaymentField extends FormComponent {
4070
getPaymentStateFromState(
4171
state: FormSubmissionState
4272
): PaymentState | undefined {
43-
const value = state[this.name] as unknown
73+
const value = state[this.name]
4474
return this.isPaymentState(value) ? value : undefined
4575
}
4676

@@ -88,6 +118,13 @@ export class PaymentField extends FormComponent {
88118
)
89119
}
90120

121+
/**
122+
* Override base isState to validate PaymentState
123+
*/
124+
isState(value?: FormStateValue | FormState): value is FormState {
125+
return this.isPaymentState(value)
126+
}
127+
91128
/**
92129
* For error preview page that shows all possible errors on a component
93130
*/
@@ -112,7 +149,6 @@ export class PaymentField extends FormComponent {
112149

113150
/**
114151
* Dispatcher for external redirect to GOV.UK Pay
115-
* STUB - Jez to implement
116152
*/
117153
static async dispatcher(
118154
request: FormRequestPayload,
@@ -121,38 +157,52 @@ export class PaymentField extends FormComponent {
121157
): Promise<unknown> {
122158
const paymentService = new PaymentService()
123159

124-
// 1. Generate UUID token and store in session
160+
// 1. Generate UUID token
125161
const uuid = randomUUID()
126162

127-
const { options } = args.component
163+
const { options, name: componentName } = args.component
128164
const { model } = args.controller
129165

130166
const state = await args.controller.getState(request)
131-
132-
const data = {
133-
uuid,
134-
reference: state.$$__referenceNumber,
135-
description: options.description,
136-
amount: options.amount
137-
} as PaymentState
138-
139-
request.yar.set(`${request.url.pathname}-payment`, data)
167+
const reference = state.$$__referenceNumber as string
168+
const amount = options.amount ?? 0
169+
const description = options.description ?? ''
140170

141171
const formId = model.formId
142172
const slug = `/${model.basePath}`
143173

144-
// 2. Call paymentService.createPayment()
174+
// 2. Build the return URL for GOV.UK Pay
175+
const { baseUrl } = getPluginOptions(request.server)
176+
const returnUrl = `${baseUrl}/payment-callback?uuid=${uuid}`
177+
178+
// Build the summary URL to redirect to after payment
179+
const summaryUrl = `${baseUrl}/${model.basePath}/summary`
180+
181+
// 3. Call paymentService.createPayment()
145182
// GOV.UK Pay expects amount in pence, so multiply pounds by 100
146-
const amountInPence = Math.round(data.amount * 100)
183+
const amountInPence = Math.round(amount * 100)
147184
const payment = await paymentService.createPayment(
148185
amountInPence,
149-
data.description,
150-
uuid,
151-
data.reference,
186+
description,
187+
returnUrl,
188+
reference,
152189
{ formId, slug }
153190
)
154191

155-
// 3. Redirect to GOV.UK Pay paymentUrl
192+
// 4. Store session data for the return route to use
193+
const sessionData: PaymentSessionData = {
194+
uuid,
195+
reference,
196+
amount,
197+
description,
198+
paymentId: payment.paymentId,
199+
componentName,
200+
sourceUrl: summaryUrl
201+
}
202+
203+
request.yar.set(`payment-${uuid}`, sessionData)
204+
205+
// 5. Redirect to GOV.UK Pay paymentUrl
156206
return h.redirect(payment.paymentUrl).code(StatusCodes.SEE_OTHER)
157207
}
158208

@@ -189,3 +239,16 @@ export interface PaymentDispatcherArgs {
189239
sourceUrl: string
190240
paymentService: PaymentService
191241
}
242+
243+
/**
244+
* Session data stored when dispatching to GOV.UK Pay
245+
*/
246+
export interface PaymentSessionData {
247+
uuid: string
248+
reference: string
249+
amount: number
250+
description: string
251+
paymentId: string
252+
componentName: string
253+
sourceUrl: string
254+
}

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

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,41 +16,3 @@ export interface PaymentState {
1616
createdAt: string
1717
}
1818
}
19-
20-
/**
21-
* Response from GOV.UK Pay API
22-
*/
23-
export interface PaymentStatus {
24-
amount: number
25-
state: {
26-
status:
27-
| 'created'
28-
| 'started'
29-
| 'submitted'
30-
| 'capturable'
31-
| 'success'
32-
| 'failed'
33-
| 'cancelled'
34-
| 'error'
35-
finished: boolean
36-
message?: string
37-
code?: string
38-
canRetry?: boolean
39-
}
40-
createdDate: string
41-
}
42-
43-
/**
44-
* Service interface for GOV.UK Pay integration
45-
*/
46-
export interface PaymentService {
47-
createPayment(
48-
amount: number,
49-
description: string,
50-
metadata: { formId: string; slug: string }
51-
): Promise<{ paymentId: string; paymentUrl: string }>
52-
53-
getPaymentStatus(paymentId: string): Promise<PaymentStatus>
54-
55-
capturePayment(paymentId: string): Promise<boolean>
56-
}

src/server/plugins/engine/models/SummaryViewModel.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { SchemaVersion, type Section } from '@defra/forms-model'
22

3+
import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
4+
import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
35
import {
46
getAnswer,
57
type Field
@@ -52,6 +54,8 @@ export class SummaryViewModel {
5254
hasMissingNotificationEmail?: boolean
5355
components?: ComponentViewModel[]
5456
allowSaveAndExit = false
57+
paymentState?: PaymentState
58+
paymentDetails?: CheckAnswers
5559

5660
constructor(
5761
request: FormContextRequest,
@@ -144,6 +148,10 @@ export class SummaryViewModel {
144148
)
145149
} else {
146150
for (const field of collection.fields) {
151+
// PaymentField is rendered in its own section, skip it here
152+
if (field instanceof PaymentField) {
153+
continue
154+
}
147155
items.push(ItemField(page, state, field, { path, errors }))
148156
}
149157
}

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

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { type RouteOptions } from '@hapi/hapi'
99

1010
import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js'
1111
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
12+
import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
1213
import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
1314
import {
1415
checkEmailAddressForLiveFormSubmission,
@@ -65,7 +66,7 @@ export class SummaryPageController extends QuestionPageController {
6566
const viewModel = new SummaryViewModel(request, this, context)
6667

6768
const { query } = request
68-
const { payload, errors } = context
69+
const { payload, errors, state } = context
6970
const components = this.collection.getViewModel(payload, errors, query)
7071

7172
// We already figure these out in the base page controller. Take them and apply them to our page-specific model.
@@ -77,9 +78,76 @@ export class SummaryPageController extends QuestionPageController {
7778
viewModel.allowSaveAndExit = this.shouldShowSaveAndExit(request.server)
7879
viewModel.errors = errors
7980

81+
// Find PaymentField and extract payment state for the summary banner
82+
const paymentField = context.relevantPages
83+
.flatMap((page) => page.collection.fields)
84+
.find((field): field is PaymentField => field instanceof PaymentField)
85+
86+
if (paymentField) {
87+
const paymentState = paymentField.getPaymentStateFromState(state)
88+
if (paymentState) {
89+
viewModel.paymentState = paymentState
90+
viewModel.paymentDetails = this.buildPaymentDetails(
91+
paymentField,
92+
paymentState
93+
)
94+
}
95+
}
96+
8097
return viewModel
8198
}
8299

100+
private buildPaymentDetails(
101+
paymentField: PaymentField,
102+
paymentState: NonNullable<
103+
ReturnType<PaymentField['getPaymentStateFromState']>
104+
>
105+
) {
106+
const formatDate = (isoString: string) => {
107+
const date = new Date(isoString)
108+
return (
109+
date.toLocaleDateString('en-GB', {
110+
day: 'numeric',
111+
month: 'long',
112+
year: 'numeric'
113+
}) +
114+
' – ' +
115+
date.toLocaleTimeString('en-GB', {
116+
hour: '2-digit',
117+
minute: '2-digit',
118+
second: '2-digit'
119+
})
120+
)
121+
}
122+
123+
const rows = [
124+
{
125+
key: { text: 'Payment for' },
126+
value: { text: paymentState.description }
127+
},
128+
{
129+
key: { text: 'Total amount' },
130+
value: { text: ${paymentState.amount}` }
131+
},
132+
{
133+
key: { text: 'Reference' },
134+
value: { text: paymentState.reference }
135+
}
136+
]
137+
138+
if (paymentState.preAuth?.createdAt) {
139+
rows.push({
140+
key: { text: 'Date details were entered' },
141+
value: { text: formatDate(paymentState.preAuth.createdAt) }
142+
})
143+
}
144+
145+
return {
146+
title: { text: 'Payment details' },
147+
summaryList: { rows }
148+
}
149+
}
150+
83151
/**
84152
* Returns an async function. This is called in plugin.ts when there is a GET request at `/{id}/{path*}`,
85153
*/

0 commit comments

Comments
 (0)