├── .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 `
` to disable native browser validation. The input pattern used to trigger the dialer keypad on mobile does not allow spaces, causing browsers that implement pattern validation to display an error tooltip. 52 | 53 | The `cc-type` property is optional and may be a single card type or an array of types. If its value is defined on the scope, the card number will be checked against the type(s) in addition to the Luhh algorithm. A special validity key—`ccNumberType`—indicates whether the card matched the specified type. If no type is provided, `ccNumberType` will always be valid for any card that passes Luhn and matches any card type. 54 | 55 | You can also enable eager card type detection to match against card type with only leading digits (e.g. a `4` can immediately be detected as a Visa). Add the `cc-eager-type` attribute to your element to enable eager type detection. The eagerly matched type will be available as `$ccEagerType` on the model controller. 56 | 57 | Displaying the card type from a user input: 58 | 59 | ```html 60 | 61 | 62 |
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 ` 79 | 80 |

That's not a valid {{cardType}}

81 | 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 |
2 |
3 | 4 | 5 |
6 |
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 | --------------------------------------------------------------------------------