Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recurring payment job sends payment requests to GOV.UK Pay #2109

Merged
merged 8 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docker/env/recurring_payments_job.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ SALES_API_URL=http://host.docker.internal:4000
SALES_API_TIMEOUT_MS=120000

# Recurring Payments start delay for local
RECURRING_PAYMENTS_LOCAL_DELAY=30
RECURRING_PAYMENTS_LOCAL_DELAY=30

# GOV.UK Pay
GOV_PAY_API_URL=https://publicapi.payments.service.gov.uk/v1/payments
3 changes: 3 additions & 0 deletions docker/env/recurring_payments_job.secrets.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ OAUTH_SCOPE=<insert here>

DYNAMICS_API_PATH=<insert here>
DYNAMICS_API_VERSION=<insert here>

# GOV.UK Pay
GOV_PAY_RECURRING_APIKEY=<insert here>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { salesApi } from '@defra-fish/connectors-lib'
import { processRecurringPayments } from '../recurring-payments-processor.js'
import { sendPayment } from '../services/govuk-pay-service.js'

jest.mock('@defra-fish/business-rules-lib')
jest.mock('@defra-fish/connectors-lib', () => ({
Expand All @@ -8,9 +9,12 @@ jest.mock('@defra-fish/connectors-lib', () => ({
preparePermissionDataForRenewal: jest.fn(() => ({
licensee: { countryCode: 'GB-ENG' }
})),
createTransaction: jest.fn()
createTransaction: jest.fn(() => ({
cost: 30
}))
}
}))
jest.mock('../services/govuk-pay-service.js')

describe('recurring-payments-processor', () => {
beforeEach(() => {
Expand Down Expand Up @@ -53,15 +57,15 @@ describe('recurring-payments-processor', () => {

it('prepares the data for found recurring payments', async () => {
const referenceNumber = Symbol('reference')
salesApi.getDueRecurringPayments.mockReturnValueOnce([{ expanded: { activePermission: { entity: { referenceNumber } } } }])
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment(referenceNumber)])

await processRecurringPayments()

expect(salesApi.preparePermissionDataForRenewal).toHaveBeenCalledWith(referenceNumber)
})

it('creates a transaction with the correct data', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([{ expanded: { activePermission: { entity: { referenceNumber: '1' } } } }])
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])

const isLicenceForYou = Symbol('isLicenceForYou')
const isRenewal = Symbol('isRenewal')
Expand Down Expand Up @@ -108,7 +112,7 @@ describe('recurring-payments-processor', () => {
})

it('strips the concession name returned by preparePermissionDataForRenewal before passing to createTransaction', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([{ expanded: { activePermission: { entity: { referenceNumber: '1' } } } }])
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])

salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: {
Expand Down Expand Up @@ -141,7 +145,7 @@ describe('recurring-payments-processor', () => {
})

it('assigns the correct startDate when licenceStartTime is present', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([{ expanded: { activePermission: { entity: { referenceNumber: '1' } } } }])
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])

salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' },
Expand All @@ -159,7 +163,7 @@ describe('recurring-payments-processor', () => {
})

it('assigns the correct startDate when licenceStartTime is not present', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([{ expanded: { activePermission: { entity: { referenceNumber: '1' } } } }])
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])

salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' },
Expand All @@ -176,7 +180,7 @@ describe('recurring-payments-processor', () => {
})

it('raises an error if createTransaction fails', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([{ expanded: { activePermission: { entity: { referenceNumber: '1' } } } }])
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
const error = 'Wuh-oh!'
salesApi.createTransaction.mockImplementationOnce(() => {
throw new Error(error)
Expand All @@ -185,6 +189,34 @@ describe('recurring-payments-processor', () => {
await expect(processRecurringPayments()).rejects.toThrowError(error)
})

it('prepares and sends the payment request', async () => {
const agreementId = Symbol('agreementId')
const transactionId = Symbol('transactionId')

salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment('foo', agreementId)])

salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' }
})

salesApi.createTransaction.mockReturnValueOnce({
cost: 50,
id: transactionId
})

const expectedData = {
amount: 5000,
description: 'The recurring card payment for your rod fishing licence',
reference: transactionId,
authorisation_mode: 'agreement',
agreement_id: agreementId
}

await processRecurringPayments()

expect(sendPayment).toHaveBeenCalledWith(expectedData)
})

describe.each([2, 3, 10])('if there are %d recurring payments', count => {
it('prepares the data for each one', async () => {
const references = []
Expand All @@ -194,7 +226,7 @@ describe('recurring-payments-processor', () => {

const mockGetDueRecurringPayments = []
references.forEach(reference => {
mockGetDueRecurringPayments.push({ expanded: { activePermission: { entity: { referenceNumber: reference } } } })
mockGetDueRecurringPayments.push(getMockDueRecurringPayment(reference))
})
salesApi.getDueRecurringPayments.mockReturnValueOnce(mockGetDueRecurringPayments)

Expand All @@ -211,7 +243,7 @@ describe('recurring-payments-processor', () => {
it('creates a transaction for each one', async () => {
const mockGetDueRecurringPayments = []
for (let i = 0; i < count; i++) {
mockGetDueRecurringPayments.push({ expanded: { activePermission: { entity: { referenceNumber: i } } } })
mockGetDueRecurringPayments.push(getMockDueRecurringPayment(i))
}
salesApi.getDueRecurringPayments.mockReturnValueOnce(mockGetDueRecurringPayments)

Expand Down Expand Up @@ -241,5 +273,53 @@ describe('recurring-payments-processor', () => {

expect(salesApi.createTransaction.mock.calls).toEqual(expectedData)
})

it('sends a payment for each one', async () => {
const mockGetDueRecurringPayments = []
const agreementIds = []
for (let i = 0; i < count; i++) {
const agreementId = Symbol(`agreementId${1}`)
agreementIds.push(agreementId)
mockGetDueRecurringPayments.push(getMockDueRecurringPayment(i, agreementId))
}
salesApi.getDueRecurringPayments.mockReturnValueOnce(mockGetDueRecurringPayments)

const permits = []
for (let i = 0; i < count; i++) {
permits.push(Symbol(`permit${i}`))
}

permits.forEach((permit, i) => {
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' }
})

salesApi.createTransaction.mockReturnValueOnce({
cost: i,
id: permit
})
})

const expectedData = []
permits.forEach((permit, i) => {
expectedData.push([
{
amount: i * 100,
description: 'The recurring card payment for your rod fishing licence',
reference: permit,
authorisation_mode: 'agreement',
agreement_id: agreementIds[i]
}
])
})

await processRecurringPayments()
expect(sendPayment.mock.calls).toEqual(expectedData)
})
irisfaraway marked this conversation as resolved.
Show resolved Hide resolved
})
})

const getMockDueRecurringPayment = (referenceNumber = '123', agreementId = '456') => ({
entity: { agreementId },
expanded: { activePermission: { entity: { referenceNumber } } }
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import moment from 'moment-timezone'
import { SERVICE_LOCAL_TIME } from '@defra-fish/business-rules-lib'
import { salesApi } from '@defra-fish/connectors-lib'
import { sendPayment } from './services/govuk-pay-service.js'

export const processRecurringPayments = async () => {
if (process.env.RUN_RECURRING_PAYMENTS?.toLowerCase() === 'true') {
Expand All @@ -16,17 +17,30 @@ export const processRecurringPayments = async () => {

const processRecurringPayment = async record => {
const referenceNumber = record.expanded.activePermission.entity.referenceNumber
const agreementId = record.entity.agreementId
const transaction = await createNewTransaction(referenceNumber)
takeRecurringPayment(agreementId, transaction)
}

const createNewTransaction = async referenceNumber => {
const transactionData = await processPermissionData(referenceNumber)
console.log('Creating new transaction based on', referenceNumber)
try {
const response = await salesApi.createTransaction(transactionData)
console.log('New transaction created:', response)
return response
} catch (e) {
console.log('Error creating transaction', JSON.stringify(transactionData))
throw e
}
}

const takeRecurringPayment = (agreementId, transaction) => {
const preparedPayment = preparePayment(agreementId, transaction)
console.log('Requesting payment:', preparedPayment)
sendPayment(preparedPayment)
}

const processPermissionData = async referenceNumber => {
console.log('Preparing data based on', referenceNumber)
const data = await salesApi.preparePermissionDataForRenewal(referenceNumber)
Expand Down Expand Up @@ -56,3 +70,15 @@ const prepareStartDate = permission => {
.utc()
.toISOString()
}

const preparePayment = (agreementId, transaction) => {
const result = {
amount: Math.round(transaction.cost * 100),
description: 'The recurring card payment for your rod fishing licence',
reference: transaction.id,
authorisation_mode: 'agreement',
agreement_id: agreementId
}

return result
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { sendPayment } from '../govuk-pay-service.js'
import { govUkPayApi } from '@defra-fish/connectors-lib'

jest.mock('@defra-fish/connectors-lib')

describe('govuk-pay-service', () => {
describe('sendPayment', () => {
it('should send provided payload data to Gov.UK Pay', async () => {
govUkPayApi.createPayment.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ success: true, paymentId: 'abc123' })
})
const unique = Symbol('payload')
const payload = {
amount: '100',
description: 'The recurring card payment for your rod fishing licence',
reference: unique
}
await sendPayment(payload)
expect(govUkPayApi.createPayment).toHaveBeenCalledWith(payload, true)
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { govUkPayApi } from '@defra-fish/connectors-lib'

export const sendPayment = preparedPayment => {
govUkPayApi.createPayment(preparedPayment, true)
}
irisfaraway marked this conversation as resolved.
Show resolved Hide resolved
Loading