├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .zuul.yml
├── LICENSE
├── README.md
├── bower.json
├── package.json
├── release
└── angular-credit-cards.js
├── src
├── cvc.js
├── expiration.js
├── index.js
└── number.js
└── test
├── README.md
├── cvc.js
├── expiration.js
├── fixtures
└── cc-exp.html
└── number.js
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on:
3 | push:
4 | branches:
5 | - master
6 | pull_request:
7 | branches:
8 | - master
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v3
15 | - uses: actions/setup-node@v3
16 | with:
17 | node-version: '12.x'
18 | - run: npm install
19 | - run: npm test
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/.zuul.yml:
--------------------------------------------------------------------------------
1 | ui: mocha-bdd
2 | browserify:
3 | - transform: brfs
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Ben Drucker
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # angular-credit-cards
2 |
3 | A set of Angular directives for constructing credit card payment forms. Uses [creditcards](https://www.npmjs.org/package/creditcards) to parse and validate inputs. Pairs well with [angular-stripe](https://www.npmjs.org/package/angular-stripe) or any other payments backend. [Try it!](http://embed.plnkr.co/uE47aZ/preview)
4 |
5 | ## Installation
6 | ```bash
7 | # use npm
8 | $ npm install angular-credit-cards
9 | # or bower
10 | $ bower install angular-credit-cards
11 | ```
12 |
13 | ## Setup
14 |
15 | Include `'angular-credit-cards'` in your module's dependencies:
16 |
17 | ```js
18 | // node module exports the string 'angular-credit-cards' for convenience
19 | angular.module('myApp', [
20 | require('angular-credit-cards')
21 | ]);
22 | // otherwise, include the code first then the module name
23 | angular.module('myApp', [
24 | 'credit-cards'
25 | ]);
26 | ```
27 |
28 | If you'd like to use the [creditcards](https://www.npmjs.org/package/creditcards) API directly, you can inject the service as `creditcards`.
29 |
30 | ## API
31 |
32 | With the exception of `ccExp`, all directives require `ngModel` on their elements. While designed to be used together, all directives except `ccExp` can be used completely independently.
33 |
34 | All directives apply a [numeric input pattern](http://bradfrostweb.com/blog/mobile/better-numerical-inputs-for-mobile-forms/) so that mobile browsers use a modified version of the enlarged telephone keypad. You should use `type="text"` for all `input` elements.
35 |
36 |
37 |
38 | ### Card Number (`cc-number`)
39 |
40 | ```html
41 |
42 | ```
43 |
44 | * Can format your inputs into space-delimited groups (e.g. `4242 4242 4242 4242`) by adding the `cc-format` option
45 | * Strips all punctuation and spaces in the model
46 | * Validates the card against the [Luhn algorithm](http://en.wikipedia.org/wiki/Luhn_algorithm)
47 | * Checks whether the card is the type(s) specified in scope property in `cc-type` (optional)
48 | * Otherwise, checks whether the card matches any valid card type
49 | * Exposes the [card type](https://github.com/bendrucker/creditcards-types#card-types) as `$ccType` on the model controller
50 |
51 | If you're using `cc-format`, you'll want to apply the [`novalidate`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-novalidate) attribute to your `
63 |
64 | Looks like you're typing a {{paymentForm.cardNumber.$ccEagerType}} number!
65 |
66 |
67 | Yes, that looks like a valid {{paymentForm.cardNumber.$ccType}} number!
68 |
69 |
70 | You must enter a credit card number!
71 |
72 | ```
73 |
74 | Enforcing a specific card type chosen with a ``:
75 |
76 | ```html
77 |
82 | ```
83 |
84 |
85 |
86 | ### CVC (`cc-cvc`)
87 |
88 | ```html
89 |
90 |
91 | ```
92 |
93 | * Sets `maxlength="4"`
94 | * Validates the CVC
95 |
96 | You can optionally specify a scope property that stores the card type as `cc-type`. For American Express cards, a 4 digit CVC is expected. For all other card types, 3 digits are expected.
97 |
98 |
99 |
100 | ### Expiration (`cc-exp`, `cc-exp-month`, `cc-exp-year`)
101 |
102 | ```html
103 |
104 |
105 |
106 |
107 | ```
108 |
109 | #### `cc-exp-month`
110 |
111 | * Sets `maxlength="2"`
112 | * Validates the month
113 | * Converts it to a number
114 |
115 | #### `cc-exp-year`
116 |
117 | * Sets `maxlength="2"` (or `4` with the `full-year` attribute)
118 | * Converts the year to a 4 digit number (`'14'` -> `2014`), unless `full-year` is added
119 | * Validates the year
120 | * Validates that the expiration year has not passed
121 |
122 | #### `cc-exp`
123 |
124 | Validates that the month/year pair has not passed
125 |
126 | `cc-exp-month` and `cc-exp-year` should both be placed on `input` elements with `type="text"` or no `type` attribute. The browser's normal maxlength behavior (preventing input after the specified number of characters and truncating pasted text to that length) does not work with `type="number"`. Both directives will handle parsing the date components into numbers internally.
127 |
128 | `cc-exp` must be placed on a parent element of `cc-exp-month` and `cc-exp-year`. Because `ccExp` is not an input and adds a validation property directly to the form, you cannot access its validity as `myForm.ccExp.$valid`. Instead use `myForm.$error.ccExp` to determine whether to show a validation error.
129 |
130 |
131 |
132 | ## Integration
133 |
134 | If you're not fully familiar with form validation in Angular, these may be helpful:
135 | * [Angular Documentation: Forms](https://docs.angularjs.org/guide/forms)
136 | * [Angular Form Validation (Scotch.io)](http://scotch.io/tutorials/javascript/angularjs-form-validation)
137 | * [Form validation with AngularJS (ng-newsletter)](http://www.ng-newsletter.com/posts/validations.html)
138 |
139 | angular-credit-cards sets validity keys that match the directive names (`ccNumber`, `ccCvc`, `ccExp`, `ccExpMonth`, `ccExpYear`). You can use these keys or the form css classes in order to display error messages. If input is unparseable (letters, empty string), Angular will set a `parse` key before validation is reached.
140 |
141 | You can also try a [live demo](http://embed.plnkr.co/uE47aZ/preview) and experiment with various inputs and see how they're validated.
142 |
143 | ## License
144 |
145 | [MIT](LICENSE)
146 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-credit-cards",
3 | "homepage": "https://github.com/bendrucker/angular-credit-cards",
4 | "authors": [
5 | "Ben Drucker "
6 | ],
7 | "description": "Angular directives for formatting and validating credit card inputs",
8 | "main": "./release/angular-credit-cards.js",
9 | "moduleType": [
10 | "node"
11 | ],
12 | "keywords": [
13 | "angular",
14 | "credit",
15 | "card",
16 | "payments",
17 | "validation",
18 | "directive",
19 | "form"
20 | ],
21 | "license": "MIT",
22 | "ignore": [
23 | "**/.*",
24 | "node_modules",
25 | "bower_components",
26 | "components",
27 | "test",
28 | "tests"
29 | ],
30 | "dependencies": {
31 | "angular": ">=1.3 <1.7"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-credit-cards",
3 | "version": "3.1.9",
4 | "description": "Angular directives for formatting and validating credit card inputs",
5 | "main": "./src",
6 | "scripts": {
7 | "test": "standard && zuul --phantom -- test/*.js",
8 | "umd": "browserify -e . -s angularCreditCards -t [exposify --expose [ --angular=angular ] ] -p derequire/plugin > ./release/angular-credit-cards.js",
9 | "preversion": "npm run umd && git add -A ./release/* && git commit -m 'UMD Build'"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "https://github.com/bendrucker/angular-credit-cards.git"
14 | },
15 | "keywords": [
16 | "angular",
17 | "credit",
18 | "card",
19 | "payments",
20 | "validation",
21 | "directive",
22 | "form"
23 | ],
24 | "author": "Ben Drucker (http://www.bendrucker.me/)",
25 | "license": "MIT",
26 | "bugs": {
27 | "url": "https://github.com/bendrucker/angular-credit-cards/issues"
28 | },
29 | "homepage": "https://github.com/bendrucker/angular-credit-cards",
30 | "devDependencies": {
31 | "angular": "~1.8.0",
32 | "angular-mocks": "~1.6.0",
33 | "brfs": "^1.4.0",
34 | "browserify": "^14.0.0",
35 | "chai": "^3.0.0",
36 | "derequire": "^2.0.0",
37 | "exposify": "^0.5.0",
38 | "phantomjs-prebuilt": "~2.1.4",
39 | "sinon": "^1.15.3",
40 | "sinon-chai": "^2.8.0",
41 | "standard": "^8.0.0",
42 | "zuul": "defunctzombie/zuul#075444eb80c4e961341ad9c2e8f7a267fbd7d211"
43 | },
44 | "dependencies": {
45 | "ap": "~0.2.0",
46 | "cast-array": "~1.0.1",
47 | "creditcards": "~2.1.0",
48 | "function-bind": "~1.1.0"
49 | },
50 | "peerDependencies": {
51 | "angular": ">=1.3 <2"
52 | },
53 | "files": [
54 | "src/*.js",
55 | "readme.md"
56 | ],
57 | "standard": {
58 | "ignore": [
59 | "release/"
60 | ]
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/release/angular-credit-cards.js:
--------------------------------------------------------------------------------
1 | (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.angularCreditCards = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i= new Date(year, month)
255 | }
256 |
257 | function parseMonth (month) {
258 | return parseIntStrict(month)
259 | }
260 |
261 | function formatExpYear (year, strip) {
262 | year = year.toString()
263 | return strip ? year.substr(2, 4) : year
264 | }
265 |
266 | function isExpYearValid (year) {
267 | if (typeof year !== 'number') return false
268 | year = parseIntStrict(year)
269 | return year > 0
270 | }
271 |
272 | function isExpYearPast (year) {
273 | return new Date().getFullYear() > year
274 | }
275 |
276 | },{"is-valid-month":17,"parse-int":19,"parse-year":20}],9:[function(_dereq_,module,exports){
277 | 'use strict'
278 |
279 | module.exports = {
280 | card: _dereq_('./card'),
281 | cvc: _dereq_('./cvc'),
282 | expiration: _dereq_('./expiration')
283 | }
284 |
285 | },{"./card":6,"./cvc":7,"./expiration":8}],10:[function(_dereq_,module,exports){
286 | 'use strict'
287 |
288 | var ccTypes = _dereq_('creditcards-types')
289 | var camel = _dereq_('to-camel-case')
290 | var extend = _dereq_('xtend')
291 |
292 | module.exports = extend(ccTypes, {
293 | get: function getTypeByName (name) {
294 | return ccTypes.types[camel(name)]
295 | }
296 | })
297 |
298 | },{"creditcards-types":3,"to-camel-case":21,"xtend":24}],11:[function(_dereq_,module,exports){
299 | 'use strict'
300 |
301 | var zeroFill = _dereq_('zero-fill')
302 | var parseIntStrict = _dereq_('parse-int')
303 |
304 | var pad = zeroFill(2)
305 |
306 | module.exports = function expandYear (year, now) {
307 | now = now || new Date()
308 | var base = now.getFullYear().toString().substr(0, 2)
309 | year = parseIntStrict(year)
310 | return parseIntStrict(base + pad(year))
311 | }
312 |
313 | },{"parse-int":19,"zero-fill":26}],12:[function(_dereq_,module,exports){
314 | 'use strict'
315 |
316 | module.exports = (function (array) {
317 | return function luhn (number) {
318 | if (typeof number !== 'string') throw new TypeError('Expected string input')
319 | if (!number) return false
320 | var length = number.length
321 | var bit = 1
322 | var sum = 0
323 | var value
324 |
325 | while (length) {
326 | value = parseInt(number.charAt(--length), 10)
327 | bit ^= 1
328 | sum += bit ? array[value] : value
329 | }
330 |
331 | return sum % 10 === 0
332 | }
333 | }([0, 2, 4, 6, 8, 1, 3, 5, 7, 9]))
334 |
335 | },{}],13:[function(_dereq_,module,exports){
336 | 'use strict';
337 |
338 | /* eslint no-invalid-this: 1 */
339 |
340 | var ERROR_MESSAGE = 'Function.prototype.bind called on incompatible ';
341 | var slice = Array.prototype.slice;
342 | var toStr = Object.prototype.toString;
343 | var funcType = '[object Function]';
344 |
345 | module.exports = function bind(that) {
346 | var target = this;
347 | if (typeof target !== 'function' || toStr.call(target) !== funcType) {
348 | throw new TypeError(ERROR_MESSAGE + target);
349 | }
350 | var args = slice.call(arguments, 1);
351 |
352 | var bound;
353 | var binder = function () {
354 | if (this instanceof bound) {
355 | var result = target.apply(
356 | this,
357 | args.concat(slice.call(arguments))
358 | );
359 | if (Object(result) === result) {
360 | return result;
361 | }
362 | return this;
363 | } else {
364 | return target.apply(
365 | that,
366 | args.concat(slice.call(arguments))
367 | );
368 | }
369 | };
370 |
371 | var boundLength = Math.max(0, target.length - args.length);
372 | var boundArgs = [];
373 | for (var i = 0; i < boundLength; i++) {
374 | boundArgs.push('$' + i);
375 | }
376 |
377 | bound = Function('binder', 'return function (' + boundArgs.join(',') + '){ return binder.apply(this,arguments); }')(binder);
378 |
379 | if (target.prototype) {
380 | var Empty = function Empty() {};
381 | Empty.prototype = target.prototype;
382 | bound.prototype = new Empty();
383 | Empty.prototype = null;
384 | }
385 |
386 | return bound;
387 | };
388 |
389 | },{}],14:[function(_dereq_,module,exports){
390 | 'use strict';
391 |
392 | var implementation = _dereq_('./implementation');
393 |
394 | module.exports = Function.prototype.bind || implementation;
395 |
396 | },{"./implementation":13}],15:[function(_dereq_,module,exports){
397 | 'use strict';
398 |
399 | module.exports = Number.isFinite || function (value) {
400 | return !(typeof value !== 'number' || value !== value || value === Infinity || value === -Infinity);
401 | };
402 |
403 | },{}],16:[function(_dereq_,module,exports){
404 | // https://github.com/paulmillr/es6-shim
405 | // http://people.mozilla.org/~jorendorff/es6-draft.html#sec-number.isinteger
406 | var isFinite = _dereq_("is-finite");
407 | module.exports = Number.isInteger || function(val) {
408 | return typeof val === "number" &&
409 | isFinite(val) &&
410 | Math.floor(val) === val;
411 | };
412 |
413 | },{"is-finite":15}],17:[function(_dereq_,module,exports){
414 | 'use strict'
415 |
416 | var isInteger = _dereq_('is-integer')
417 |
418 | module.exports = function isValidMonth (month) {
419 | if (typeof month !== 'number' || !isInteger(month)) return false
420 | return month >= 1 && month <= 12
421 | }
422 |
423 | },{"is-integer":16}],18:[function(_dereq_,module,exports){
424 | module.exports = Array.isArray || function (arr) {
425 | return Object.prototype.toString.call(arr) == '[object Array]';
426 | };
427 |
428 | },{}],19:[function(_dereq_,module,exports){
429 | 'use strict'
430 |
431 | var isInteger = _dereq_('is-integer')
432 | var isIntegerRegex = /^-?\d+$/
433 |
434 | module.exports = function parseIntStrict (integer) {
435 | if (typeof integer === 'number') {
436 | return isInteger(integer) ? integer : undefined
437 | }
438 | if (typeof integer === 'string') {
439 | return isIntegerRegex.test(integer) ? parseInt(integer, 10) : undefined
440 | }
441 | }
442 |
443 | },{"is-integer":16}],20:[function(_dereq_,module,exports){
444 | 'use strict'
445 |
446 | var parseIntStrict = _dereq_('parse-int')
447 | var expandYear = _dereq_('expand-year')
448 |
449 | module.exports = function parseYear (year, expand, now) {
450 | year = parseIntStrict(year)
451 | if (year == null) return
452 | if (!expand) return year
453 | return expandYear(year, now)
454 | }
455 |
456 | },{"expand-year":11,"parse-int":19}],21:[function(_dereq_,module,exports){
457 |
458 | var space = _dereq_('to-space-case')
459 |
460 | /**
461 | * Export.
462 | */
463 |
464 | module.exports = toCamelCase
465 |
466 | /**
467 | * Convert a `string` to camel case.
468 | *
469 | * @param {String} string
470 | * @return {String}
471 | */
472 |
473 | function toCamelCase(string) {
474 | return space(string).replace(/\s(\w)/g, function (matches, letter) {
475 | return letter.toUpperCase()
476 | })
477 | }
478 |
479 | },{"to-space-case":23}],22:[function(_dereq_,module,exports){
480 |
481 | /**
482 | * Export.
483 | */
484 |
485 | module.exports = toNoCase
486 |
487 | /**
488 | * Test whether a string is camel-case.
489 | */
490 |
491 | var hasSpace = /\s/
492 | var hasSeparator = /(_|-|\.|:)/
493 | var hasCamel = /([a-z][A-Z]|[A-Z][a-z])/
494 |
495 | /**
496 | * Remove any starting case from a `string`, like camel or snake, but keep
497 | * spaces and punctuation that may be important otherwise.
498 | *
499 | * @param {String} string
500 | * @return {String}
501 | */
502 |
503 | function toNoCase(string) {
504 | if (hasSpace.test(string)) return string.toLowerCase()
505 | if (hasSeparator.test(string)) return (unseparate(string) || string).toLowerCase()
506 | if (hasCamel.test(string)) return uncamelize(string).toLowerCase()
507 | return string.toLowerCase()
508 | }
509 |
510 | /**
511 | * Separator splitter.
512 | */
513 |
514 | var separatorSplitter = /[\W_]+(.|$)/g
515 |
516 | /**
517 | * Un-separate a `string`.
518 | *
519 | * @param {String} string
520 | * @return {String}
521 | */
522 |
523 | function unseparate(string) {
524 | return string.replace(separatorSplitter, function (m, next) {
525 | return next ? ' ' + next : ''
526 | })
527 | }
528 |
529 | /**
530 | * Camelcase splitter.
531 | */
532 |
533 | var camelSplitter = /(.)([A-Z]+)/g
534 |
535 | /**
536 | * Un-camelcase a `string`.
537 | *
538 | * @param {String} string
539 | * @return {String}
540 | */
541 |
542 | function uncamelize(string) {
543 | return string.replace(camelSplitter, function (m, previous, uppers) {
544 | return previous + ' ' + uppers.toLowerCase().split('').join(' ')
545 | })
546 | }
547 |
548 | },{}],23:[function(_dereq_,module,exports){
549 |
550 | var clean = _dereq_('to-no-case')
551 |
552 | /**
553 | * Export.
554 | */
555 |
556 | module.exports = toSpaceCase
557 |
558 | /**
559 | * Convert a `string` to space case.
560 | *
561 | * @param {String} string
562 | * @return {String}
563 | */
564 |
565 | function toSpaceCase(string) {
566 | return clean(string).replace(/[\W_]+(.|$)/g, function (matches, match) {
567 | return match ? ' ' + match : ''
568 | }).trim()
569 | }
570 |
571 | },{"to-no-case":22}],24:[function(_dereq_,module,exports){
572 | module.exports = extend
573 |
574 | var hasOwnProperty = Object.prototype.hasOwnProperty;
575 |
576 | function extend() {
577 | var target = {}
578 |
579 | for (var i = 0; i < arguments.length; i++) {
580 | var source = arguments[i]
581 |
582 | for (var key in source) {
583 | if (hasOwnProperty.call(source, key)) {
584 | target[key] = source[key]
585 | }
586 | }
587 | }
588 |
589 | return target
590 | }
591 |
592 | },{}],25:[function(_dereq_,module,exports){
593 | module.exports = extend
594 |
595 | var hasOwnProperty = Object.prototype.hasOwnProperty;
596 |
597 | function extend(target) {
598 | for (var i = 1; i < arguments.length; i++) {
599 | var source = arguments[i]
600 |
601 | for (var key in source) {
602 | if (hasOwnProperty.call(source, key)) {
603 | target[key] = source[key]
604 | }
605 | }
606 | }
607 |
608 | return target
609 | }
610 |
611 | },{}],26:[function(_dereq_,module,exports){
612 | /*! zero-fill. MIT License. Feross Aboukhadijeh */
613 | /**
614 | * Given a number, return a zero-filled string.
615 | * From http://stackoverflow.com/questions/1267283/
616 | * @param {number} width
617 | * @param {number} number
618 | * @return {string}
619 | */
620 | module.exports = function zeroFill (width, number, pad) {
621 | if (number === undefined) {
622 | return function (number, pad) {
623 | return zeroFill(width, number, pad)
624 | }
625 | }
626 | if (pad === undefined) pad = '0'
627 | width -= number.toString().length
628 | if (width > 0) return new Array(width + (/\./.test(number) ? 2 : 1)).join(pad) + number
629 | return number + ''
630 | }
631 |
632 | },{}],27:[function(_dereq_,module,exports){
633 | 'use strict'
634 |
635 | var cvc = _dereq_('creditcards').cvc
636 | var bind = _dereq_('function-bind')
637 |
638 | module.exports = factory
639 |
640 | factory.$inject = ['$parse']
641 | function factory ($parse) {
642 | return {
643 | restrict: 'A',
644 | require: 'ngModel',
645 | compile: function (element, attributes) {
646 | attributes.$set('maxlength', 4)
647 | attributes.$set('pattern', '[0-9]*')
648 | attributes.$set('xAutocompletetype', 'cc-csc')
649 |
650 | return function (scope, element, attributes, ngModel) {
651 | ngModel.$validators.ccCvc = function (value) {
652 | return ngModel.$isEmpty(ngModel.$viewValue) || cvc.isValid(value, $parse(attributes.ccType)(scope))
653 | }
654 |
655 | if (attributes.ccType) {
656 | scope.$watch(attributes.ccType, bind.call(ngModel.$validate, ngModel))
657 | }
658 | }
659 | }
660 | }
661 | }
662 |
663 | },{"creditcards":9,"function-bind":14}],28:[function(_dereq_,module,exports){
664 | 'use strict'
665 |
666 | var expiration = _dereq_('creditcards').expiration
667 | var month = expiration.month
668 | var year = expiration.year
669 | var ap = _dereq_('ap')
670 |
671 | exports = module.exports = function ccExp () {
672 | return {
673 | restrict: 'AE',
674 | require: 'ccExp',
675 | controller: CcExpController,
676 | link: function (scope, element, attributes, ccExp) {
677 | ccExp.$watch()
678 | }
679 | }
680 | }
681 |
682 | CcExpController.$inject = ['$scope', '$element']
683 | function CcExpController ($scope, $element) {
684 | var nullFormCtrl = {
685 | $setValidity: noop
686 | }
687 | var parentForm = $element.inheritedData('$formController') || nullFormCtrl
688 | var ngModel = {
689 | year: {},
690 | month: {}
691 | }
692 |
693 | this.setMonth = function (monthCtrl) {
694 | ngModel.month = monthCtrl
695 | }
696 | this.setYear = function (yearCtrl) {
697 | ngModel.year = yearCtrl
698 | }
699 |
700 | function setValidity (exp) {
701 | var expMonth = exp.month
702 | var expYear = exp.year
703 | var valid = (expMonth == null && expYear == null) || !!expMonth && !!expYear && !expiration.isPast(expMonth, expYear)
704 | parentForm.$setValidity('ccExp', valid, $element)
705 | }
706 |
707 | this.$watch = function $watchExp () {
708 | $scope.$watch(function watchExp () {
709 | return {
710 | month: ngModel.month.$modelValue,
711 | year: ngModel.year.$modelValue
712 | }
713 | }, setValidity, true)
714 | }
715 | }
716 |
717 | var nullCcExp = {
718 | setMonth: noop,
719 | setYear: noop
720 | }
721 |
722 | exports.month = function ccExpMonth () {
723 | return {
724 | restrict: 'A',
725 | require: ['ngModel', '^?ccExp'],
726 | compile: function (element, attributes) {
727 | attributes.$set('maxlength', 2)
728 | attributes.$set('pattern', '[0-9]*')
729 | attributes.$set('xAutocompletetype', 'cc-exp-month')
730 |
731 | return function (scope, element, attributes, controllers) {
732 | var ngModel = controllers[0]
733 | var ccExp = controllers[1] || nullCcExp
734 |
735 | ccExp.setMonth(ngModel)
736 | ngModel.$parsers.unshift(month.parse)
737 | ngModel.$validators.ccExpMonth = function validateExpMonth (value) {
738 | return ngModel.$isEmpty(ngModel.$viewValue) || month.isValid(value)
739 | }
740 | }
741 | }
742 | }
743 | }
744 |
745 | exports.year = function ccExpYear () {
746 | return {
747 | restrict: 'A',
748 | require: ['ngModel', '^?ccExp'],
749 | compile: function (element, attributes) {
750 | var fullYear = attributes.fullYear !== undefined
751 |
752 | attributes.$set('maxlength', fullYear ? 4 : 2)
753 | attributes.$set('pattern', '[0-9]*')
754 | attributes.$set('xAutocompletetype', 'cc-exp-year')
755 |
756 | return function (scope, element, attributes, controllers) {
757 | var ngModel = controllers[0]
758 | var ccExp = controllers[1] || nullCcExp
759 |
760 | ccExp.setYear(ngModel)
761 |
762 | ngModel.$parsers.unshift(ap.partialRight(year.parse, !fullYear))
763 |
764 | ngModel.$formatters.unshift(function formatExpYear (value) {
765 | return value ? year.format(value, !fullYear) : ''
766 | })
767 |
768 | ngModel.$validators.ccExpYear = function validateExpYear (value) {
769 | return ngModel.$isEmpty(ngModel.$viewValue) || (year.isValid(value) && !year.isPast(value))
770 | }
771 | }
772 | }
773 | }
774 | }
775 |
776 | function noop () {}
777 |
778 | },{"ap":1,"creditcards":9}],29:[function(_dereq_,module,exports){
779 | 'use strict'
780 |
781 | var card = _dereq_('creditcards').card
782 | var array = _dereq_('cast-array')
783 | var partial = _dereq_('ap').partial
784 |
785 | module.exports = factory
786 |
787 | factory.$inject = ['$parse', '$timeout']
788 | function factory ($parse, $timeout) {
789 | return {
790 | restrict: 'A',
791 | require: ['ngModel', 'ccNumber'],
792 | controller: function () {
793 | this.type = null
794 | this.eagerType = null
795 | },
796 | compile: function ($element, $attributes) {
797 | $attributes.$set('pattern', '[0-9]*')
798 | $attributes.$set('xAutocompletetype', 'cc-number')
799 |
800 | return function ($scope, $element, $attributes, controllers) {
801 | var ngModel = controllers[0]
802 | var ccNumber = controllers[1]
803 |
804 | $scope.$watch($attributes.ngModel, function (number) {
805 | ngModel.$ccType = ccNumber.type = card.type(number)
806 | })
807 |
808 | function $viewValue () {
809 | return ngModel.$viewValue
810 | }
811 |
812 | function setCursorPostion (element, position) {
813 | $timeout(function () {
814 | if (element.setSelectionRange) {
815 | element.setSelectionRange(position, position)
816 | } else if (element.createTextRange) {
817 | var range = element.createTextRange()
818 | range.move('character', position)
819 | range.select()
820 | }
821 | }, 0)
822 | }
823 |
824 | if ($attributes.ccEagerType != null) {
825 | $scope.$watch($viewValue, function eagerTypeCheck (number) {
826 | number = card.parse(number)
827 | ngModel.$ccEagerType = ccNumber.eagerType = card.type(number, true)
828 | })
829 | }
830 |
831 | if ($attributes.ccType) {
832 | $scope.$watch($attributes.ccType, function () {
833 | ngModel.$validate()
834 | })
835 | }
836 |
837 | if ($attributes.ccFormat != null) {
838 | ngModel.$formatters.unshift(card.format)
839 | $element.on('input', function formatInput () {
840 | var input = $element.val()
841 | if (!input) return
842 | var element = $element[0]
843 | var formatted = card.format(card.parse(input))
844 |
845 | var selectionEnd = element.selectionEnd
846 | ngModel.$setViewValue(formatted)
847 | ngModel.$render()
848 |
849 | if (selectionEnd === input.length && input.length < formatted.length) {
850 | selectionEnd = formatted.length
851 | }
852 | setCursorPostion(element, selectionEnd)
853 | })
854 | }
855 |
856 | ngModel.$parsers.unshift(card.parse)
857 |
858 | ngModel.$validators.ccNumber = function validateCcNumber (number) {
859 | return ngModel.$isEmpty(ngModel.$viewValue) || card.isValid(number)
860 | }
861 |
862 | ngModel.$validators.ccNumberType = function validateCcNumberType (number) {
863 | if (ngModel.$isEmpty(ngModel.$viewValue)) return true
864 | var type = $parse($attributes.ccType)($scope)
865 | if (!type) card.isValid(number)
866 | return array(type).some(partial(card.isValid, number))
867 | }
868 | }
869 | }
870 | }
871 | }
872 |
873 | },{"ap":1,"cast-array":2,"creditcards":9}],30:[function(_dereq_,module,exports){
874 | (function (global){(function (){
875 | 'use strict'
876 |
877 | var angular = (typeof window !== "undefined" ? window['angular'] : typeof global !== "undefined" ? global['angular'] : null)
878 | var creditcards = _dereq_('creditcards')
879 | var number = _dereq_('./number')
880 | var cvc = _dereq_('./cvc')
881 | var expiration = _dereq_('./expiration')
882 |
883 | module.exports = angular
884 | .module('credit-cards', [])
885 | .value('creditcards', creditcards)
886 | .directive('ccNumber', number)
887 | .directive('ccExp', expiration)
888 | .directive('ccExpMonth', expiration.month)
889 | .directive('ccExpYear', expiration.year)
890 | .directive('ccCvc', cvc)
891 | .name
892 |
893 | }).call(this)}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
894 | },{"./cvc":27,"./expiration":28,"./number":29,"creditcards":9}]},{},[30])(30)
895 | });
896 |
--------------------------------------------------------------------------------
/src/cvc.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | var cvc = require('creditcards').cvc
4 | var bind = require('function-bind')
5 |
6 | module.exports = factory
7 |
8 | factory.$inject = ['$parse']
9 | function factory ($parse) {
10 | return {
11 | restrict: 'A',
12 | require: 'ngModel',
13 | compile: function (element, attributes) {
14 | attributes.$set('maxlength', 4)
15 | attributes.$set('pattern', '[0-9]*')
16 | attributes.$set('xAutocompletetype', 'cc-csc')
17 |
18 | return function (scope, element, attributes, ngModel) {
19 | ngModel.$validators.ccCvc = function (value) {
20 | return ngModel.$isEmpty(ngModel.$viewValue) || cvc.isValid(value, $parse(attributes.ccType)(scope))
21 | }
22 |
23 | if (attributes.ccType) {
24 | scope.$watch(attributes.ccType, bind.call(ngModel.$validate, ngModel))
25 | }
26 | }
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/expiration.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | var expiration = require('creditcards').expiration
4 | var month = expiration.month
5 | var year = expiration.year
6 | var ap = require('ap')
7 |
8 | exports = module.exports = function ccExp () {
9 | return {
10 | restrict: 'AE',
11 | require: 'ccExp',
12 | controller: CcExpController,
13 | link: function (scope, element, attributes, ccExp) {
14 | ccExp.$watch()
15 | }
16 | }
17 | }
18 |
19 | CcExpController.$inject = ['$scope', '$element']
20 | function CcExpController ($scope, $element) {
21 | var nullFormCtrl = {
22 | $setValidity: noop
23 | }
24 | var parentForm = $element.inheritedData('$formController') || nullFormCtrl
25 | var ngModel = {
26 | year: {},
27 | month: {}
28 | }
29 |
30 | this.setMonth = function (monthCtrl) {
31 | ngModel.month = monthCtrl
32 | }
33 | this.setYear = function (yearCtrl) {
34 | ngModel.year = yearCtrl
35 | }
36 |
37 | function setValidity (exp) {
38 | var expMonth = exp.month
39 | var expYear = exp.year
40 | var valid = (expMonth == null && expYear == null) || !!expMonth && !!expYear && !expiration.isPast(expMonth, expYear)
41 | parentForm.$setValidity('ccExp', valid, $element)
42 | }
43 |
44 | this.$watch = function $watchExp () {
45 | $scope.$watch(function watchExp () {
46 | return {
47 | month: ngModel.month.$modelValue,
48 | year: ngModel.year.$modelValue
49 | }
50 | }, setValidity, true)
51 | }
52 | }
53 |
54 | var nullCcExp = {
55 | setMonth: noop,
56 | setYear: noop
57 | }
58 |
59 | exports.month = function ccExpMonth () {
60 | return {
61 | restrict: 'A',
62 | require: ['ngModel', '^?ccExp'],
63 | compile: function (element, attributes) {
64 | attributes.$set('maxlength', 2)
65 | attributes.$set('pattern', '[0-9]*')
66 | attributes.$set('xAutocompletetype', 'cc-exp-month')
67 |
68 | return function (scope, element, attributes, controllers) {
69 | var ngModel = controllers[0]
70 | var ccExp = controllers[1] || nullCcExp
71 |
72 | ccExp.setMonth(ngModel)
73 | ngModel.$parsers.unshift(month.parse)
74 | ngModel.$validators.ccExpMonth = function validateExpMonth (value) {
75 | return ngModel.$isEmpty(ngModel.$viewValue) || month.isValid(value)
76 | }
77 | }
78 | }
79 | }
80 | }
81 |
82 | exports.year = function ccExpYear () {
83 | return {
84 | restrict: 'A',
85 | require: ['ngModel', '^?ccExp'],
86 | compile: function (element, attributes) {
87 | var fullYear = attributes.fullYear !== undefined
88 |
89 | attributes.$set('maxlength', fullYear ? 4 : 2)
90 | attributes.$set('pattern', '[0-9]*')
91 | attributes.$set('xAutocompletetype', 'cc-exp-year')
92 |
93 | return function (scope, element, attributes, controllers) {
94 | var ngModel = controllers[0]
95 | var ccExp = controllers[1] || nullCcExp
96 |
97 | ccExp.setYear(ngModel)
98 |
99 | ngModel.$parsers.unshift(ap.partialRight(year.parse, !fullYear))
100 |
101 | ngModel.$formatters.unshift(function formatExpYear (value) {
102 | return value ? year.format(value, !fullYear) : ''
103 | })
104 |
105 | ngModel.$validators.ccExpYear = function validateExpYear (value) {
106 | return ngModel.$isEmpty(ngModel.$viewValue) || (year.isValid(value) && !year.isPast(value))
107 | }
108 | }
109 | }
110 | }
111 | }
112 |
113 | function noop () {}
114 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | var angular = require('angular')
4 | var creditcards = require('creditcards')
5 | var number = require('./number')
6 | var cvc = require('./cvc')
7 | var expiration = require('./expiration')
8 |
9 | module.exports = angular
10 | .module('credit-cards', [])
11 | .value('creditcards', creditcards)
12 | .directive('ccNumber', number)
13 | .directive('ccExp', expiration)
14 | .directive('ccExpMonth', expiration.month)
15 | .directive('ccExpYear', expiration.year)
16 | .directive('ccCvc', cvc)
17 | .name
18 |
--------------------------------------------------------------------------------
/src/number.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | var card = require('creditcards').card
4 | var array = require('cast-array')
5 | var partial = require('ap').partial
6 |
7 | module.exports = factory
8 |
9 | factory.$inject = ['$parse', '$timeout']
10 | function factory ($parse, $timeout) {
11 | return {
12 | restrict: 'A',
13 | require: ['ngModel', 'ccNumber'],
14 | controller: function () {
15 | this.type = null
16 | this.eagerType = null
17 | },
18 | compile: function ($element, $attributes) {
19 | $attributes.$set('pattern', '[0-9]*')
20 | $attributes.$set('xAutocompletetype', 'cc-number')
21 |
22 | return function ($scope, $element, $attributes, controllers) {
23 | var ngModel = controllers[0]
24 | var ccNumber = controllers[1]
25 |
26 | $scope.$watch($attributes.ngModel, function (number) {
27 | ngModel.$ccType = ccNumber.type = card.type(number)
28 | })
29 |
30 | function $viewValue () {
31 | return ngModel.$viewValue
32 | }
33 |
34 | function setCursorPostion (element, position) {
35 | $timeout(function () {
36 | if (element.setSelectionRange) {
37 | element.setSelectionRange(position, position)
38 | } else if (element.createTextRange) {
39 | var range = element.createTextRange()
40 | range.move('character', position)
41 | range.select()
42 | }
43 | }, 0)
44 | }
45 |
46 | if ($attributes.ccEagerType != null) {
47 | $scope.$watch($viewValue, function eagerTypeCheck (number) {
48 | number = card.parse(number)
49 | ngModel.$ccEagerType = ccNumber.eagerType = card.type(number, true)
50 | })
51 | }
52 |
53 | if ($attributes.ccType) {
54 | $scope.$watch($attributes.ccType, function () {
55 | ngModel.$validate()
56 | })
57 | }
58 |
59 | if ($attributes.ccFormat != null) {
60 | ngModel.$formatters.unshift(card.format)
61 | $element.on('input', function formatInput () {
62 | var input = $element.val()
63 | if (!input) return
64 | var element = $element[0]
65 | var formatted = card.format(card.parse(input))
66 |
67 | var selectionEnd = element.selectionEnd
68 | ngModel.$setViewValue(formatted)
69 | ngModel.$render()
70 |
71 | if (selectionEnd === input.length && input.length < formatted.length) {
72 | selectionEnd = formatted.length
73 | }
74 | setCursorPostion(element, selectionEnd)
75 | })
76 | }
77 |
78 | ngModel.$parsers.unshift(card.parse)
79 |
80 | ngModel.$validators.ccNumber = function validateCcNumber (number) {
81 | return ngModel.$isEmpty(ngModel.$viewValue) || card.isValid(number)
82 | }
83 |
84 | ngModel.$validators.ccNumberType = function validateCcNumberType (number) {
85 | if (ngModel.$isEmpty(ngModel.$viewValue)) return true
86 | var type = $parse($attributes.ccType)($scope)
87 | if (!type) card.isValid(number)
88 | return array(type).some(partial(card.isValid, number))
89 | }
90 | }
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/test/README.md:
--------------------------------------------------------------------------------
1 | ## Testing angular-credit-cards
2 |
3 | The test suite can be run with `npm test` after cloning the repository and running `npm install`. angular-credit-cards tests the functionality and features of its directives, their interactions, and the various attributes they can receive. For test coverage of parsing and formatting of card data, see:
4 |
5 | * The [creditcards test suite](https://github.com/bendrucker/creditcards/tree/master/test), which provides more than 30 tests against card data parsing and validation logic
6 | * The [creditcards-types test suite](https://github.com/bendrucker/creditcards-types/blob/master/test), which provides nearly 70 additional tests against card type matching functionality
7 |
--------------------------------------------------------------------------------
/test/cvc.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | /* global describe, beforeEach, it */
4 |
5 | var expect = require('chai').expect
6 | var angular = require('angular')
7 | require('angular-mocks/ngMock')
8 |
9 | describe('cc-cvc', function () {
10 | beforeEach(angular.mock.module(require('../')))
11 |
12 | var $compile, scope, controller, element
13 | beforeEach(angular.mock.inject(function ($injector) {
14 | $compile = $injector.get('$compile')
15 | scope = $injector.get('$rootScope').$new()
16 | scope.card = {}
17 | element = angular.element(' ')
18 | controller = $compile(element)(scope).controller('ngModel')
19 | }))
20 |
21 | it('sets maxlength to 4', function () {
22 | expect(element.attr('maxlength')).to.equal('4')
23 | })
24 |
25 | it('adds a numeric pattern', function () {
26 | expect(element.attr('pattern')).to.equal('[0-9]*')
27 | })
28 |
29 | it('adds an autocomplete attribute', function () {
30 | expect(element.attr('x-autocompletetype')).to.equal('cc-csc')
31 | })
32 |
33 | it('accepts a 3 digit numeric', function () {
34 | controller.$setViewValue('123')
35 | scope.$digest()
36 | expect(controller.$valid).to.be.true
37 | expect(scope.card.cvc).to.equal('123')
38 | })
39 |
40 | it('accepts a 4 digit numeric', function () {
41 | controller.$setViewValue('1234')
42 | scope.$digest()
43 | expect(controller.$valid).to.be.true
44 | expect(scope.card.cvc).to.equal('1234')
45 | })
46 |
47 | it('does not accept numbers', function () {
48 | controller.$setViewValue(123)
49 | scope.$digest()
50 | expect(controller.$valid).to.be.false
51 | })
52 |
53 | it('accepts an empty cvc', function () {
54 | controller.$setViewValue('')
55 | scope.$digest()
56 | expect(controller.$valid).to.be.true
57 | })
58 |
59 | it('unsets the model value when invalid', function () {
60 | controller.$setViewValue('abc')
61 | scope.$digest()
62 | expect(scope.card.cvc).to.be.undefined
63 | })
64 |
65 | describe('ccType', function () {
66 | beforeEach(function () {
67 | element.attr('cc-type', 'cardType')
68 | controller = $compile(element)(scope).controller('ngModel')
69 | })
70 |
71 | it('validates against the card type', function () {
72 | scope.cardType = 'visa'
73 | scope.card.cvc = '1234'
74 | scope.$digest()
75 | expect(controller.$valid).to.be.false
76 | scope.cardType = 'americanExpress'
77 | scope.$digest()
78 | expect(controller.$valid).to.be.true
79 | })
80 | })
81 | })
82 |
--------------------------------------------------------------------------------
/test/expiration.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | /* global describe, beforeEach, afterEach, it */
4 |
5 | var expect = require('chai').use(require('sinon-chai')).expect
6 | var sinon = require('sinon')
7 | var angular = require('angular')
8 | var fs = require('fs')
9 | var path = require('path')
10 | require('angular-mocks/ngMock')
11 |
12 | describe('Expiration', function () {
13 | beforeEach(angular.mock.module(require('../')))
14 |
15 | var $compile, element, $scope, expiration, sandbox
16 | beforeEach(angular.mock.inject(function ($injector) {
17 | $compile = $injector.get('$compile')
18 | element = angular.element(fs.readFileSync(path.resolve(__dirname, 'fixtures/cc-exp.html')).toString())
19 | $scope = $injector.get('$rootScope').$new()
20 | $scope.expiration = (expiration = {})
21 | sandbox = sinon.sandbox.create()
22 |
23 | // Set current date to 12/1/2014 00:00 UTC -5
24 | sandbox.useFakeTimers(1417410000000)
25 | }))
26 |
27 | afterEach(function () {
28 | sandbox.restore()
29 | })
30 |
31 | describe('cc-exp', function () {
32 | var form, formController
33 | beforeEach(function () {
34 | form = element
35 | formController = $compile(form)($scope).controller('form')
36 | element = form.children()
37 | })
38 |
39 | it('is valid for a valid future month', function () {
40 | expiration.month = 1
41 | expiration.year = 2015
42 | $scope.$digest()
43 | expect(formController.$error.ccExp).to.not.be.ok
44 | })
45 |
46 | it('is valid for the current month', function () {
47 | expiration.month = 12
48 | expiration.year = 2014
49 | $scope.$digest()
50 | expect(formController.$error.ccExp).to.not.be.ok
51 | })
52 |
53 | it('is invalid when month exists but not year', function () {
54 | expiration.month = 10
55 | $scope.$digest()
56 | expect(formController.$error.ccExp).to.contain(element)
57 | })
58 |
59 | it('is invalid when year exists but not month', function () {
60 | expiration.year = 2018
61 | $scope.$digest()
62 | expect(formController.$error.ccExp).to.contain(element)
63 | })
64 |
65 | it('is invalid for a past month this year', function () {
66 | expiration.month = 10
67 | expiration.year = 2014
68 | $scope.$digest()
69 | expect(formController.$error.ccExp).to.contain(element)
70 | })
71 |
72 | it('is valid with an empty expiration', function () {
73 | $scope.$digest()
74 | expect(formController.$error.ccExp).to.not.be.ok
75 | expect(formController.$error.ccExpMonth).to.not.be.ok
76 | expect(formController.$error.ccExpYear).to.not.be.ok
77 | })
78 |
79 | it('is a noop with no form', function () {
80 | $compile(element.clone())($scope).controller('ccExp')
81 | $scope.$digest()
82 | })
83 |
84 | it('is valid with valid initial data', function () {
85 | expiration.month = 1
86 | expiration.year = 2015
87 | formController = $compile(element)($scope).controller('form')
88 | expect(formController.$error.ccExp).to.not.be.ok
89 | expect(formController.$valid).equal(true)
90 | })
91 | })
92 |
93 | describe('cc-exp-month', function () {
94 | var controller
95 | beforeEach(function () {
96 | element = element.find('input').eq(0)
97 | controller = $compile(element)($scope).controller('ngModel')
98 | })
99 |
100 | it('sets maxlength to 2', function () {
101 | expect(element.attr('maxlength')).to.equal('2')
102 | })
103 |
104 | it('adds an autocomplete attribute', function () {
105 | expect(element.attr('x-autocompletetype')).to.equal('cc-exp-month')
106 | })
107 |
108 | it('validates maxlength with type="number" (#13)', function () {
109 | element = element.clone().attr('type', 'number')
110 | controller = $compile(element)($scope).controller('ngModel')
111 | controller.$setViewValue('100')
112 | $scope.$digest()
113 | expect(controller.$valid).to.be.false
114 | expect(controller.$error.maxlength).to.be.true
115 | })
116 |
117 | it('adds a numeric pattern', function () {
118 | expect(element.attr('pattern')).to.equal('[0-9]*')
119 | })
120 |
121 | it('is accepts a valid month string', function () {
122 | controller.$setViewValue('05')
123 | $scope.$digest()
124 | expect(controller.$valid).to.be.true
125 | expect(expiration.month).to.equal(5)
126 | })
127 |
128 | it('is accepts a valid month number', function () {
129 | controller.$setViewValue(5)
130 | $scope.$digest()
131 | expect(controller.$valid).to.be.true
132 | expect(expiration.month).to.equal(5)
133 | })
134 |
135 | it('is invalid when falsy', function () {
136 | controller.$setViewValue('')
137 | $scope.$digest()
138 | expect(controller.$valid).to.be.false
139 | })
140 | })
141 |
142 | describe('cc-exp-year', function () {
143 | var controller
144 | beforeEach(function () {
145 | element = element.find('input').eq(1)
146 | controller = $compile(element)($scope).controller('ngModel')
147 | })
148 |
149 | it('sets maxlength to 2', function () {
150 | expect(element.attr('maxlength')).to.equal('2')
151 | })
152 |
153 | it('adds a numeric pattern', function () {
154 | expect(element.attr('pattern')).to.equal('[0-9]*')
155 | })
156 |
157 | it('adds an autocomplete attribute', function () {
158 | expect(element.attr('x-autocompletetype')).to.equal('cc-exp-year')
159 | })
160 |
161 | it('is invalid when in the past', function () {
162 | controller.$setViewValue(13)
163 | $scope.$digest()
164 | expect(controller.$error.ccExpYear).to.be.true
165 | })
166 |
167 | it('is valid for this year', function () {
168 | controller.$setViewValue('14')
169 | $scope.$digest()
170 | expect(controller.$valid).to.be.true
171 | expect(expiration.year).to.equal(2014)
172 | })
173 |
174 | it('is valid for a far-future year', function () {
175 | controller.$setViewValue('99')
176 | $scope.$digest()
177 | expect(controller.$valid).to.be.true
178 | })
179 |
180 | it('is not valid for a past year', function () {
181 | controller.$setViewValue('13')
182 | $scope.$digest()
183 | expect(controller.$valid).to.be.false
184 | })
185 |
186 | it('formats the year from the model value', function () {
187 | expiration.year = 2014
188 | $scope.$digest()
189 | expect(controller.$viewValue).to.equal('14')
190 | })
191 |
192 | describe('full-year', function () {
193 | beforeEach(function () {
194 | element = element.clone().attr('full-year', '')
195 | controller = $compile(element)($scope).controller('ngModel')
196 | })
197 |
198 | it('sets maxlength to 4', function () {
199 | expect(element.attr('maxlength')).to.equal('4')
200 | })
201 |
202 | it('does not pad the date when parsing', function () {
203 | controller.$setViewValue('2014')
204 | $scope.$digest()
205 | expect(controller.$valid).to.be.true
206 | expect(expiration.year).to.equal(2014)
207 | })
208 |
209 | it('does not strip the date when formatting', function () {
210 | expiration.year = 2014
211 | $scope.$digest()
212 | expect(controller.$viewValue).to.equal('2014')
213 | })
214 | })
215 | })
216 | })
217 |
--------------------------------------------------------------------------------
/test/fixtures/cc-exp.html:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/test/number.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | /* global describe, beforeEach, it */
4 |
5 | var expect = require('chai').expect
6 | var angular = require('angular')
7 | require('angular-mocks/ngMock')
8 |
9 | describe('cc-number', function () {
10 | beforeEach(angular.mock.module(require('../')))
11 |
12 | var $compile, element, scope, controller
13 | beforeEach(angular.mock.inject(function ($injector, $parse) {
14 | $compile = $injector.get('$compile')
15 | element = angular.element(' ')
16 | scope = $injector.get('$rootScope').$new()
17 | scope.card = {}
18 | controller = $compile(element)(scope).controller('ngModel')
19 | }))
20 |
21 | it('adds a numeric pattern', function () {
22 | expect(element.attr('pattern')).to.equal('[0-9]*')
23 | })
24 |
25 | it('adds an autocomplete attribute', function () {
26 | expect(element.attr('x-autocompletetype')).to.equal('cc-number')
27 | })
28 |
29 | it('is initially pristine', function () {
30 | scope.$digest()
31 | expect(controller.$pristine).to.equal(true)
32 | })
33 |
34 | it('accepts a valid card', function () {
35 | controller.$setViewValue('4242 4242 4242 4242')
36 | expect(controller.$valid).to.be.true
37 | expect(scope.card.number).to.equal('4242424242424242')
38 | })
39 |
40 | it('accepts an empty card', function () {
41 | controller.$setViewValue('')
42 | scope.$digest()
43 | expect(controller.$error.ccNumber).to.not.be.ok
44 | expect(controller.$error.ccNumberType).to.not.be.ok
45 | expect(scope.card.number).to.equal('')
46 | })
47 |
48 | it('accepts a valid card with specified type', function () {
49 | scope.cardType = 'Visa'
50 | controller.$setViewValue('4242 4242 4242 4242')
51 | scope.$digest()
52 | expect(controller.$error.ccNumberType).to.not.be.ok
53 | })
54 |
55 | it('accepts a valid card with multiple types', function () {
56 | scope.cardType = ['Visa', 'American Express']
57 | controller.$setViewValue('4242 4242 4242 4242')
58 | scope.$digest()
59 | expect(controller.$error.ccNumberType).to.not.be.ok
60 | controller.$setViewValue('378282246310005')
61 | scope.$digest()
62 | expect(controller.$error.ccNumberType).to.not.be.ok
63 | })
64 |
65 | it('rejects a luhn-invalid card', function () {
66 | controller.$setViewValue('4242424242424241')
67 | expect(controller.$error.ccNumber).to.be.true
68 | expect(scope.card.number).to.be.undefined
69 | })
70 |
71 | it('rejects a luhn-valid card with no matching type', function () {
72 | controller.$setViewValue('42')
73 | expect(controller.$error.ccNumber).to.be.true
74 | expect(scope.card.number).to.be.undefined
75 | })
76 |
77 | describe('ccFormat (card formatting)', function () {
78 | it('formats the card number during entry', function () {
79 | element.val('42424')
80 | element.triggerHandler('input')
81 | scope.$digest()
82 | expect(controller.$viewValue).to.equal('4242 4')
83 | expect(element.val()).to.equal('4242 4')
84 | })
85 |
86 | it('increments the cursor after a space', function () {
87 | element.val('42424')
88 | element.triggerHandler('input')
89 | scope.$digest()
90 | expect(controller.$viewValue).to.equal('4242 4')
91 | expect(element[0].selectionEnd).to.equal(6)
92 | })
93 |
94 | it('increments the cursor after a space and many characters with debounce', angular.mock.inject(function ($injector) {
95 | $compile = $injector.get('$compile')
96 | element = angular.element(' ')
97 | scope = $injector.get('$rootScope').$new()
98 | scope.card = {}
99 | controller = $compile(element)(scope).controller('ngModel')
100 | element.val('424242')
101 | element.triggerHandler('input')
102 | controller.$commitViewValue()
103 | scope.$digest()
104 | expect(controller.$viewValue).to.equal('4242 42')
105 | expect(element[0].selectionEnd).to.equal(7)
106 | }))
107 |
108 | it('decrements the cursor when deleted a character after the space', function () {
109 | element.val('4242 4')
110 | element.triggerHandler('input')
111 | scope.$digest()
112 |
113 | element.val('4242 ')
114 | element.triggerHandler('input')
115 | scope.$digest()
116 | expect(controller.$viewValue).to.equal('4242')
117 | expect(element[0].selectionEnd).to.equal(4)
118 | })
119 | })
120 |
121 | describe('ccType (expected type)', function () {
122 | beforeEach(function () {
123 | element.attr('cc-type', 'cardType')
124 | controller = $compile(element)(scope).controller('ngModel')
125 | })
126 |
127 | it('accepts a valid card with specified type', function () {
128 | scope.cardType = 'Visa'
129 | controller.$setViewValue('4242 4242 4242 4242')
130 | scope.$digest()
131 | expect(controller.$error.ccNumberType).to.not.be.ok
132 | })
133 |
134 | it('rejects a valid card when the type is a mismatch', function () {
135 | scope.cardType = 'American Express'
136 | controller.$setViewValue('4242 4242 4242 4242')
137 | scope.$digest()
138 | expect(controller.$error.ccNumber).to.not.be.ok
139 | expect(controller.$error.ccNumberType).to.equal(true)
140 | })
141 | })
142 |
143 | describe('$ccType (actual type)', function () {
144 | var ccNumberController
145 | beforeEach(function () {
146 | ccNumberController = element.controller('ccNumber')
147 | scope.$digest()
148 | })
149 |
150 | it('exposes a calculated card type', function () {
151 | controller.$setViewValue('4242424242424242')
152 | scope.$digest()
153 | expect(ccNumberController.type).to.equal('Visa')
154 | })
155 |
156 | it('syncs the type with the ngModelController', function () {
157 | controller.$setViewValue('4242424242424242')
158 | scope.$digest()
159 | expect(controller.$ccType).to.equal('Visa')
160 | })
161 | })
162 |
163 | describe('$ccEagerType', function () {
164 | var ccNumberController
165 | beforeEach(function () {
166 | element.attr('cc-eager-type', '')
167 | ccNumberController = $compile(element)(scope).controller('ccNumber')
168 | controller = element.controller('ngModel')
169 | scope.$digest()
170 | })
171 |
172 | it('eagerly checks the type', function () {
173 | controller.$setViewValue('42')
174 | scope.$digest()
175 | expect(ccNumberController.eagerType).to.equal('Visa')
176 | })
177 |
178 | it('syncs the eager type with the ngModelController', function () {
179 | controller.$setViewValue('42')
180 | expect(controller.$ccEagerType).to.equal('Visa')
181 | })
182 |
183 | it('clears the type after the view value is cleared', function () {
184 | controller.$setViewValue('4')
185 | expect(controller.$ccEagerType).to.equal('Visa')
186 | controller.$setViewValue('')
187 | expect(controller.$ccEagerType).to.equal(undefined)
188 | })
189 | })
190 | })
191 |
--------------------------------------------------------------------------------