Skip to content

Commit

Permalink
feat: Add support for tokenizing the CVV standalone
Browse files Browse the repository at this point in the history
Introduces the `CvvElement` which can be used to tokenize the CVV by
itself for use in CIT where the merchant wants the customer to confirm
their CVV before checking out:

```js
const cvvElement = recurly.elements.CvvElement({});
cvvElement.attach(document.querySelector('#recurly-elements'));
```
  • Loading branch information
cbarton committed Oct 1, 2024
1 parent b1dd079 commit a0c681a
Show file tree
Hide file tree
Showing 27 changed files with 312 additions and 182 deletions.
6 changes: 5 additions & 1 deletion lib/recurly.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import deepAssign from './util/deep-assign';
import deepFilter from 'deep-filter';
import Emitter from 'component-emitter';
import pick from 'lodash.pick';
import uniq from 'array-unique';
import uid from './util/uid';
import errors from './recurly/errors';
import { bankAccount } from './recurly/bank-account';
Expand Down Expand Up @@ -79,6 +80,7 @@ const DEFAULTS = {
}
},
api: DEFAULT_API_URL,
required: ['number', 'month', 'year', 'first_name', 'last_name'],
fields: {
all: {
style: {}
Expand Down Expand Up @@ -286,7 +288,9 @@ export class Recurly extends Emitter {
deepAssign(this.config.fields, options.fields);
}

this.config.required = options.required || this.config.required || [];
if (typeof options.required === 'object') {
this.config.required = uniq([...this.config.required, ...options.required]);
}

// Begin parent role configuration and setup
if (this.config.parent) {
Expand Down
11 changes: 11 additions & 0 deletions lib/recurly/element/cvv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Element from './element';

export function factory (options) {
return new CvvElement({ ...options, elements: this });
}

export class CvvElement extends Element {
static type = 'cvv';
static elementClassName = 'CvvElement';
static supportsTokenization = true;
}
5 changes: 4 additions & 1 deletion lib/recurly/elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { factory as cardNumberElementFactory, CardNumberElement } from './elemen
import { factory as cardMonthElementFactory, CardMonthElement } from './element/card-month';
import { factory as cardYearElementFactory, CardYearElement } from './element/card-year';
import { factory as cardCvvElementFactory, CardCvvElement } from './element/card-cvv';
import { factory as cvvElementFactory, CvvElement } from './element/cvv';
import uid from '../util/uid';

const debug = require('debug')('recurly:elements');
Expand All @@ -29,10 +30,12 @@ export default class Elements extends Emitter {
CardMonthElement = cardMonthElementFactory;
CardYearElement = cardYearElementFactory;
CardCvvElement = cardCvvElementFactory;
CvvElement = cvvElementFactory;

static VALID_SETS = [
[ CardElement ],
[ CardNumberElement, CardMonthElement, CardYearElement, CardCvvElement ]
[ CvvElement ],
[ CardNumberElement, CardMonthElement, CardYearElement, CardCvvElement ],
];

constructor ({ recurly }) {
Expand Down
6 changes: 4 additions & 2 deletions lib/recurly/hosted-fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,12 @@ export class HostedFields extends Emitter {
}
});

// If we have a card hosted field, clear all missing target errors.
// If we have a card/cvv hosted field, clear all missing target errors.
const cardFieldMissingErrorPresent = this.errors.some(e => e.type === 'card');
if (cardFieldMissingErrorPresent) {
const onlyCvvFieldPresent = this.fields.length === 1 && this.fields[0].type === 'cvv';
if (cardFieldMissingErrorPresent && !onlyCvvFieldPresent) {
// If we are only missing the card field, clear the error
// If we only have a cvv field, clear the errors
const missingFieldErrors = this.errors.filter(e => e.name === 'missing-hosted-field-target');
if (missingFieldErrors.length === 1) {
this.errors = this.errors.filter(e => !(e.name === 'missing-hosted-field-target' && e.type === 'card'));
Expand Down
18 changes: 11 additions & 7 deletions lib/recurly/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,17 @@ function token (customerData, bus, done) {
}

const { number, month, year, cvv } = inputs;
Risk.preflight({ recurly: this, number, month, year, cvv })
.then(({ risk, tokenType }) => {
inputs.risk = risk;
if (tokenType) inputs.type = tokenType;
})
.then(() => this.request.post({ route: '/token', data: inputs, done: complete }))
.done();
if (number && month && year) {
Risk.preflight({ recurly: this, number, month, year, cvv })
.then(({ risk, tokenType }) => {
inputs.risk = risk;
if (tokenType) inputs.type = tokenType;
})
.then(() => this.request.post({ route: '/token', data: inputs, done: complete }))
.done();
} else {
this.request.post({ route: '/token', data: inputs, done: complete.bind(this) });
}
}

function complete (err, res) {
Expand Down
26 changes: 12 additions & 14 deletions lib/recurly/validate.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
/*jshint -W058 */

import { FIELDS as CARD_FIELDS } from './token';
import { FIELDS as ADDRESS_FIELDS } from './token';
import each from 'component-each';
import find from 'component-find';
import { parseCard } from '../util/parse-card';
import CREDIT_CARD_TYPES from '../const/credit-card-types.json';

const debug = require('debug')('recurly:validate');

const CARD_FIELDS = [
...ADDRESS_FIELDS,
'number',
'month',
'year',
'cvv',
];

/**
* Validation error messages
* @type {String}
Expand Down Expand Up @@ -197,25 +205,15 @@ export function validateCardInputs (recurly, inputs) {
const format = formatFieldValidationError;
let errors = [];

if (!cardNumber(inputs.number)) {
if (inputs.number && !cardNumber(inputs.number)) {
errors.push(format('number', INVALID));
}

if (!expiry(inputs.month, inputs.year)) {
if (inputs.month && inputs.year && !expiry(inputs.month, inputs.year)) {
errors.push(format('month', INVALID), format('year', INVALID));
}

if (!inputs.first_name) {
errors.push(format('first_name', BLANK));
}

if (!inputs.last_name) {
errors.push(format('last_name', BLANK));
}

if (~recurly.config.required.indexOf('cvv') && !inputs.cvv) {
errors.push(format('cvv', BLANK));
} else if ((~recurly.config.required.indexOf('cvv') || inputs.cvv) && !cvv(inputs.cvv)) {
if (inputs.cvv && !cvv(inputs.cvv)) {
errors.push(format('cvv', INVALID));
}

Expand Down
9 changes: 7 additions & 2 deletions packages/public-api-fixture-server/fixtures/field.html.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
}
// Stub broker behavior
if (config().type === 'number') {
var tokenizeElementType = config().recurly.fields.tokenizeElement || 'number';
if (config().type === tokenizeElementType) {
window.addEventListener('message', receivePostMessage, false);
}
Expand All @@ -38,7 +39,11 @@
// Event handlers
function onToken (body) {
var recurly = new parent.recurly.Recurly(getRecurlyConfig());
const recurlyConfig = getRecurlyConfig();
var recurly = new parent.recurly.Recurly(recurlyConfig);
if (recurlyConfig.fields.tokenizeElement === 'cvv') {
recurly.config.required = ['cvv'];
}
var inputs = body.inputs;
var id = body.id;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<%- include('_head'); -%>
<input type="text" data-test="first-name">
<div data-recurly="cvv"></div>
<%- include('_foot'); -%>
26 changes: 24 additions & 2 deletions test/e2e/display.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
environmentIs,
fillCardElement,
fillDistinctCardElements,
fillCvvElement,
init
} = require('./support/helpers');

Expand All @@ -32,16 +33,26 @@ maybeDescribe('Display', () => {

describe('distinct elements', async function () {
it('matches distinct elements baseline', async function () {
const { CardElement, ...distinctElements } = ELEMENT_TYPES;
const { CardElement, CvvElement, ...distinctElements } = ELEMENT_TYPES;
for (const element in distinctElements) {
await createElement(element, { style: { fontFamily: 'Pacifico' }});
await createElement(element, { style: { fontFamily: 'Pacifico' } });
}
await fillDistinctCardElements();
await clickFirstName();

assertVisualRegressionThreshold(await browser.checkElement(await $('.test-bed'), 'elements/distinct-elements'));
});
});

describe('CvvElement', async function () {
it('matches CvvElement baseline', async function () {
await createElement(ELEMENT_TYPES.CvvElement, { style: { fontFamily: 'Pacifico' } });
await fillCvvElement();
await clickFirstName();

assertVisualRegressionThreshold(await browser.checkElement(await $('.test-bed'), 'elements/cvv-element'));
});
});
});

const hostedFieldOpts = { fields: { all: { style: { fontFamily: 'Pacifico' } } } };
Expand All @@ -67,6 +78,17 @@ maybeDescribe('Display', () => {
assertVisualRegressionThreshold(await browser.checkElement(await $('.test-bed'), 'hosted-fields/distinct-fields'));
});
});

describe('when using a cvv Hosted Field', async function () {
beforeEach(init({ fixture: 'hosted-fields-cvv', opts: hostedFieldOpts }));

it('matches cvv Hosted Field baseline', async function () {
await fillCvvElement();
await clickFirstName();

assertVisualRegressionThreshold(await browser.checkElement(await $('.test-bed'), 'hosted-fields/cvv'));
});
});
});

function assertVisualRegressionThreshold (diff, threshold = 0.05) {
Expand Down
Loading

0 comments on commit a0c681a

Please sign in to comment.