Skip to content

Commit

Permalink
Merge pull request #902 from recurly/tokenize-cvv
Browse files Browse the repository at this point in the history
feat: Add support for tokenizing the CVV standalone
  • Loading branch information
chrissrogers authored Oct 4, 2024
2 parents 59b76f6 + d1fa962 commit a52924d
Show file tree
Hide file tree
Showing 18 changed files with 283 additions and 181 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,6 @@ jobs:
- check
- unit_test
- unit_test_remote
- e2e_test
- integration_test
steps:
- uses: actions/checkout@v3
- uses: browser-actions/setup-chrome@v1
Expand Down
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 (Array.isArray(options.required)) {
this.config.required = uniq([...this.config.required, ...options.required]);
}

// Begin parent role configuration and setup
if (this.config.parent) {
Expand Down
1 change: 1 addition & 0 deletions lib/recurly/element/card-cvv.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export function factory (options) {
export class CardCvvElement extends Element {
static type = 'cvv';
static elementClassName = 'CardCvvElement';
static supportsTokenization = true;
}
3 changes: 2 additions & 1 deletion lib/recurly/elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export default class Elements extends Emitter {

static VALID_SETS = [
[ CardElement ],
[ CardNumberElement, CardMonthElement, CardYearElement, CardCvvElement ]
[ CardCvvElement ],
[ 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 });
}
}

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
11 changes: 9 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,9 +19,13 @@
}
// Stub broker behavior
if (config().type === 'number') {
window.addEventListener('message', receivePostMessage, false);
function setStubTokenizationElementName (name) {
window.stubTokenizationElementName = name;
if (config().type === name) {
window.addEventListener('message', receivePostMessage, false);
}
}
setStubTokenizationElementName('number');
sendMessage(prefix + ':ready', { type: config().type });
Expand All @@ -39,6 +43,9 @@
function onToken (body) {
var recurly = new parent.recurly.Recurly(getRecurlyConfig());
if (stubTokenizationElementName === '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'); -%>
3 changes: 2 additions & 1 deletion 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 Down Expand Up @@ -34,7 +35,7 @@ maybeDescribe('Display', () => {
it('matches distinct elements baseline', async function () {
const { CardElement, ...distinctElements } = ELEMENT_TYPES;
for (const element in distinctElements) {
await createElement(element, { style: { fontFamily: 'Pacifico' }});
await createElement(element, { style: { fontFamily: 'Pacifico' } });
}
await fillDistinctCardElements();
await clickFirstName();
Expand Down
100 changes: 46 additions & 54 deletions test/e2e/implementation.state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const {
environmentIs,
EXAMPLES,
fillCardElement,
fillDistinctCardElements,
fillElement,
init
} = require('./support/helpers');

Expand Down Expand Up @@ -68,6 +70,17 @@ describe('Field State', elementAndFieldSuite({
);
});
},
cvvElement: async () => {
it('displays field state on the page', async function () {
await setupElementsStateOutput();
await assertInputStateChange(() => fillElement(0, '.recurly-hosted-field-input', '123'), 0, {
empty: false,
length: 3,
focus: false,
valid: true
});
});
},
cardHostedField: async function () {
it('displays field state on the page', async function () {
// Skip Electron due to element blur incompatibility
Expand Down Expand Up @@ -115,6 +128,19 @@ describe('Field State', elementAndFieldSuite({
hostedFieldState({ number, month, year, cvv })
);
});
},
cvvHostedField: async () => {
it('displays field state on the page', async function () {
const cvv = {
empty: false,
length: 3,
focus: false,
valid: true,
};

await setupHostedFieldStateOutput();
await assertInputStateChange(() => fillElement(0, '.recurly-hosted-field-input', '123'), 0, { fields: { cvv } });
});
}
}));

Expand All @@ -141,6 +167,7 @@ async function setupHostedFieldStateOutput () {
}

async function assertCardBehavior ({ wrap = obj => obj } = {}) {
const FRAME = 0;
const expect = {
valid: false,
firstSix: '',
Expand All @@ -164,29 +191,9 @@ async function assertCardBehavior ({ wrap = obj => obj } = {}) {
valid: false
}
};
const expectation = (changes) => wrap(Object.assign({}, expect, changes));

const firstName = await $(sel.firstName);
const output = await $(sel.output);
const actual = async () => JSON.parse(await output.getText());
const assertStateOutputIs = async changes => {
assert.deepStrictEqual(
await actual(),
wrap(Object.assign({}, expect, changes))
);
};

// await browser.switchToFrame(0);
// const number = await $(sel.number);
// await number.setValue(EXAMPLES.NUMBER);
// await browser.waitUntil(async () => (await number.getValue()).length >= 19);
// await browser.switchToFrame(null);
await fillCardElement({
expiry: '',
cvv: ''
});
await firstName.click();

await assertStateOutputIs({
await assertInputStateChange(() => fillCardElement({ expiry: '', cvv: '' }), FRAME, expectation({
firstSix: '411111',
lastFour: '1111',
brand: 'visa',
Expand All @@ -197,14 +204,9 @@ async function assertCardBehavior ({ wrap = obj => obj } = {}) {
focus: false,
valid: true
}
});

await fillCardElement({
cvv: ''
});
await firstName.click();
}));

await assertStateOutputIs({
await assertInputStateChange(() => fillCardElement({ cvv: '' }), FRAME, expectation({
firstSix: '411111',
lastFour: '1111',
brand: 'visa',
Expand All @@ -220,15 +222,9 @@ async function assertCardBehavior ({ wrap = obj => obj } = {}) {
focus: false,
valid: true
}
});
}));

// await browser.switchToFrame(0);
// await (await $(sel.cvv)).addValue(EXAMPLES.CVV);
// await browser.switchToFrame(null);
await fillCardElement();
await firstName.click();

await assertStateOutputIs({
await assertInputStateChange(() => fillCardElement(), FRAME, expectation({
firstSix: '411111',
lastFour: '1111',
brand: 'visa',
Expand All @@ -250,7 +246,7 @@ async function assertCardBehavior ({ wrap = obj => obj } = {}) {
focus: false,
valid: true
}
});
}));
}

async function assertDistinctCardBehavior (...expectations) {
Expand All @@ -260,28 +256,24 @@ async function assertDistinctCardBehavior (...expectations) {
'28',
'123'
];
const firstName = await $(sel.firstName);
const output = await $(sel.output);
const actual = async () => JSON.parse(await output.getText());
const assertStateOutputIs = async expect => assert.deepStrictEqual(
await actual(),
expect
);

for (const entry of entries) {
const i = entries.indexOf(entry);
await browser.switchToFrame(i);
const input = await $('.recurly-hosted-field-input');
await input.addValue(entry);
if (environmentIs(BROWSERS.EDGE)) {
await browser.waitUntil(async () => (await input.getValue()).replace(/ /g, '') === entry);
}
await browser.switchToFrame(null);
await firstName.click();
await assertStateOutputIs(expectations[i]);
await assertInputStateChange(() => fillElement(i, '.recurly-hosted-field-input', entry), i, expectations[i]);
}
}

async function assertInputStateChange(example, frame, expectation) {
const blurTriggerEl = await $(sel.firstName);
const output = await $(sel.output);

await example();

await blurTriggerEl.click();
const actual = JSON.parse(await output.getText());
assert.deepStrictEqual(actual, expectation);
}

function hostedFieldState ({ number, month, year, cvv }) {
return {
fields: {
Expand Down
12 changes: 12 additions & 0 deletions test/e2e/recurly.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const {
assertIsAToken,
EXAMPLES,
getValue,
fillElement,
init,
recurlyEnvironment,
tokenize
Expand Down Expand Up @@ -100,6 +101,17 @@ describe('Recurly.js', async function () {
assertIsAToken(tokenWith);
});
});

describe('when using standalone cvv hosted field', async function () {
beforeEach(init({ fixture: 'hosted-fields-cvv' }));

it('creates a token', async function () {
await fillElement(0, sel.hostedFieldInput, EXAMPLES.CVV);
const [err, token] = await tokenize(sel.form);
assert.strictEqual(err, null);
assertIsAToken(token);
});
});
});

describe('Bacs bank account', async function () {
Expand Down
Loading

0 comments on commit a52924d

Please sign in to comment.