summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/CreditCard.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/modules/CreditCard.sys.mjs')
-rw-r--r--toolkit/modules/CreditCard.sys.mjs531
1 files changed, 531 insertions, 0 deletions
diff --git a/toolkit/modules/CreditCard.sys.mjs b/toolkit/modules/CreditCard.sys.mjs
new file mode 100644
index 0000000000..40dd837318
--- /dev/null
+++ b/toolkit/modules/CreditCard.sys.mjs
@@ -0,0 +1,531 @@
+/* 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;
+ }
+}