diff options
Diffstat (limited to 'toolkit/modules/CreditCard.sys.mjs')
-rw-r--r-- | toolkit/modules/CreditCard.sys.mjs | 528 |
1 files changed, 528 insertions, 0 deletions
diff --git a/toolkit/modules/CreditCard.sys.mjs b/toolkit/modules/CreditCard.sys.mjs new file mode 100644 index 0000000000..dd127d77b2 --- /dev/null +++ b/toolkit/modules/CreditCard.sys.mjs @@ -0,0 +1,528 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// The list of known and supported credit card network ids ("types") +// This list mirrors the networks from dom/payments/BasicCardPayment.cpp +// and is defined by https://www.w3.org/Payments/card-network-ids +const SUPPORTED_NETWORKS = Object.freeze([ + "amex", + "cartebancaire", + "diners", + "discover", + "jcb", + "mastercard", + "mir", + "unionpay", + "visa", +]); + +// This lists stores lower cased variations of popular credit card network +// names for matching against strings. +export const NETWORK_NAMES = { + "american express": "amex", + "master card": "mastercard", + "union pay": "unionpay", +}; + +// Based on https://en.wikipedia.org/wiki/Payment_card_number +// +// Notice: +// - CarteBancaire (`4035`, `4360`) is now recognized as Visa. +// - UnionPay (`63--`) is now recognized as Discover. +// This means that the order matters. +// First we'll try to match more specific card, +// and if that doesn't match we'll test against the more generic range. +const CREDIT_CARD_IIN = [ + { type: "amex", start: 34, end: 34, len: 15 }, + { type: "amex", start: 37, end: 37, len: 15 }, + { type: "cartebancaire", start: 4035, end: 4035, len: 16 }, + { type: "cartebancaire", start: 4360, end: 4360, len: 16 }, + // We diverge from Wikipedia here, because Diners card + // support length of 14-19. + { type: "diners", start: 300, end: 305, len: [14, 19] }, + { type: "diners", start: 3095, end: 3095, len: [14, 19] }, + { type: "diners", start: 36, end: 36, len: [14, 19] }, + { type: "diners", start: 38, end: 39, len: [14, 19] }, + { type: "discover", start: 6011, end: 6011, len: [16, 19] }, + { type: "discover", start: 622126, end: 622925, len: [16, 19] }, + { type: "discover", start: 624000, end: 626999, len: [16, 19] }, + { type: "discover", start: 628200, end: 628899, len: [16, 19] }, + { type: "discover", start: 64, end: 65, len: [16, 19] }, + { type: "jcb", start: 3528, end: 3589, len: [16, 19] }, + { type: "mastercard", start: 2221, end: 2720, len: 16 }, + { type: "mastercard", start: 51, end: 55, len: 16 }, + { type: "mir", start: 2200, end: 2204, len: 16 }, + { type: "unionpay", start: 62, end: 62, len: [16, 19] }, + { type: "unionpay", start: 81, end: 81, len: [16, 19] }, + { type: "visa", start: 4, end: 4, len: 16 }, +].sort((a, b) => b.start - a.start); + +export class CreditCard { + /** + * A CreditCard object represents a credit card, with + * number, name, expiration, network, and CCV. + * The number is the only required information when creating + * an object, all other members are optional. The number + * is validated during construction and will throw if invalid. + * + * @param {string} name, optional + * @param {string} number + * @param {string} expirationString, optional + * @param {string|number} expirationMonth, optional + * @param {string|number} expirationYear, optional + * @param {string} network, optional + * @param {string|number} ccv, optional + * @param {string} encryptedNumber, optional + * @throws if number is an invalid credit card number + */ + constructor({ + name, + number, + expirationString, + expirationMonth, + expirationYear, + network, + ccv, + encryptedNumber, + }) { + this._name = name; + this._unmodifiedNumber = number; + this._encryptedNumber = encryptedNumber; + this._ccv = ccv; + this.number = number; + let { month, year } = CreditCard.normalizeExpiration({ + expirationString, + expirationMonth, + expirationYear, + }); + this._expirationMonth = month; + this._expirationYear = year; + this.network = network; + } + + set name(value) { + this._name = value; + } + + set expirationMonth(value) { + if (typeof value == "undefined") { + this._expirationMonth = undefined; + return; + } + this._expirationMonth = CreditCard.normalizeExpirationMonth(value); + } + + get expirationMonth() { + return this._expirationMonth; + } + + set expirationYear(value) { + if (typeof value == "undefined") { + this._expirationYear = undefined; + return; + } + this._expirationYear = CreditCard.normalizeExpirationYear(value); + } + + get expirationYear() { + return this._expirationYear; + } + + set expirationString(value) { + let { month, year } = CreditCard.parseExpirationString(value); + this.expirationMonth = month; + this.expirationYear = year; + } + + set ccv(value) { + this._ccv = value; + } + + get number() { + return this._number; + } + + /** + * Sets the number member of a CreditCard object. If the number + * is not valid according to the Luhn algorithm then the member + * will get set to the empty string before throwing an exception. + * + * @param {string} value + * @throws if the value is an invalid credit card number + */ + set number(value) { + if (value) { + let normalizedNumber = CreditCard.normalizeCardNumber(value); + // Based on the information on wiki[1], the shortest valid length should be + // 12 digits (Maestro). + // [1] https://en.wikipedia.org/wiki/Payment_card_number + normalizedNumber = normalizedNumber.match(/^\d{12,}$/) + ? normalizedNumber + : ""; + this._number = normalizedNumber; + } else { + this._number = ""; + } + + if (value && !this.isValidNumber()) { + this._number = ""; + throw new Error("Invalid credit card number"); + } + } + + get network() { + return this._network; + } + + set network(value) { + this._network = value || undefined; + } + + // Implements the Luhn checksum algorithm as described at + // http://wikipedia.org/wiki/Luhn_algorithm + // Number digit lengths vary with network, but should fall within 12-19 range. [2] + // More details at https://en.wikipedia.org/wiki/Payment_card_number + isValidNumber() { + if (!this._number) { + return false; + } + + // Remove dashes and whitespace + let number = this._number.replace(/[\-\s]/g, ""); + + let len = number.length; + if (len < 12 || len > 19) { + return false; + } + + if (!/^\d+$/.test(number)) { + return false; + } + + let total = 0; + for (let i = 0; i < len; i++) { + let ch = parseInt(number[len - i - 1], 10); + if (i % 2 == 1) { + // Double it, add digits together if > 10 + ch *= 2; + if (ch > 9) { + ch -= 9; + } + } + total += ch; + } + return total % 10 == 0; + } + + /** + * Normalizes a credit card number. + * @param {string} number + * @return {string | null} + * @memberof CreditCard + */ + static normalizeCardNumber(number) { + if (!number) { + return null; + } + return number.replace(/[\-\s]/g, ""); + } + + /** + * Attempts to match the number against known network identifiers. + * + * @param {string} ccNumber Credit card number with no spaces or special characters in it. + * + * @returns {string|null} + */ + static getType(ccNumber) { + if (!ccNumber) { + return null; + } + + for (let i = 0; i < CREDIT_CARD_IIN.length; i++) { + const range = CREDIT_CARD_IIN[i]; + if (typeof range.len == "number") { + if (range.len != ccNumber.length) { + continue; + } + } else if ( + ccNumber.length < range.len[0] || + ccNumber.length > range.len[1] + ) { + continue; + } + + const prefixLength = Math.floor(Math.log10(range.start)) + 1; + const prefix = parseInt(ccNumber.substring(0, prefixLength), 10); + if (prefix >= range.start && prefix <= range.end) { + return range.type; + } + } + return null; + } + + /** + * Attempts to retrieve a card network identifier based + * on a name. + * + * @param {string|undefined|null} name + * + * @returns {string|null} + */ + static getNetworkFromName(name) { + if (!name) { + return null; + } + let lcName = name.trim().toLowerCase().normalize("NFKC"); + if (SUPPORTED_NETWORKS.includes(lcName)) { + return lcName; + } + for (let term in NETWORK_NAMES) { + if (lcName.includes(term)) { + return NETWORK_NAMES[term]; + } + } + return null; + } + + /** + * Returns true if the card number is valid and the + * expiration date has not passed. Otherwise false. + * + * @returns {boolean} + */ + isValid() { + if (!this.isValidNumber()) { + return false; + } + + let currentDate = new Date(); + let currentYear = currentDate.getFullYear(); + if (this._expirationYear > currentYear) { + return true; + } + + // getMonth is 0-based, so add 1 because credit cards are 1-based + let currentMonth = currentDate.getMonth() + 1; + return ( + this._expirationYear == currentYear && + this._expirationMonth >= currentMonth + ); + } + + get maskedNumber() { + return CreditCard.getMaskedNumber(this._number); + } + + get longMaskedNumber() { + return CreditCard.getLongMaskedNumber(this._number); + } + + /** + * Get credit card display label. It should display masked numbers, the + * cardholder's name, and the expiration date, separated by a commas. + * In addition, the card type is provided in the accessibility label. + */ + static getLabelInfo({ number, name, month, year, type }) { + let formatSelector = ["number"]; + if (name) { + formatSelector.push("name"); + } + if (month && year) { + formatSelector.push("expiration"); + } + let stringId = `credit-card-label-${formatSelector.join("-")}-2`; + return { + id: stringId, + args: { + number: CreditCard.getMaskedNumber(number), + name, + month: month?.toString(), + year: year?.toString(), + type, + }, + }; + } + + /** + * + * Please use getLabelInfo above, as it allows for localization. + * @deprecated + */ + static getLabel({ number, name }) { + let parts = []; + + if (number) { + parts.push(CreditCard.getMaskedNumber(number)); + } + if (name) { + parts.push(name); + } + return parts.join(", "); + } + + static normalizeExpirationMonth(month) { + month = parseInt(month, 10); + if (isNaN(month) || month < 1 || month > 12) { + return undefined; + } + return month; + } + + static normalizeExpirationYear(year) { + year = parseInt(year, 10); + if (isNaN(year) || year < 0) { + return undefined; + } + if (year < 100) { + year += 2000; + } + return year; + } + + static parseExpirationString(expirationString) { + let rules = [ + { + regex: /(?:^|\D)(\d{2})(\d{2})(?!\d)/, + }, + { + regex: /(?:^|\D)(\d{4})[-/](\d{1,2})(?!\d)/, + yearIndex: 0, + monthIndex: 1, + }, + { + regex: /(?:^|\D)(\d{1,2})[-/](\d{4})(?!\d)/, + yearIndex: 1, + monthIndex: 0, + }, + { + regex: /(?:^|\D)(\d{1,2})[-/](\d{1,2})(?!\d)/, + }, + { + regex: /(?:^|\D)(\d{2})(\d{2})(?!\d)/, + }, + ]; + + expirationString = expirationString.replaceAll(" ", ""); + for (let rule of rules) { + let result = rule.regex.exec(expirationString); + if (!result) { + continue; + } + + let year, month; + const parsedResults = [parseInt(result[1], 10), parseInt(result[2], 10)]; + if (!rule.yearIndex || !rule.monthIndex) { + month = parsedResults[0]; + if (month > 12) { + year = parsedResults[0]; + month = parsedResults[1]; + } else { + year = parsedResults[1]; + } + } else { + year = parsedResults[rule.yearIndex]; + month = parsedResults[rule.monthIndex]; + } + + if (month >= 1 && month <= 12 && (year < 100 || year > 2000)) { + return { month, year }; + } + } + return { month: undefined, year: undefined }; + } + + static normalizeExpiration({ + expirationString, + expirationMonth, + expirationYear, + }) { + // Only prefer the string version if missing one or both parsed formats. + let parsedExpiration = {}; + if (expirationString && (!expirationMonth || !expirationYear)) { + parsedExpiration = CreditCard.parseExpirationString(expirationString); + } + return { + month: CreditCard.normalizeExpirationMonth( + parsedExpiration.month || expirationMonth + ), + year: CreditCard.normalizeExpirationYear( + parsedExpiration.year || expirationYear + ), + }; + } + + static formatMaskedNumber(maskedNumber) { + return { + affix: "****", + label: maskedNumber.replace(/^\**/, ""), + }; + } + + static getCreditCardLogo(network) { + const PATH = "chrome://formautofill/content/"; + const THIRD_PARTY_PATH = PATH + "third-party/"; + switch (network) { + case "amex": + return THIRD_PARTY_PATH + "cc-logo-amex.png"; + case "cartebancaire": + return THIRD_PARTY_PATH + "cc-logo-cartebancaire.png"; + case "diners": + return THIRD_PARTY_PATH + "cc-logo-diners.svg"; + case "discover": + return THIRD_PARTY_PATH + "cc-logo-discover.png"; + case "jcb": + return THIRD_PARTY_PATH + "cc-logo-jcb.svg"; + case "mastercard": + return THIRD_PARTY_PATH + "cc-logo-mastercard.svg"; + case "mir": + return THIRD_PARTY_PATH + "cc-logo-mir.svg"; + case "unionpay": + return THIRD_PARTY_PATH + "cc-logo-unionpay.svg"; + case "visa": + return THIRD_PARTY_PATH + "cc-logo-visa.svg"; + default: + return PATH + "icon-credit-card-generic.svg"; + } + } + + static getMaskedNumber(number) { + return "*".repeat(4) + " " + number.substr(-4); + } + + static getLongMaskedNumber(number) { + return "*".repeat(number.length - 4) + number.substr(-4); + } + + /* + * Validates the number according to the Luhn algorithm. This + * method does not throw an exception if the number is invalid. + */ + static isValidNumber(number) { + try { + new CreditCard({ number }); + } catch (ex) { + return false; + } + return true; + } + + static isValidNetwork(network) { + return SUPPORTED_NETWORKS.includes(network); + } + + static getSupportedNetworks() { + return SUPPORTED_NETWORKS; + } + + /** + * Localised names for supported networks are available in + * `browser/preferences/formAutofill.ftl`. + */ + static getNetworkL10nId(network) { + return this.isValidNetwork(network) + ? `autofill-card-network-${network}` + : null; + } +} |