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

feat: Add support for tokenizing the CVV standalone #902

Merged
merged 2 commits into from
Oct 4, 2024
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
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)) {
cbarton marked this conversation as resolved.
Show resolved Hide resolved
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
Loading