diff --git a/app/controllers/simplified-account/settings/card-types/card-types.controller.js b/app/controllers/simplified-account/settings/card-types/card-types.controller.js index 199f1a357..ea8a5dc9e 100644 --- a/app/controllers/simplified-account/settings/card-types/card-types.controller.js +++ b/app/controllers/simplified-account/settings/card-types/card-types.controller.js @@ -1,7 +1,20 @@ const { response } = require('@utils/response') +const { formatCardTypesForTemplate } = require('@utils/simplified-account/format/format-card-types') +const { getAllCardTypes, getAcceptedCardTypesForServiceAndAccountType } = require('@services/card-types.service') -function get (req, res) { - response(req, res, 'simplified-account/settings/card-types/index', { }) +async function get (req, res, next) { + const serviceId = req.service.externalId + const accountType = req.account.type + const isAdminUser = req.user.isAdminUserForService(serviceId) + try { + const { card_types: allCards } = await getAllCardTypes() + const { card_types: acceptedCards } = await getAcceptedCardTypesForServiceAndAccountType(serviceId, accountType) + const cardTypes = formatCardTypesForTemplate(allCards, acceptedCards, req.account, isAdminUser) + response(req, res, 'simplified-account/settings/card-types/index', + { cardTypes, isAdminUser }) + } catch (err) { + next(err) + } } module.exports = { diff --git a/app/controllers/simplified-account/settings/card-types/card-types.controller.test.js b/app/controllers/simplified-account/settings/card-types/card-types.controller.test.js new file mode 100644 index 000000000..f68a4bde7 --- /dev/null +++ b/app/controllers/simplified-account/settings/card-types/card-types.controller.test.js @@ -0,0 +1,118 @@ +const sinon = require('sinon') +const ControllerTestBuilder = require('@test/test-helpers/simplified-account/controllers/ControllerTestBuilder.class') +const { expect } = require('chai') +const User = require('@models/User.class') +const userFixtures = require('@test/fixtures/user.fixtures') + +const ACCOUNT_TYPE = 'live' +const SERVICE_ID = 'service-id-123abc' + +const adminUser = new User(userFixtures.validUserResponse({ + external_id: 'user-id-for-admin-user', + service_roles: { + service: { + service: { external_id: SERVICE_ID }, + role: { name: 'admin' } + } + } +})) + +const viewOnlyUser = new User(userFixtures.validUserResponse( + { + external_id: 'user-id-for-view-only-user', + service_roles: { + service: + { + service: { external_id: SERVICE_ID }, + role: { name: 'view-only' } + } + } + })) + +const allCardTypes = [{ + id: 'id-001', + brand: 'visa', + label: 'Visa', + type: 'DEBIT', + requires3ds: false +}, +{ + id: 'id-002', + brand: 'visa', + label: 'Visa', + type: 'CREDIT', + requires3ds: false +}] +const acceptedCardTypes = [allCardTypes[0]] + +const mockResponse = sinon.spy() +const mockGetAllCardTypes = sinon.stub().resolves({ card_types: allCardTypes }) +const mockGetAcceptedCardTypesForServiceAndAccountType = sinon.stub().resolves({ card_types: acceptedCardTypes }) + +const { res, nextRequest, call } = new ControllerTestBuilder('@controllers/simplified-account/settings/card-types/card-types.controller') + .withServiceExternalId(SERVICE_ID) + .withAccountType(ACCOUNT_TYPE) + .withStubs({ + '@utils/response': { response: mockResponse }, + '@services/card-types.service': { + getAllCardTypes: mockGetAllCardTypes, + getAcceptedCardTypesForServiceAndAccountType: mockGetAcceptedCardTypesForServiceAndAccountType + } + }) + .build() + +describe('Controller: settings/card-types', () => { + describe('get for admin user', () => { + before(() => { + nextRequest({ + user: adminUser + }) + call('get') + }) + + it('should call the response method', () => { + expect(mockResponse.called).to.be.true // eslint-disable-line + }) + + it('should pass req, res and template path to the response method', () => { + expect(mockResponse.args[0][0].user).to.deep.equal(adminUser) + expect(mockResponse.args[0][1]).to.deep.equal(res) + expect(mockResponse.args[0][2]).to.equal('simplified-account/settings/card-types/index') + }) + + it('should pass context data to the response method', () => { + expect(mockResponse.args[0][3]).to.have.property('cardTypes').to.have.property('debitCards').length(1) + expect(mockResponse.args[0][3].cardTypes.debitCards[0]).to.deep.include({ text: 'Visa debit', checked: true }) + expect(mockResponse.args[0][3]).to.have.property('cardTypes').to.have.property('creditCards').length(1) + expect(mockResponse.args[0][3].cardTypes.creditCards[0]).to.deep.include({ text: 'Visa credit', checked: false }) + expect(mockResponse.args[0][3]).to.have.property('isAdminUser').to.equal(true) + }) + }) + + describe('get for non-admin user', () => { + before(() => { + nextRequest({ + user: viewOnlyUser + }) + call('get') + }) + + it('should call the response method', () => { + expect(mockResponse.called).to.be.true // eslint-disable-line + }) + + it('should pass req, res and template path to the response method', () => { + expect(mockResponse.args[0][0].user).to.deep.equal(viewOnlyUser) + expect(mockResponse.args[0][1]).to.deep.equal(res) + expect(mockResponse.args[0][2]).to.equal('simplified-account/settings/card-types/index') + }) + + it('should pass context data to the response method', () => { + expect(mockResponse.args[0][3]).to.have.property('cardTypes').to.have.property('Enabled debit cards').to.have.length(1).to.include('Visa debit') + expect(mockResponse.args[0][3].cardTypes).to.have.property('Not enabled debit cards').to.have.length(0) + expect(mockResponse.args[0][3].cardTypes).to.have.property('Enabled credit cards').to.have.length(0) + expect(mockResponse.args[0][3].cardTypes).to.have.property('Not enabled credit cards').to.have.length(1).to.include('Visa credit') + expect(mockResponse.args[0][3]).to.have.property('isAdminUser').to.equal(false) + }) + }) +}) diff --git a/app/services/card-types.service.js b/app/services/card-types.service.js new file mode 100644 index 000000000..dc0de9d7a --- /dev/null +++ b/app/services/card-types.service.js @@ -0,0 +1,17 @@ +'use strict' + +const ConnectorClient = require('./clients/connector.client.js').ConnectorClient +const connectorClient = new ConnectorClient(process.env.CONNECTOR_URL) + +async function getAllCardTypes () { + return connectorClient.getAllCardTypes() +} + +async function getAcceptedCardTypesForServiceAndAccountType (serviceId, accountType) { + return connectorClient.getAcceptedCardsForServiceAndAccountType(serviceId, accountType) +} + +module.exports = { + getAllCardTypes, + getAcceptedCardTypesForServiceAndAccountType +} diff --git a/app/services/clients/connector.client.js b/app/services/clients/connector.client.js index a559dc64d..350965216 100644 --- a/app/services/clients/connector.client.js +++ b/app/services/clients/connector.client.js @@ -257,6 +257,21 @@ ConnectorClient.prototype = { return response.data }, + /** + * Retrieves the accepted card Types for the given external service external id and account type + * @param serviceId (required) + * @param accountType (required) + * @returns {Promise} + */ + getAcceptedCardsForServiceAndAccountType: async function (serviceId, accountType) { + const url = `${this.connectorUrl}/v1/frontend/service/{serviceId}/account/{accountType}/card-types` + .replace('{serviceId}', encodeURIComponent(serviceId)) + .replace('{accountType}', encodeURIComponent(accountType)) + configureClient(client, url) + const response = await client.get(url, 'get accepted card types for account') + return response.data + }, + /** * Updates the accepted card Types for to the given gateway account * @param gatewayAccountId (required) diff --git a/app/simplified-account-routes.js b/app/simplified-account-routes.js index 246980a47..8b477b27f 100644 --- a/app/simplified-account-routes.js +++ b/app/simplified-account-routes.js @@ -69,6 +69,9 @@ simplifiedAccount.get(paths.simplifiedAccount.settings.worldpayDetails.index, pe simplifiedAccount.get(paths.simplifiedAccount.settings.worldpayDetails.credentials, permission('gateway-credentials:update'), serviceSettingsController.worldpayDetails.worldpayCredentials.get) simplifiedAccount.post(paths.simplifiedAccount.settings.worldpayDetails.credentials, permission('gateway-credentials:update'), serviceSettingsController.worldpayDetails.worldpayCredentials.post) +// card types +simplifiedAccount.get(paths.simplifiedAccount.settings.cardTypes.index, permission('transactions:read'), serviceSettingsController.cardTypes.get) + // stripe details const stripeDetailsPath = paths.simplifiedAccount.settings.stripeDetails const stripeDetailsRouter = new Router({ mergeParams: true }) diff --git a/app/utils/simplified-account/format/format-card-types.js b/app/utils/simplified-account/format/format-card-types.js new file mode 100644 index 000000000..fcd4fc424 --- /dev/null +++ b/app/utils/simplified-account/format/format-card-types.js @@ -0,0 +1,80 @@ +const cardsThatNeedToBeEnabledOnWorldpay = ['American Express', 'Union Pay'] + +const formatLabel = (card) => { + if (card.brand === 'visa' || card.brand === 'master-card') { + return `${card.label} ${card.type.toLowerCase()}` + } + return card.brand === 'jcb' ? card.label.toUpperCase() : card.label +} + +const createCardTypeChecklistItem = (card, acceptedCards) => { + return { + value: card.id, + text: formatLabel(card), + checked: acceptedCards.filter(accepted => accepted.id === card.id).length !== 0, + requires3ds: card.requires3ds + } +} + +const disableCheckboxIf3dsRequiredButNotEnabled = (cardTypeChecklistItem, accountType, accountRequires3ds) => { + if (cardTypeChecklistItem.requires3ds && !accountRequires3ds) { + return { + ...cardTypeChecklistItem, + disabled: true, + hint: { + html: accountType === 'test' ? `${cardTypeChecklistItem.text} is not available on test accounts` : `${cardTypeChecklistItem.text} cannot be used because 3D Secure is not available. Please contact support` + } + } + } + return cardTypeChecklistItem +} + +const addHintForAmexAndUnionpayIfWorldpay = (cardTypeChecklistItem, paymentProvider) => { + if (cardsThatNeedToBeEnabledOnWorldpay.includes(cardTypeChecklistItem.text) && paymentProvider === 'worldpay') { + return { + ...cardTypeChecklistItem, + hint: { + html: 'You must have already enabled this with Worldpay' + } + } + } + return cardTypeChecklistItem +} + +const formatCardTypesForAdminTemplate = (allCards, acceptedCards, account) => { + const debitCardChecklistItems = allCards.filter(card => card.type === 'DEBIT') + .map(card => createCardTypeChecklistItem(card, acceptedCards)) + .map(cardTypeChecklistItem => disableCheckboxIf3dsRequiredButNotEnabled(cardTypeChecklistItem, account.type, account.requires3ds)) + + const creditCardChecklistItems = allCards.filter(card => card.type === 'CREDIT') + .map(card => createCardTypeChecklistItem(card, acceptedCards)) + .map(cardTypeChecklistItem => addHintForAmexAndUnionpayIfWorldpay(cardTypeChecklistItem, account.paymentProvider)) + + return { debitCards: debitCardChecklistItems, creditCards: creditCardChecklistItems } +} + +const formatCardTypesForNonAdminTemplate = (allCards, acceptedCards) => { + const acceptedCardTypeIds = acceptedCards.map(card => card.id) + const formattedCardTypes = { + 'Enabled debit cards': [], + 'Not enabled debit cards': [], + 'Enabled credit cards': [], + 'Not enabled credit cards': [] + } + allCards.forEach(card => { + const cardIsEnabled = acceptedCardTypeIds.includes(card.id) ? 'Enabled' : 'Not enabled' + formattedCardTypes[`${cardIsEnabled} ${card.type.toLowerCase()} cards`].push(formatLabel(card)) + }) + return formattedCardTypes +} + +const formatCardTypesForTemplate = (allCards, acceptedCards, account, isAdminUser) => { + if (isAdminUser) { + return formatCardTypesForAdminTemplate(allCards, acceptedCards, account) + } + return formatCardTypesForNonAdminTemplate(allCards, acceptedCards) +} + +module.exports = { + formatCardTypesForTemplate +} diff --git a/app/utils/simplified-account/format/format-card-types.test.js b/app/utils/simplified-account/format/format-card-types.test.js new file mode 100644 index 000000000..46aec29d0 --- /dev/null +++ b/app/utils/simplified-account/format/format-card-types.test.js @@ -0,0 +1,116 @@ +const { expect } = require('chai') +const { formatCardTypesForTemplate } = require('@utils/simplified-account/format/format-card-types') + +const allCards = [ + { + id: 'id-001', + brand: 'visa', + label: 'Visa', + type: 'DEBIT', + requires3ds: false + }, + { + id: 'id-002', + brand: 'visa', + label: 'Visa', + type: 'CREDIT', + requires3ds: false + }, + { + id: 'id-003', + brand: 'master-card', + label: 'Mastercard', + type: 'DEBIT', + requires3ds: false + }, + { + id: 'id-004', + brand: 'american-express', + label: 'American Express', + type: 'CREDIT', + requires3ds: false + }, + { + id: 'id-005', + brand: 'jcb', + label: 'Jcb', + type: 'CREDIT', + requires3ds: false + }, + { + id: 'id-006', + brand: 'maestro', + label: 'Maestro', + type: 'DEBIT', + requires3ds: true + } +] +describe('format-card-types for template', () => { + describe('present checkboxes for admin user', () => { + it('should return all card types with checked boxes if they are all accepted', () => { + const acceptedCards = [...allCards] + const account = { requires3ds: true } + const cards = formatCardTypesForTemplate(allCards, acceptedCards, account, true) + expect(cards).to.have.property('debitCards').to.have.length(3) + expect(cards.debitCards[0]).to.deep.include({ text: 'Visa debit', checked: true }) + expect(cards.debitCards[1]).to.deep.include({ text: 'Mastercard debit', checked: true }) + expect(cards.debitCards[2]).to.deep.include({ text: 'Maestro', checked: true }) + expect(cards).to.have.property('creditCards').to.have.length(3) + expect(cards.creditCards[0]).to.deep.include({ text: 'Visa credit', checked: true }) + expect(cards.creditCards[1]).to.deep.include({ text: 'American Express', checked: true }) + expect(cards.creditCards[2]).to.deep.include({ text: 'JCB', checked: true }) + }) + + it('should return unchecked boxes for not accepted card types', () => { + const acceptedCards = [...allCards].filter(card => card.id !== 'id-001' && card.id !== 'id-002') + const account = { requires3ds: true } + const cards = formatCardTypesForTemplate(allCards, acceptedCards, account, true) + expect(cards).to.have.property('debitCards').to.have.length(3) + expect(cards.debitCards[0]).to.deep.include({ text: 'Visa debit', checked: false }) + expect(cards.debitCards[1]).to.deep.include({ text: 'Mastercard debit', checked: true }) + expect(cards.debitCards[2]).to.deep.include({ text: 'Maestro', checked: true }) + expect(cards).to.have.property('creditCards').to.have.length(3) + expect(cards.creditCards[0]).to.deep.include({ text: 'Visa credit', checked: false }) + expect(cards.creditCards[1]).to.deep.include({ text: 'American Express', checked: true }) + expect(cards.creditCards[2]).to.deep.include({ text: 'JCB', checked: true }) + }) + + it('should set checkbox to disabled for requires3ds card types if 3ds not enabled for test account', () => { + const acceptedCards = [...allCards] + const account = { requires3ds: false, type: 'test' } + const cards = formatCardTypesForTemplate(allCards, acceptedCards, account, true) + expect(cards.debitCards.filter(card => card.text === 'Maestro')[0]).to.have.property('disabled').to.be.true // eslint-disable-line no-unused-expressions + expect(cards.debitCards.filter(card => card.text === 'Maestro')[0]).to.have.property('hint') + .to.deep.equal({ html: 'Maestro is not available on test accounts' }) + }) + + it('should set checkbox to disabled for requires3ds card types if 3ds not enabled for live account', () => { + const acceptedCards = [...allCards] + const account = { requires3ds: false, type: 'live' } + const cards = formatCardTypesForTemplate(allCards, acceptedCards, account, true) + expect(cards.debitCards.filter(card => card.text === 'Maestro')[0]).to.have.property('disabled').to.be.true // eslint-disable-line no-unused-expressions + expect(cards.debitCards.filter(card => card.text === 'Maestro')[0]).to.have.property('hint') + .to.deep.equal({ html: 'Maestro cannot be used because 3D Secure is not available. Please contact support' }) + }) + + it('should add hint to American Express if payment provider is Worldpay and account is live', () => { + const acceptedCards = [...allCards] + const account = { requires3ds: true, paymentProvider: 'worldpay', type: 'live' } + const cards = formatCardTypesForTemplate(allCards, acceptedCards, account, true) + expect(cards.creditCards.filter(card => card.text === 'American Express')[0]) + .to.have.property('hint').to.deep.equal({ html: 'You must have already enabled this with Worldpay' }) + }) + }) + + describe('present read-only list for non-admin user', () => { + it('should return all card types arranged by type and whether accepted or not', () => { + const acceptedCards = [...allCards].filter(card => card.id !== 'id-001' && card.id !== 'id-002') + const account = { requires3ds: true } + const cards = formatCardTypesForTemplate(allCards, acceptedCards, account, false) + expect(cards['Enabled debit cards']).to.have.length(2).to.deep.equal(['Mastercard debit', 'Maestro']) + expect(cards['Not enabled debit cards']).to.have.length(1).to.deep.equal(['Visa debit']) + expect(cards['Enabled credit cards']).to.have.length(2).to.deep.equal(['American Express', 'JCB']) + expect(cards['Not enabled credit cards']).to.have.length(1).to.deep.equal(['Visa credit']) + }) + }) +}) diff --git a/app/views/simplified-account/settings/card-types/cardTypesCheckboxes.njk b/app/views/simplified-account/settings/card-types/cardTypesCheckboxes.njk new file mode 100644 index 000000000..8e2a14d7a --- /dev/null +++ b/app/views/simplified-account/settings/card-types/cardTypesCheckboxes.njk @@ -0,0 +1,44 @@ + +

Choose which credit and debit cards you want to accept.

+ +
+ + {{ + govukCheckboxes({ + idPrefix: "debit", + name: "debit", + classes: "with-border govuk-!-padding-top-3 pay-!-border-top", + fieldset: { + legend: { + text: "Debit cards", + classes: "govuk-fieldset__legend--s" + } + }, + items: cardTypes.debitCards + }) + }} + + {{ + govukCheckboxes({ + idPrefix: "credit", + name: "credit", + classes: "with-border govuk-!-padding-top-3 pay-!-border-top", + fieldset: { + legend: { + text: "Credit cards", + classes: "govuk-fieldset__legend--s" + } + }, + items: cardTypes.creditCards + }) + }} + + {{ + govukButton({ + text: 'Save changes', + attributes: { + id: "save-card-types" + } + }) + }} +
diff --git a/app/views/simplified-account/settings/card-types/cardTypesList.njk b/app/views/simplified-account/settings/card-types/cardTypesList.njk new file mode 100644 index 000000000..c481b71df --- /dev/null +++ b/app/views/simplified-account/settings/card-types/cardTypesList.njk @@ -0,0 +1,21 @@ +{% for category, items in cardTypes %} + {% if (items.length > 0) %} +

+ {{ category }} +

+ {% set cardList = [] %} + {% for card in items %} + {% set cardListItem = { + key: { + text: card, + classes: "govuk-!-display-none" + }, + value: { + html: card + } + } %} + {% set cardList = (cardList.push(cardListItem), cardList) %} + {% endfor %} + {{ govukSummaryList({ rows: cardList }) }} + {% endif %} +{% endfor %} diff --git a/app/views/simplified-account/settings/card-types/index.njk b/app/views/simplified-account/settings/card-types/index.njk index 447667725..ab11152a3 100644 --- a/app/views/simplified-account/settings/card-types/index.njk +++ b/app/views/simplified-account/settings/card-types/index.njk @@ -5,4 +5,20 @@ {% endblock %} {% block settingsContent %} -{% endblock %} + {% if not isAdminUser %} + {{ govukInsetText({ + text: "You don’t have permission to manage settings. Contact your service admin if you would like to manage 3D Secure, + accepted card types, email notifications, billing address or mask card numbers or security codes for MOTO services.", + classes: "service-settings-inset-text--grey" + }) }} + {% endif %} + +

Card types

+ + {% if isAdminUser %} + {% include "./cardTypesCheckboxes.njk" %} + {% else %} + {% include "./cardTypesList.njk" %} + {% endif %} + +{% endblock %}