Bug 1908842: update password generator prompt text r=android-reviewers,gl
[gecko.git] / toolkit / modules / CreditCard.sys.mjs
blob622c371d76ad83f730411a6e4fd28a7d1b2fea68
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 // The list of known and supported credit card network ids ("types")
6 // This list mirrors the networks from dom/payments/BasicCardPayment.cpp
7 // and is defined by https://www.w3.org/Payments/card-network-ids
8 const SUPPORTED_NETWORKS = Object.freeze([
9   "amex",
10   "cartebancaire",
11   "diners",
12   "discover",
13   "jcb",
14   "mastercard",
15   "mir",
16   "unionpay",
17   "visa",
18 ]);
20 // This lists stores lower cased variations of popular credit card network
21 // names for matching against strings.
22 export const NETWORK_NAMES = {
23   "american express": "amex",
24   "master card": "mastercard",
25   "union pay": "unionpay",
28 // Based on https://en.wikipedia.org/wiki/Payment_card_number
30 // Notice:
31 //   - CarteBancaire (`4035`, `4360`) is now recognized as Visa.
32 //   - UnionPay (`63--`) is now recognized as Discover.
33 // This means that the order matters.
34 // First we'll try to match more specific card,
35 // and if that doesn't match we'll test against the more generic range.
36 const CREDIT_CARD_IIN = [
37   { type: "amex", start: 34, end: 34, len: 15 },
38   { type: "amex", start: 37, end: 37, len: 15 },
39   { type: "cartebancaire", start: 4035, end: 4035, len: 16 },
40   { type: "cartebancaire", start: 4360, end: 4360, len: 16 },
41   // We diverge from Wikipedia here, because Diners card
42   // support length of 14-19.
43   { type: "diners", start: 300, end: 305, len: [14, 19] },
44   { type: "diners", start: 3095, end: 3095, len: [14, 19] },
45   { type: "diners", start: 36, end: 36, len: [14, 19] },
46   { type: "diners", start: 38, end: 39, len: [14, 19] },
47   { type: "discover", start: 6011, end: 6011, len: [16, 19] },
48   { type: "discover", start: 622126, end: 622925, len: [16, 19] },
49   { type: "discover", start: 624000, end: 626999, len: [16, 19] },
50   { type: "discover", start: 628200, end: 628899, len: [16, 19] },
51   { type: "discover", start: 64, end: 65, len: [16, 19] },
52   { type: "jcb", start: 3528, end: 3589, len: [16, 19] },
53   { type: "mastercard", start: 2221, end: 2720, len: 16 },
54   { type: "mastercard", start: 51, end: 55, len: 16 },
55   { type: "mir", start: 2200, end: 2204, len: 16 },
56   { type: "unionpay", start: 62, end: 62, len: [16, 19] },
57   { type: "unionpay", start: 81, end: 81, len: [16, 19] },
58   { type: "visa", start: 4, end: 4, len: 16 },
59 ].sort((a, b) => b.start - a.start);
61 export class CreditCard {
62   /**
63    * A CreditCard object represents a credit card, with
64    * number, name, expiration, network, and CCV.
65    * The number is the only required information when creating
66    * an object, all other members are optional. The number
67    * is validated during construction and will throw if invalid.
68    *
69    * @param {string} name, optional
70    * @param {string} number
71    * @param {string} expirationString, optional
72    * @param {string|number} expirationMonth, optional
73    * @param {string|number} expirationYear, optional
74    * @param {string} network, optional
75    * @param {string|number} ccv, optional
76    * @param {string} encryptedNumber, optional
77    * @throws if number is an invalid credit card number
78    */
79   constructor({
80     name,
81     number,
82     expirationString,
83     expirationMonth,
84     expirationYear,
85     network,
86     ccv,
87     encryptedNumber,
88   }) {
89     this._name = name;
90     this._unmodifiedNumber = number;
91     this._encryptedNumber = encryptedNumber;
92     this._ccv = ccv;
93     this.number = number;
94     let { month, year } = CreditCard.normalizeExpiration({
95       expirationString,
96       expirationMonth,
97       expirationYear,
98     });
99     this._expirationMonth = month;
100     this._expirationYear = year;
101     this.network = network;
102   }
104   set name(value) {
105     this._name = value;
106   }
108   set expirationMonth(value) {
109     if (typeof value == "undefined") {
110       this._expirationMonth = undefined;
111       return;
112     }
113     this._expirationMonth = CreditCard.normalizeExpirationMonth(value);
114   }
116   get expirationMonth() {
117     return this._expirationMonth;
118   }
120   set expirationYear(value) {
121     if (typeof value == "undefined") {
122       this._expirationYear = undefined;
123       return;
124     }
125     this._expirationYear = CreditCard.normalizeExpirationYear(value);
126   }
128   get expirationYear() {
129     return this._expirationYear;
130   }
132   set expirationString(value) {
133     let { month, year } = CreditCard.parseExpirationString(value);
134     this.expirationMonth = month;
135     this.expirationYear = year;
136   }
138   set ccv(value) {
139     this._ccv = value;
140   }
142   get number() {
143     return this._number;
144   }
146   /**
147    * Sets the number member of a CreditCard object. If the number
148    * is not valid according to the Luhn algorithm then the member
149    * will get set to the empty string before throwing an exception.
150    *
151    * @param {string} value
152    * @throws if the value is an invalid credit card number
153    */
154   set number(value) {
155     if (value) {
156       let normalizedNumber = CreditCard.normalizeCardNumber(value);
157       // Based on the information on wiki[1], the shortest valid length should be
158       // 12 digits (Maestro).
159       // [1] https://en.wikipedia.org/wiki/Payment_card_number
160       normalizedNumber = normalizedNumber.match(/^\d{12,}$/)
161         ? normalizedNumber
162         : "";
163       this._number = normalizedNumber;
164     } else {
165       this._number = "";
166     }
168     if (value && !this.isValidNumber()) {
169       this._number = "";
170       throw new Error("Invalid credit card number");
171     }
172   }
174   get network() {
175     return this._network;
176   }
178   set network(value) {
179     this._network = value || undefined;
180   }
182   // Implements the Luhn checksum algorithm as described at
183   // http://wikipedia.org/wiki/Luhn_algorithm
184   // Number digit lengths vary with network, but should fall within 12-19 range. [2]
185   // More details at https://en.wikipedia.org/wiki/Payment_card_number
186   isValidNumber() {
187     if (!this._number) {
188       return false;
189     }
191     // Remove dashes and whitespace
192     const number = CreditCard.normalizeCardNumber(this._number);
194     const len = number.length;
195     if (len < 12 || len > 19) {
196       return false;
197     }
199     if (!/^\d+$/.test(number)) {
200       return false;
201     }
203     let total = 0;
204     for (let i = 0; i < len; i++) {
205       let ch = parseInt(number[len - i - 1], 10);
206       if (i % 2 == 1) {
207         // Double it, add digits together if > 10
208         ch *= 2;
209         if (ch > 9) {
210           ch -= 9;
211         }
212       }
213       total += ch;
214     }
215     return total % 10 == 0;
216   }
218   /**
219    * Normalizes a credit card number.
220    * @param {string} number
221    * @return {string | null}
222    * @memberof CreditCard
223    */
224   static normalizeCardNumber(number) {
225     if (!number) {
226       return null;
227     }
228     return number.replace(/[\-\s]/g, "");
229   }
231   /**
232    * Attempts to match the number against known network identifiers.
233    *
234    * @param {string} ccNumber Credit card number with no spaces or special characters in it.
235    *
236    * @returns {string|null}
237    */
238   static getType(ccNumber) {
239     if (!ccNumber) {
240       return null;
241     }
243     for (let i = 0; i < CREDIT_CARD_IIN.length; i++) {
244       const range = CREDIT_CARD_IIN[i];
245       if (typeof range.len == "number") {
246         if (range.len != ccNumber.length) {
247           continue;
248         }
249       } else if (
250         ccNumber.length < range.len[0] ||
251         ccNumber.length > range.len[1]
252       ) {
253         continue;
254       }
256       const prefixLength = Math.floor(Math.log10(range.start)) + 1;
257       const prefix = parseInt(ccNumber.substring(0, prefixLength), 10);
258       if (prefix >= range.start && prefix <= range.end) {
259         return range.type;
260       }
261     }
262     return null;
263   }
265   /**
266    * Attempts to retrieve a card network identifier based
267    * on a name.
268    *
269    * @param {string|undefined|null} name
270    *
271    * @returns {string|null}
272    */
273   static getNetworkFromName(name) {
274     if (!name) {
275       return null;
276     }
277     let lcName = name.trim().toLowerCase().normalize("NFKC");
278     if (SUPPORTED_NETWORKS.includes(lcName)) {
279       return lcName;
280     }
281     for (let term in NETWORK_NAMES) {
282       if (lcName.includes(term)) {
283         return NETWORK_NAMES[term];
284       }
285     }
286     return null;
287   }
289   /**
290    * Returns true if the card number is valid and the
291    * expiration date has not passed. Otherwise false.
292    *
293    * @returns {boolean}
294    */
295   isValid() {
296     if (!this.isValidNumber()) {
297       return false;
298     }
300     let currentDate = new Date();
301     let currentYear = currentDate.getFullYear();
302     if (this._expirationYear > currentYear) {
303       return true;
304     }
306     // getMonth is 0-based, so add 1 because credit cards are 1-based
307     let currentMonth = currentDate.getMonth() + 1;
308     return (
309       this._expirationYear == currentYear &&
310       this._expirationMonth >= currentMonth
311     );
312   }
314   get maskedNumber() {
315     return CreditCard.getMaskedNumber(this._number);
316   }
318   get longMaskedNumber() {
319     return CreditCard.getLongMaskedNumber(this._number);
320   }
322   /**
323    * Get credit card display label. It should display masked numbers, the
324    * cardholder's name, and the expiration date, separated by a commas.
325    * In addition, the card type is provided in the accessibility label.
326    */
327   static getLabelInfo({ number, name, month, year, type }) {
328     let formatSelector = ["number"];
329     if (name) {
330       formatSelector.push("name");
331     }
332     if (month && year) {
333       formatSelector.push("expiration");
334     }
335     let stringId = `credit-card-label-${formatSelector.join("-")}-2`;
336     return {
337       id: stringId,
338       args: {
339         number: CreditCard.getMaskedNumber(number),
340         name,
341         month: month?.toString(),
342         year: year?.toString(),
343         type,
344       },
345     };
346   }
348   /**
349    *
350    * Please use getLabelInfo above, as it allows for localization.
351    * @deprecated
352    */
353   static getLabel({ number, name }) {
354     let parts = [];
356     if (number) {
357       parts.push(CreditCard.getMaskedNumber(number));
358     }
359     if (name) {
360       parts.push(name);
361     }
362     return parts.join(", ");
363   }
365   static normalizeExpirationMonth(month) {
366     month = parseInt(month, 10);
367     if (isNaN(month) || month < 1 || month > 12) {
368       return undefined;
369     }
370     return month;
371   }
373   static normalizeExpirationYear(year) {
374     year = parseInt(year, 10);
375     if (isNaN(year) || year < 0) {
376       return undefined;
377     }
378     if (year < 100) {
379       year += 2000;
380     }
381     return year;
382   }
384   static parseExpirationString(expirationString) {
385     let rules = [
386       {
387         regex: /(?:^|\D)(\d{2})(\d{2})(?!\d)/,
388       },
389       {
390         regex: /(?:^|\D)(\d{4})[-/](\d{1,2})(?!\d)/,
391         yearIndex: 0,
392         monthIndex: 1,
393       },
394       {
395         regex: /(?:^|\D)(\d{1,2})[-/](\d{4})(?!\d)/,
396         yearIndex: 1,
397         monthIndex: 0,
398       },
399       {
400         regex: /(?:^|\D)(\d{1,2})[-/](\d{1,2})(?!\d)/,
401       },
402       {
403         regex: /(?:^|\D)(\d{2})(\d{2})(?!\d)/,
404       },
405     ];
407     expirationString = expirationString.replaceAll(" ", "");
408     for (let rule of rules) {
409       let result = rule.regex.exec(expirationString);
410       if (!result) {
411         continue;
412       }
414       let year, month;
415       const parsedResults = [parseInt(result[1], 10), parseInt(result[2], 10)];
416       if (!rule.yearIndex || !rule.monthIndex) {
417         month = parsedResults[0];
418         if (month > 12) {
419           year = parsedResults[0];
420           month = parsedResults[1];
421         } else {
422           year = parsedResults[1];
423         }
424       } else {
425         year = parsedResults[rule.yearIndex];
426         month = parsedResults[rule.monthIndex];
427       }
429       if (month >= 1 && month <= 12 && (year < 100 || year > 2000)) {
430         return { month, year };
431       }
432     }
433     return { month: undefined, year: undefined };
434   }
436   static normalizeExpiration({
437     expirationString,
438     expirationMonth,
439     expirationYear,
440   }) {
441     // Only prefer the string version if missing one or both parsed formats.
442     let parsedExpiration = {};
443     if (expirationString && (!expirationMonth || !expirationYear)) {
444       parsedExpiration = CreditCard.parseExpirationString(expirationString);
445     }
446     return {
447       month: CreditCard.normalizeExpirationMonth(
448         parsedExpiration.month || expirationMonth
449       ),
450       year: CreditCard.normalizeExpirationYear(
451         parsedExpiration.year || expirationYear
452       ),
453     };
454   }
456   static formatMaskedNumber(maskedNumber) {
457     return "*".repeat(4) + maskedNumber.substr(-4);
458   }
460   static getMaskedNumber(number) {
461     return "*".repeat(4) + " " + number.substr(-4);
462   }
464   static getLongMaskedNumber(number) {
465     return "*".repeat(number.length - 4) + number.substr(-4);
466   }
468   static getCreditCardLogo(network) {
469     const PATH = "chrome://formautofill/content/";
470     const THIRD_PARTY_PATH = PATH + "third-party/";
471     switch (network) {
472       case "amex":
473         return THIRD_PARTY_PATH + "cc-logo-amex.png";
474       case "cartebancaire":
475         return THIRD_PARTY_PATH + "cc-logo-cartebancaire.png";
476       case "diners":
477         return THIRD_PARTY_PATH + "cc-logo-diners.svg";
478       case "discover":
479         return THIRD_PARTY_PATH + "cc-logo-discover.png";
480       case "jcb":
481         return THIRD_PARTY_PATH + "cc-logo-jcb.svg";
482       case "mastercard":
483         return THIRD_PARTY_PATH + "cc-logo-mastercard.svg";
484       case "mir":
485         return THIRD_PARTY_PATH + "cc-logo-mir.svg";
486       case "unionpay":
487         return THIRD_PARTY_PATH + "cc-logo-unionpay.svg";
488       case "visa":
489         return THIRD_PARTY_PATH + "cc-logo-visa.svg";
490       default:
491         return PATH + "icon-credit-card-generic.svg";
492     }
493   }
495   /*
496    * Validates the number according to the Luhn algorithm. This
497    * method does not throw an exception if the number is invalid.
498    */
499   static isValidNumber(number) {
500     try {
501       new CreditCard({ number });
502     } catch (ex) {
503       return false;
504     }
505     return true;
506   }
508   static isValidNetwork(network) {
509     return SUPPORTED_NETWORKS.includes(network);
510   }
512   static getSupportedNetworks() {
513     return SUPPORTED_NETWORKS;
514   }
516   /**
517    * Localised names for supported networks are available in
518    * `browser/preferences/formAutofill.ftl`.
519    */
520   static getNetworkL10nId(network) {
521     return this.isValidNetwork(network)
522       ? `autofill-card-network-${network}`
523       : null;
524   }