Skip to content
Snippets Groups Projects
index.js 8.75 MiB
Newer Older
  • Learn to ignore specific revisions
  •  * @returns {boolean} Returns `true` if `value` is an array-like object,
     *  else `false`.
     * @example
     *
     * _.isArrayLikeObject([1, 2, 3]);
     * // => true
     *
     * _.isArrayLikeObject(document.body.children);
     * // => true
     *
     * _.isArrayLikeObject('abc');
     * // => false
     *
     * _.isArrayLikeObject(_.noop);
     * // => false
     */
    function isArrayLikeObject(value) {
      return isObjectLike(value) && isArrayLike(value);
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    module.exports = isArrayLikeObject;
    
    /***/ }),
    /* 1610 */
    /***/ (function(module, exports) {
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /**
     * Gets the value at `key`, unless `key` is "__proto__" or "constructor".
     *
     * @private
     * @param {Object} object The object to query.
     * @param {string} key The key of the property to get.
     * @returns {*} Returns the property value.
     */
    function safeGet(object, key) {
      if (key === 'constructor' && typeof object[key] === 'function') {
        return;
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      if (key == '__proto__') {
        return;
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    module.exports = safeGet;
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /***/ }),
    
    Romain CREY's avatar
    Romain CREY committed
    /***/ (function(module, exports, __webpack_require__) {
    
    
    var copyObject = __webpack_require__(1446),
        keysIn = __webpack_require__(1468);
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /**
     * Converts `value` to a plain object flattening inherited enumerable string
     * keyed properties of `value` to own properties of the plain object.
     *
     * @static
     * @memberOf _
     * @since 3.0.0
     * @category Lang
     * @param {*} value The value to convert.
     * @returns {Object} Returns the converted plain object.
     * @example
     *
     * function Foo() {
     *   this.b = 2;
     * }
     *
     * Foo.prototype.c = 3;
     *
     * _.assign({ 'a': 1 }, new Foo);
     * // => { 'a': 1, 'b': 2 }
     *
     * _.assign({ 'a': 1 }, _.toPlainObject(new Foo));
     * // => { 'a': 1, 'b': 2, 'c': 3 }
     */
    function toPlainObject(value) {
      return copyObject(value, keysIn(value));
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    module.exports = toPlainObject;
    
    /***/ }),
    /* 1612 */
    /***/ (function(module, exports, __webpack_require__) {
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    var baseRest = __webpack_require__(1588),
        isIterateeCall = __webpack_require__(1589);
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /**
     * Creates a function like `_.assign`.
     *
     * @private
     * @param {Function} assigner The function to assign values.
     * @returns {Function} Returns the new assigner function.
     */
    function createAssigner(assigner) {
      return baseRest(function(object, sources) {
        var index = -1,
            length = sources.length,
            customizer = length > 1 ? sources[length - 1] : undefined,
            guard = length > 2 ? sources[2] : undefined;
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        customizer = (assigner.length > 3 && typeof customizer == 'function')
          ? (length--, customizer)
          : undefined;
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        if (guard && isIterateeCall(sources[0], sources[1], guard)) {
          customizer = length < 3 ? undefined : customizer;
          length = 1;
        }
        object = Object(object);
        while (++index < length) {
          var source = sources[index];
          if (source) {
            assigner(object, source, index, customizer);
    
    Romain CREY's avatar
    Romain CREY committed
          }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    module.exports = createAssigner;
    
    /***/ }),
    /* 1613 */
    /***/ (function(module, exports, __webpack_require__) {
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const sortBy = __webpack_require__(1583)
    const { eitherIncludes } = __webpack_require__(1614)
    const { getSlugFromInstitutionLabel } = __webpack_require__(1615)
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const findExactMatch = (attr, account, existingAccounts) => {
      const sameAttr = existingAccounts.filter(
        existingAccount => existingAccount[attr] === account[attr]
      )
      if (sameAttr.length === 1) {
        return { match: sameAttr[0], method: attr + '-exact' }
      } else if (sameAttr.length > 1) {
        return { matches: sameAttr, method: attr + '-exact' }
      } else {
        return null
      }
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const untrimmedAccountNumber = /^(?:[A-Za-z]+)?-?([0-9]+)-?(?:[A-Za-z]+)?$/
    const redactedCreditCard = /xxxx xxxx xxxx (\d{4})/
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const normalizeAccountNumber = (numberArg, ibanArg) => {
      const iban = ibanArg && ibanArg.replace(/\s/g, '')
      const number =
        numberArg && !numberArg.match(redactedCreditCard)
          ? numberArg.replace(/\s/g, '')
          : numberArg
      let match
      if (iban && iban.length == 27) {
        return iban.substr(14, 11)
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      if (number.length == 23) {
        // Must be an IBAN without the COUNTRY code
        // See support demand #9102 with BI
        // We extract the account number from the IBAN
        // COUNTRY (4) BANK (5) COUNTER (5) NUMBER (11) KEY (2)
        // FRXX 16275 10501 00300060030 00
        return number.substr(10, 11)
      } else if (number.length == 16) {
        // Linxo sends Bank account number that contains
        // the counter number
        return number.substr(5, 11)
      } else if (
        number.length > 11 &&
        (match = number.match(untrimmedAccountNumber))
      ) {
        // Some account numbers from BI are in the form
        // CC-00300060030 (CC for Compte Courant) or
        // LEO-00300060030
        return match[1]
      } else {
        return number
      }
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /**
     * If either of the account numbers has length 11 and one is contained
     * in the other, it's a match
     */
    const approxNumberMatch = (account, existingAccount) => {
      return (
        existingAccount.number &&
        account.number &&
        (existingAccount.number.length === 11 || account.number.length === 11) &&
        eitherIncludes(existingAccount.number, account.number) &&
        Math.min(existingAccount.number.length, account.number.length) >= 4
      )
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const creditCardMatch = (account, existingAccount) => {
      if (account.type !== 'CreditCard' && existingAccount.type !== 'CreditCard') {
        return false
      }
      let ccAccount, lastDigits
      for (let acc of [account, existingAccount]) {
        const match = acc && acc.number && acc.number.match(redactedCreditCard)
        if (match) {
          ccAccount = acc
          lastDigits = match[1]
        }
      }
      const other = ccAccount === account ? existingAccount : account
      if (other && other.number && other.number.slice(-4) === lastDigits) {
        return true
      }
      return false
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const slugMatch = (account, existingAccount) => {
      const possibleSlug = getSlugFromInstitutionLabel(account.institutionLabel)
      const possibleSlugExisting = getSlugFromInstitutionLabel(
        existingAccount.institutionLabel
      )
      return (
        !possibleSlug ||
        !possibleSlugExisting ||
        possibleSlug === possibleSlugExisting
      )
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const currencyMatch = (account, existingAccount) => {
      if (!account.currency) {
        return false
      }
      return (
        (existingAccount.rawNumber &&
          existingAccount.rawNumber.includes(account.currency)) ||
        (existingAccount.label &&
          existingAccount.label.includes(account.currency)) ||
        (existingAccount.originalBankLabel &&
          existingAccount.originalBankLabel.includes(account.currency))
      )
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const sameTypeMatch = (account, existingAccount) => {
      return account.type === existingAccount.type
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const rules = [
      { rule: slugMatch, bonus: 0, malus: -1000 },
      { rule: approxNumberMatch, bonus: 50, malus: -50, name: 'approx-number' },
      { rule: sameTypeMatch, bonus: 50, malus: 0, name: 'same-type' },
      { rule: creditCardMatch, bonus: 150, malus: 0, name: 'credit-card-number' },
      { rule: currencyMatch, bonus: 50, malus: 0, name: 'currency' }
    ]
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const score = (account, existingAccount) => {
      const methods = []
      const res = {
        account: existingAccount,
        methods
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      let points = 0
      for (let { rule, bonus, malus, name } of rules) {
        const ok = rule(account, existingAccount)
        if (ok && bonus) {
          points += bonus
        }
        if (!ok && malus) {
          points += malus
        }
        if (name && ok) {
          methods.push(name)
        }
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      res.points = points
      return res
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const normalizeAccount = account => {
      const normalizedAccountNumber = normalizeAccountNumber(
        account.number,
        account.iban
      )
      return {
        ...account,
        rawNumber: account.number,
        number: normalizedAccountNumber
      }
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const exactMatchAttributes = ['iban', 'number']
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const eqNotUndefined = (attr1, attr2) => {
      return attr1 && attr1 === attr2
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const findMatch = (account, existingAccounts) => {
      // Start with exact attribute matches
      for (const exactAttribute of exactMatchAttributes) {
        if (account[exactAttribute]) {
          const result = findExactMatch(exactAttribute, account, existingAccounts)
          if (result && result.match) {
            return result
    
    Romain CREY's avatar
    Romain CREY committed
          }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      const matchOriginalNumber = existingAccounts.find(
        otherAccount =>
          eqNotUndefined(account.originalNumber, otherAccount.number) ||
          eqNotUndefined(account.number, otherAccount.originalNumber)
      )
      if (matchOriginalNumber) {
        return {
          match: matchOriginalNumber,
          method: 'originalNumber-exact'
        }
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      const matchRawNumberCurrencyType = existingAccounts.find(
        otherAccount =>
          (eqNotUndefined(account.rawNumber, otherAccount.number) ||
            eqNotUndefined(account.number, otherAccount.rawNumber)) &&
          otherAccount.type == account.type &&
          otherAccount.currency == account.currency
      )
      if (matchRawNumberCurrencyType) {
        return {
          match: matchRawNumberCurrencyType,
          method: 'rawNumber-exact-currency-type'
        }
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      // Now we get more fuzzy and score accounts
      const scored = sortBy(
        existingAccounts.map(existingAccount => score(account, existingAccount)),
        x => -x.points
      )
      const candidates = scored.filter(x => x.points > 0)
      if (candidates.length > 0) {
        return {
          match: candidates[0].account,
          method: candidates[0].methods.join('-')
        }
    
    /**
     * Matches existing accounts with accounts fetched on a vendor
     *
     * @typedef {MatchResult}
     * @property {io.cozy.account} account - Account from fetched accounts
     * @property {io.cozy.account} match - Existing account that was matched. Null if no match was found.
     * @property {string} method - How the two accounts were matched
     *
     * @param  {io.cozy.account} fetchedAccounts - Account that have been fetched
     * on the vendor and that will be matched with existing accounts
     * @param  {io.cozy.accounts} existingAccounts - Will be match against (those
     * io.cozy.accounts already have an _id)
     * @return {Array<MatchResult>} - Match results (as many results as fetchedAccounts.length)
     */
    const matchAccounts = (fetchedAccountsArg, existingAccounts) => {
      const fetchedAccounts = fetchedAccountsArg.map(normalizeAccount)
      const toMatch = [...existingAccounts].map(normalizeAccount)
      const results = []
      for (let fetchedAccount of fetchedAccounts) {
        const matchResult = findMatch(fetchedAccount, toMatch)
        if (matchResult) {
          const i = toMatch.indexOf(matchResult.match)
          toMatch.splice(i, 1)
          results.push({ account: fetchedAccount, ...matchResult })
        } else {
          results.push({ account: fetchedAccount })
        }
    
    Romain CREY's avatar
    Romain CREY committed
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    module.exports = {
      matchAccounts,
      normalizeAccountNumber,
      score,
      creditCardMatch,
      approxNumberMatch
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /***/ }),
    
    /* 1614 */
    /***/ (function(module, exports) {
    
    const eitherIncludes = (str1, str2) => {
      return Boolean(str1 && str2 && (str1.includes(str2) || str2.includes(str1)))
    }
    
    module.exports = {
      eitherIncludes
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /***/ }),
    
    Romain CREY's avatar
    Romain CREY committed
    /***/ (function(module, exports, __webpack_require__) {
    
    
    const log = __webpack_require__(2).namespace('slug-account')
    const labelSlugs = __webpack_require__(1616)
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const institutionLabelsCompiled = Object.entries(labelSlugs).map(
      ([ilabelRx, slug]) => {
        if (ilabelRx[0] === '/' && ilabelRx[ilabelRx.length - 1] === '/') {
          return [new RegExp(ilabelRx.substr(1, ilabelRx.length - 2), 'i'), slug]
        } else {
          return [ilabelRx, slug]
        }
      }
    )
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const getSlugFromInstitutionLabel = institutionLabel => {
      if (!institutionLabel) {
        log('warn', 'No institution label, cannot compute slug')
        return
      }
      for (const [rx, slug] of institutionLabelsCompiled) {
        if (rx instanceof RegExp) {
          const match = institutionLabel.match(rx)
          if (match) {
            return slug
          }
        } else if (rx.toLowerCase() === institutionLabel.toLowerCase()) {
          return slug
        }
      }
      log('warn', `Could not compute slug for ${institutionLabel}`)
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    module.exports = {
      getSlugFromInstitutionLabel
    }
    
    /***/ }),
    /* 1616 */
    /***/ (function(module, exports) {
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    module.exports = {
      'AXA Banque': 'axabanque102',
      '/Banque Populaire.*/': 'banquepopulaire',
      BforBank: 'bforbank97',
      'BNP Paribas': 'bnpparibas82',
      BNPP: 'bnpparibas82',
      '/Boursorama.*/': 'boursorama83',
      casden: 'casden173',
      '/Hello bank!.*/': 'hellobank145',
      Bred: 'bred',
      CA: 'caatlantica3',
      'Carrefour Banque': 'carrefour159',
      "/Caisse d'Épargne.*/": 'caissedepargne1',
      'Compte Nickel': 'comptenickel168',
      '/^CIC.*/': 'cic63',
      'Crédit Agricole': 'caatlantica3',
      'Crédit Coopératif': 'creditcooperatif148',
      '/Crédit du Nord.*/': 'cdngroup88',
      '/Crédit Maritime.*/': 'creditmaritime',
      '/Crédit Mutuel.*/': 'cic45',
      '/Linxea/': 'linxea',
      Fortuneo: 'fortuneo84',
      'Hello bank!': 'hellobank145',
      'HSBC France': 'hsbc119',
      HSBC: 'hsbc119',
      '/^ING.*/': 'ingdirect95',
      '/La Banque Postale.*/': 'labanquepostale44',
      '/LCL.*/': 'lcl-linxo',
      Milleis: 'barclays136',
      Monabanq: 'monabanq96',
      'Société Générale': 'societegenerale',
      'Société marseillaise de crédit': 'cdngroup109'
    }
    
    /***/ }),
    /* 1617 */
    /***/ (function(module, exports, __webpack_require__) {
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const fromPairs = __webpack_require__(1570)
    const log = __webpack_require__(2).namespace('BankingReconciliator')
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    class BankingReconciliator {
      constructor(options) {
        this.options = options
    
    Romain CREY's avatar
    Romain CREY committed
      }
    
    
      async saveAccounts(fetchedAccounts, options) {
        const { BankAccount } = this.options
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        const stackAccounts = await BankAccount.fetchAll()
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        // Reconciliate
        const reconciliatedAccounts = BankAccount.reconciliate(
          fetchedAccounts,
          stackAccounts
        )
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        log('info', 'Saving accounts...')
        const savedAccounts = await BankAccount.bulkSave(reconciliatedAccounts, {
          handleDuplicates: 'remove'
        })
        if (options.onAccountsSaved) {
          options.onAccountsSaved(savedAccounts)
        }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        return { savedAccounts, reconciliatedAccounts }
    
    Romain CREY's avatar
    Romain CREY committed
      }
    
    
      /**
       * @typedef ReconciliatorResponse
       * @attribute {Array<BankAccount>} accounts
       * @attribute {Array<BankTransactions>} transactions
       */
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      /**
       * @typedef ReconciliatorSaveOptions
       * @attribute {Function} logProgress
       */
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      /**
       * Save new accounts and transactions
       *
       * @param {Array<BankAccount>} fetchedAccounts
       * @param {Array<BankTransactions>} fetchedTransactions
       * @param  {ReconciliatorSaveOptions} options
       * @returns {ReconciliatorResponse}
       *
       */
      async save(fetchedAccounts, fetchedTransactions, options = {}) {
        const { BankAccount, BankTransaction } = this.options
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        const { reconciliatedAccounts, savedAccounts } = await this.saveAccounts(
          fetchedAccounts,
          options
        )
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        // Bank accounts saved in Cozy, we can now link transactions to accounts
        // via their cozy id
        const vendorIdToCozyId = fromPairs(
          savedAccounts.map(acc => [acc[BankAccount.vendorIdAttr], acc._id])
        )
        log('info', 'Linking transactions to accounts...')
        log('info', JSON.stringify(vendorIdToCozyId))
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        fetchedTransactions.forEach(tr => {
          tr.account = vendorIdToCozyId[tr[BankTransaction.vendorAccountIdAttr]]
          if (tr.account === undefined) {
            log(
              'warn',
              `Transaction without account, vendorAccountIdAttr: ${BankTransaction.vendorAccountIdAttr}`
            )
            log('warn', 'transaction: ' + JSON.stringify(tr))
            throw new Error('Transaction without account.')
          }
        })
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        const reconciliatedAccountIds = new Set(
          reconciliatedAccounts.filter(acc => acc._id).map(acc => acc._id)
        )
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        // Pass to transaction reconciliation only transactions that belong
        // to one of the reconciliated accounts
        const stackTransactions = (await BankTransaction.fetchAll()).filter(
          transaction => reconciliatedAccountIds.has(transaction.account)
        )
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        const transactions = BankTransaction.reconciliate(
          fetchedTransactions,
          stackTransactions,
          options
        )
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        log('info', 'Saving transactions...')
        let i = 1
        const logProgressFn = doc => {
          log('debug', `[bulkSave] ${i++} Saving ${doc.date} ${doc.label}`)
        }
        const savedTransactions = await BankTransaction.bulkSave(transactions, {
          concurrency: 30,
          logProgress:
            options.logProgress !== undefined ? options.logProgress : logProgressFn,
          handleDuplicates: 'remove'
        })
        return {
          accounts: savedAccounts,
          transactions: savedTransactions
        }
      }
    
    Romain CREY's avatar
    Romain CREY committed
    }
    
    
    module.exports = BankingReconciliator
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /***/ }),
    
    /* 1618 */
    /***/ (function(module, exports, __webpack_require__) {
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const keyBy = __webpack_require__(1619)
    const groupBy = __webpack_require__(1579)
    const maxBy = __webpack_require__(1620)
    const addDays = __webpack_require__(1623)
    const isAfter = __webpack_require__(1627)
    const Document = __webpack_require__(1393)
    const log = __webpack_require__(1601)
    const BankAccount = __webpack_require__(1604)
    const { matchTransactions } = __webpack_require__(1628)
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const maxValue = (iterable, fn) => {
      const res = maxBy(iterable, fn)
      return res ? fn(res) : null
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const getDate = transaction => {
      const date = transaction.realisationDate || transaction.date
      return date.slice(0, 10)
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /**
     * Get the date of the latest transaction in an array.
     * Transactions in the future are ignored.
     *
     * @param {array} stackTransactions
     * @returns {string} The date of the latest transaction (YYYY-MM-DD)
     */
    const getSplitDate = stackTransactions => {
      const now = new Date()
      const notFutureTransactions = stackTransactions.filter(transaction => {
        const date = getDate(transaction)
        return !isAfter(date, now)
      })
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      return maxValue(notFutureTransactions, getDate)
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    const ensureISOString = date => {
      if (date instanceof Date) {
        return date.toISOString()
      } else {
        return date
      }
    }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    class Transaction extends Document {
      static getDate(transaction) {
        return transaction
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      isAfter(minDate) {
        if (!minDate) {
          return true
        } else {
          const day = ensureISOString(this.date).slice(0, 10)
          if (day !== 'NaN') {
            return day > minDate
          } else {
            log(
              'warn',
              'transaction date could not be parsed. transaction: ' +
                JSON.stringify(this)
            )
            return false
          }
        }
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      isBeforeOrSame(maxDate) {
        if (!maxDate) {
          return true
        } else {
          const day = ensureISOString(this.date).slice(0, 10)
          if (day !== 'NaN') {
            return day <= maxDate
          } else {
            log(
              'warn',
              'transaction date could not be parsed. transaction: ' +
                JSON.stringify(this)
            )
            return false
    
    Romain CREY's avatar
    Romain CREY committed
          }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      /**
       * Get the descriptive (and almost uniq) identifier of a transaction
       * @param {object} transaction - The transaction (containing at least amount, originalBankLabel and date)
       * @returns {object}
       */
      getIdentifier() {
        return `${this.amount}-${this.originalBankLabel}-${this.date}`
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      /**
       * Get transactions that should be present in the stack but are not.
       * Transactions that are older that 1 week before the oldest existing
       * transaction are ignored.
       *
       * @param {array} newTransactions
       * @param {array} stackTransactions
       * @returns {array}
       */
      static getMissedTransactions(
        newTransactions,
        stackTransactions,
        options = {}
      ) {
        const oldestDate = maxValue(stackTransactions, getDate)
        const frontierDate = addDays(oldestDate, -7)
        const recentNewTransactions = newTransactions.filter(tr =>
          isAfter(getDate(tr), frontierDate)
        )
        const matchingResults = Array.from(
          matchTransactions(recentNewTransactions, stackTransactions)
        )
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        const missedTransactions = matchingResults
          .filter(result => !result.match)
          .map(result => result.transaction)
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        const trackEvent = options.trackEvent
        if (typeof trackEvent === 'function') {
          try {
            const nbMissed = missedTransactions.length
            const nbExisting = stackTransactions.length
            trackEvent({
              e_a: 'ReconciliateMissing',
              e_n: 'MissedTransactionPct',
              e_v: parseFloat((nbMissed / nbExisting).toFixed(2), 10)
            })
            trackEvent({
              e_a: 'ReconciliateMissing',
              e_n: 'MissedTransactionAbs',
              e_v: nbMissed
            })
          } catch (e) {
            log('warn', `Could not send MissedTransaction event: ${e.message}`)
          }
        }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        return missedTransactions
      }
    
      static reconciliate(remoteTransactions, localTransactions, options = {}) {
        const findByVendorId = transaction =>
          localTransactions.find(t => t.vendorId === transaction.vendorId)
    
        const groups = groupBy(remoteTransactions, transaction =>
          findByVendorId(transaction) ? 'updatedTransactions' : 'newTransactions'
        )
    
        let newTransactions = groups.newTransactions || []
        const updatedTransactions = groups.updatedTransactions || []
    
        const splitDate = getSplitDate(localTransactions)
    
        if (splitDate) {
          if (typeof options.trackEvent === 'function') {
            options.trackEvent({
              e_a: 'ReconciliateSplitDate'
            })
    
    Romain CREY's avatar
    Romain CREY committed
          }
    
    
          const isAfterSplit = x => Transaction.prototype.isAfter.call(x, splitDate)
          const isBeforeSplit = x =>
            Transaction.prototype.isBeforeOrSame.call(x, splitDate)
    
    Romain CREY's avatar
    Romain CREY committed
    
    
          const transactionsAfterSplit = newTransactions.filter(isAfterSplit)
    
          if (transactionsAfterSplit.length > 0) {
            log(
              'info',
              `Found ${transactionsAfterSplit.length} transactions after ${splitDate}`
            )
          } else {
            log('info', `No transaction after ${splitDate}`)
    
    Romain CREY's avatar
    Romain CREY committed
          }
    
    
          const transactionsBeforeSplit = newTransactions.filter(isBeforeSplit)
          log(
            'info',
            `Found ${transactionsBeforeSplit.length} transactions before ${splitDate}`
          )
    
    Romain CREY's avatar
    Romain CREY committed
    
    
          const missedTransactions = Transaction.getMissedTransactions(
            transactionsBeforeSplit,
            localTransactions,
            options
          )
    
          if (missedTransactions.length > 0) {
            log(
              'info',
              `Found ${missedTransactions.length} missed transactions before ${splitDate}`
            )
          } else {
            log('info', `No missed transactions before ${splitDate}`)
    
    Romain CREY's avatar
    Romain CREY committed
          }
    
    
          newTransactions = [...transactionsAfterSplit, ...missedTransactions]
        } else {
          log('info', "Can't find a split date, saving all new transactions")
    
    Romain CREY's avatar
    Romain CREY committed
        }
    
    
        log(
          'info',
          `Transaction reconciliation: new ${newTransactions.length}, updated ${updatedTransactions.length}, split date ${splitDate} `
        )
        return [].concat(newTransactions).concat(updatedTransactions)
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      static async getMostRecentForAccounts(accountIds) {
        try {
          log('debug', 'Transaction.getLast')
    
    Romain CREY's avatar
    Romain CREY committed
    
    
          const index = await Document.getIndex(this.doctype, ['date', 'account'])
          const options = {
            selector: {
              date: { $gte: null },
              account: {
                $in: accountIds
              }
            },
            sort: [{ date: 'desc' }]
          }
          const transactions = await Document.query(index, options)
          log('info', 'last transactions length: ' + transactions.length)
    
    Romain CREY's avatar
    Romain CREY committed
    
    
          return transactions
        } catch (e) {
          log('error', e)
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    Romain CREY's avatar
    Romain CREY committed
        }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      static async deleteOrphans() {
        log('info', 'Deleting up orphan operations')
        const accounts = keyBy(await BankAccount.fetchAll(), '_id')
        const operations = await this.fetchAll()
        const orphanOperations = operations.filter(x => !accounts[x.account])
        log('info', `Total number of operations: ${operations.length}`)
        log('info', `Total number of orphan operations: ${orphanOperations.length}`)
        log('info', `Deleting ${orphanOperations.length} orphan operations...`)
        if (orphanOperations.length > 0) {
          return this.deleteAll(orphanOperations)
    
      getVendorAccountId() {
        return this[this.constructor.vendorAccountIdAttr]
      }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
      static getCategoryId(transaction, options) {
        const opts = {
          localModelOverride: false,
          localModelUsageThreshold: this.LOCAL_MODEL_USAGE_THRESHOLD,
          globalModelUsageThreshold: this.GLOBAL_MODEL_USAGE_THRESHOLD,
          ...options
        }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        if (transaction.manualCategoryId) {
          return transaction.manualCategoryId
        }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        if (
          opts.localModelOverride &&
          transaction.localCategoryId &&
          transaction.localCategoryProba &&
          transaction.localCategoryProba > opts.localModelUsageThreshold
        ) {
          return transaction.localCategoryId
        }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        if (
          transaction.cozyCategoryId &&
          transaction.cozyCategoryProba &&
          transaction.cozyCategoryProba > opts.globalModelUsageThreshold
        ) {
          return transaction.cozyCategoryId
        }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        // If the cozy categorization models have not been applied, we return null
        // so the transaction is considered as « categorization in progress ».
        // Otherwize we just use the automatic categorization from the vendor
        if (!transaction.localCategoryId && !transaction.cozyCategoryId) {
          return null
        }
    
    Romain CREY's avatar
    Romain CREY committed
    
    
        return transaction.automaticCategoryId
      }
    
    Romain CREY's avatar
    Romain CREY committed
    }
    
    Transaction.doctype = 'io.cozy.bank.operations'
    Transaction.version = 1
    Transaction.vendorAccountIdAttr = 'vendorAccountId'
    Transaction.vendorIdAttr = 'vendorId'
    Transaction.idAttributes = ['vendorId']
    Transaction.checkedAttributes = [
      'label',
      'originalBankLabel',
      'automaticCategoryId',
      'account'
    ]
    Transaction.LOCAL_MODEL_USAGE_THRESHOLD = 0.8
    Transaction.GLOBAL_MODEL_USAGE_THRESHOLD = 0.15
    Transaction.getSplitDate = getSplitDate
    
    module.exports = Transaction
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /***/ }),
    
    Romain CREY's avatar
    Romain CREY committed
    /***/ (function(module, exports, __webpack_require__) {
    
    
    var baseAssignValue = __webpack_require__(1443),
        createAggregator = __webpack_require__(1580);
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /**
     * Creates an object composed of keys generated from the results of running
     * each element of `collection` thru `iteratee`. The corresponding value of
     * each key is the last element responsible for generating the key. The
     * iteratee is invoked with one argument: (value).
     *
     * @static
     * @memberOf _
     * @since 4.0.0
     * @category Collection
     * @param {Array|Object} collection The collection to iterate over.
     * @param {Function} [iteratee=_.identity] The iteratee to transform keys.
     * @returns {Object} Returns the composed aggregate object.
     * @example
     *
     * var array = [
     *   { 'dir': 'left', 'code': 97 },
     *   { 'dir': 'right', 'code': 100 }
     * ];
     *
     * _.keyBy(array, function(o) {
     *   return String.fromCharCode(o.code);
     * });
     * // => { 'a': { 'dir': 'left', 'code': 97 }, 'd': { 'dir': 'right', 'code': 100 } }
     *
     * _.keyBy(array, 'dir');
     * // => { 'left': { 'dir': 'left', 'code': 97 }, 'right': { 'dir': 'right', 'code': 100 } }
     */
    var keyBy = createAggregator(function(result, value, key) {
      baseAssignValue(result, key, value);
    });
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    module.exports = keyBy;
    
    /***/ }),
    /* 1620 */
    /***/ (function(module, exports, __webpack_require__) {
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    var baseExtremum = __webpack_require__(1621),
        baseGt = __webpack_require__(1622),
        baseIteratee = __webpack_require__(1545);
    
    Romain CREY's avatar
    Romain CREY committed
    
    
    /**
     * This method is like `_.max` except that it accepts `iteratee` which is
     * invoked for each element in `array` to generate the criterion by which
     * the value is ranked. The iteratee is invoked with one argument: (value).
     *
     * @static
     * @memberOf _
     * @since 4.0.0
     * @category Math
     * @param {Array} array The array to iterate over.
     * @param {Function} [iteratee=_.identity] The iteratee invoked per element.
     * @returns {*} Returns the maximum value.
     * @example
     *
     * var objects = [{ 'n': 1 }, { 'n': 2 }];
     *
     * _.maxBy(objects, function(o) { return o.n; });
     * // => { 'n': 2 }
     *
     * // The `_.property` iteratee shorthand.
     * _.maxBy(objects, 'n');
     * // => { 'n': 2 }
     */
    function maxBy(array, iteratee) {