diff --git a/manifest.webapp b/manifest.webapp index bbf9076fcc..935c7289b1 100755 --- a/manifest.webapp +++ b/manifest.webapp @@ -115,8 +115,8 @@ "balance-lower": { "description": "Alert the user when his account balance is lower than a certain value", "collapsible": true, - "stateful": false, - "multiple": false, + "stateful": true, + "multiple": true, "default_priority": "normal", "templates": {} }, @@ -154,9 +154,9 @@ }, "budget-alerts": { "description": "Alert the user when sum of expenses goes higher than defined in settings", - "collapsible": false, - "stateful": false, - "multiple": false, + "collapsible": true, + "stateful": true, + "multiple": true, "default_priority": "normal", "templates": {} }, diff --git a/src/ducks/appSuggestions/services.js b/src/ducks/appSuggestions/services.js index b01d45568b..e30937b2ba 100644 --- a/src/ducks/appSuggestions/services.js +++ b/src/ducks/appSuggestions/services.js @@ -1,7 +1,7 @@ import logger from 'cozy-logger' import { findMatchingBrand, getNotInstalledBrands } from 'ducks/brandDictionary' import { getLabel } from 'ducks/transactions/helpers' -import { getKonnectorFromTrigger } from 'utils/triggers' +import { trigger as triggerLibs } from 'cozy-client/dist/models' import { BankTransaction } from 'cozy-doctypes' import AppSuggestion from './AppSuggestion' import Trigger from './Trigger' @@ -11,6 +11,7 @@ import get from 'lodash/get' import set from 'lodash/set' const log = logger.namespace('app-suggestions') +const { getKonnector } = triggerLibs.triggers export const findSuggestionForTransaction = ( transaction, @@ -90,7 +91,7 @@ export const findAppSuggestions = async setting => { set(setting, 'appSuggestions.lastSeq', transactionsToCheck.newLastSeq) log('info', 'Get not installed brands') - const installedSlugs = triggers.map(getKonnectorFromTrigger) + const installedSlugs = triggers.map(getKonnector) const brands = getNotInstalledBrands(installedSlugs) log('info', `${brands.length} not installed brands`) diff --git a/src/ducks/appSuggestions/services.spec.js b/src/ducks/appSuggestions/services.spec.js index f0fedee51c..ed10cb7c3e 100644 --- a/src/ducks/appSuggestions/services.spec.js +++ b/src/ducks/appSuggestions/services.spec.js @@ -1,6 +1,19 @@ -import { findSuggestionForTransaction, normalizeSuggestions } from './services' +import { + findSuggestionForTransaction, + normalizeSuggestions, + findAppSuggestions +} from './services' import { TRANSACTION_DOCTYPE } from '../../doctypes' import { getBrands } from '../brandDictionary' +import { Document } from 'cozy-doctypes' +import CozyClient from 'cozy-client' +import fetch from 'node-fetch' +global.fetch = fetch + +const client = new CozyClient({ + uri: 'http://localhost:8080' +}) +Document.registerClient(client) describe('findSuggestionForTransaction', () => { const brands = getBrands() @@ -87,3 +100,11 @@ describe('normalizeSuggestions', () => { expect(normalizeSuggestions(suggestions)).toMatchSnapshot() }) }) + +describe('findAppSuggestions', () => { + it('should work with empty data', async () => { + Document.fetchAll = jest.fn().mockResolvedValue([]) + Document.fetchChanges = jest.fn().mockResolvedValue({ documents: [] }) + await findAppSuggestions({}) + }) +}) diff --git a/src/ducks/budgetAlerts/CategoryBudgetNotificationView.js b/src/ducks/budgetAlerts/CategoryBudgetNotificationView.js index 9fd808f469..37bbf49ef9 100644 --- a/src/ducks/budgetAlerts/CategoryBudgetNotificationView.js +++ b/src/ducks/budgetAlerts/CategoryBudgetNotificationView.js @@ -1,6 +1,8 @@ import sumBy from 'lodash/sumBy' import keyBy from 'lodash/keyBy' import merge from 'lodash/merge' +import sortBy from 'lodash/sortBy' +import formatDate from 'date-fns/format' import { ACCOUNT_DOCTYPE, GROUP_DOCTYPE } from 'doctypes' import NotificationView from 'ducks/notifications/BaseNotificationView' @@ -67,6 +69,9 @@ const transformForTemplate = (budgetAlert, t, accountsById, groupsById) => { } } +const formatBudgetAlertToCategoryId = budgetAlert => + `${budgetAlert.categoryId}:${budgetAlert.maxThreshold}` + class CategoryBudget extends NotificationView { constructor(options) { super(options) @@ -112,6 +117,8 @@ class CategoryBudget extends NotificationView { : null } + this.templateData = data + return data } @@ -149,10 +156,25 @@ class CategoryBudget extends NotificationView { } getExtraAttributes() { + if (!this.templateData) { + return + } + const { budgetAlerts } = this.templateData return merge(super.getExtraAttributes(), { data: { route: '/analysis/categories' - } + }, + categoryId: budgetAlerts.map(formatBudgetAlertToCategoryId).join(','), + state: JSON.stringify({ + budgetAlerts: sortBy( + budgetAlerts, + budgetAlert => budgetAlert.categoryId + ).map(budgetAlert => ({ + categoryId: budgetAlert.categoryId, + date: formatDate(new Date(), 'YYYY-MM-DD'), + maxThreshold: budgetAlert.maxThreshold + })) + }) }) } } diff --git a/src/ducks/notifications/BalanceLower/index.js b/src/ducks/notifications/BalanceLower/index.js index 63c85cf304..cb301a774f 100644 --- a/src/ducks/notifications/BalanceLower/index.js +++ b/src/ducks/notifications/BalanceLower/index.js @@ -3,11 +3,13 @@ import flatten from 'lodash/flatten' import uniqBy from 'lodash/uniqBy' import groupBy from 'lodash/groupBy' import map from 'lodash/map' +import mapValues from 'lodash/mapValues' import merge from 'lodash/merge' import log from 'cozy-logger' import { getAccountBalance } from 'ducks/account/helpers' import { getCurrencySymbol } from 'utils/currencySymbol' import { getCurrentDate } from 'ducks/notifications/utils' +import { isNew as isNewTransaction } from 'ducks/transactions/helpers' import template from './template.hbs' import { toText } from 'cozy-notifications' import { ruleAccountFilter } from 'ducks/settings/ruleUtils' @@ -53,6 +55,8 @@ const customToText = cozyHTMLEmail => { return toText(cozyHTMLEmail, getContent) } +const byIdSorter = (acc1, acc2) => (acc1._id > acc2._id ? 1 : -1) + class BalanceLower extends NotificationView { constructor(config) { super(config) @@ -74,13 +78,22 @@ class BalanceLower extends NotificationView { * Rules that do not match any accounts are discarded */ findMatchingRules() { + const nbNewTransactionsByAccountId = mapValues( + groupBy( + this.data.transactions.filter( + process.env.NODE_ENV === 'test' ? () => true : isNewTransaction + ), + tr => tr.account + ), + transactions => transactions.length + ) return this.rules .filter(rule => rule.enabled) .map(rule => ({ rule, - accounts: this.data.accounts.filter(acc => - this.filterForRule(rule, acc) - ) + accounts: this.data.accounts + .filter(account => nbNewTransactionsByAccountId[account._id] > 0) + .filter(acc => this.filterForRule(rule, acc)) })) .filter(({ accounts }) => accounts.length > 0) } @@ -111,20 +124,42 @@ class BalanceLower extends NotificationView { log('info', `BalanceLower: ${accounts.length} accountsFiltered`) - return { + this.templateData = { matchingRules, accounts, institutions: groupAccountsByInstitution(accounts), date: getCurrentDate(), ...this.urls } + + return this.templateData } getExtraAttributes() { return merge(super.getExtraAttributes(), { data: { route: '/balances' - } + }, + + // If there are new transactions for the account but the account balance + // does not change, there will be no alerts + state: JSON.stringify({ + accounts: this.templateData.accounts + .map(account => ({ + _id: account._id, + balance: account.balance + })) + .sort(byIdSorter) + }), + + // The category of the alert is made of the rule doc + the threshold + categoryId: this.templateData.matchingRules + .map(({ rule }) => + rule.accountOrGroup + ? `${rule.accountOrGroup._type}:${rule.accountOrGroup._id}:${rule.value}` + : `all:${rule.value}` + ) + .join(',') }) } diff --git a/src/ducks/notifications/BalanceLower/index.spec.js b/src/ducks/notifications/BalanceLower/index.spec.js index e8a9075230..01cf5f2444 100644 --- a/src/ducks/notifications/BalanceLower/index.spec.js +++ b/src/ducks/notifications/BalanceLower/index.spec.js @@ -47,6 +47,7 @@ describe('balance lower', () => { } ], data: { + transactions: operations, accounts: fixtures['io.cozy.bank.accounts'], groups: fixtures['io.cozy.bank.groups'] }, @@ -141,6 +142,21 @@ describe('balance lower', () => { expect(minValueBy(accounts, getAccountBalance)).toBeLessThan(500) expect(maxValueBy(accounts, getAccountBalance)).toBe(325.24) }) + + it('should compute correct state', async () => { + const { notification } = setup({ + value: 500, + accountOrGroup: isabelleGroup + }) + await notification.buildData() + const extraAttributes = await notification.getExtraAttributes() + expect(extraAttributes).toEqual( + expect.objectContaining({ + categoryId: 'io.cozy.bank.groups:isabelle:500', + state: '{"accounts":[{"_id":"comptelou1","balance":325.24}]}' + }) + ) + }) }) describe('notification content', () => { diff --git a/src/ducks/notifications/LateHealthReimbursement/index.js b/src/ducks/notifications/LateHealthReimbursement/index.js index 4c7387ec00..474d21ef0d 100644 --- a/src/ducks/notifications/LateHealthReimbursement/index.js +++ b/src/ducks/notifications/LateHealthReimbursement/index.js @@ -59,12 +59,12 @@ const customToText = cozyHTMLEmail => { } class LateHealthReimbursement extends NotificationView { - constructor(config) { - super(config) - this.interval = config.value + constructor(options) { + super(options) + this.interval = options.value } - async getTransactions() { + async fetchTransactions() { const DATE_FORMAT = 'YYYY-MM-DD' const today = new Date() const lt = formatDate(subDays(today, this.interval), DATE_FORMAT) @@ -123,14 +123,14 @@ class LateHealthReimbursement extends NotificationView { !isAlreadyNotified(lateReimbursement, LateHealthReimbursement) ) - log('info', `${toNotify} need to be notified`) + log('info', `${toNotify.length} need to be notified`) this.toNotify = toNotify return toNotify } - getAccounts(transactions) { + fetchAccounts(transactions) { const accountIds = uniq( transactions.map(transaction => transaction.account) ) @@ -138,7 +138,7 @@ class LateHealthReimbursement extends NotificationView { } async fetchData() { - const transactions = await this.getTransactions() + const transactions = await this.fetchTransactions() if (transactions.length === 0) { log('info', 'No late health reimbursement') @@ -148,7 +148,7 @@ class LateHealthReimbursement extends NotificationView { log('info', `${transactions.length} late health reimbursements`) log('info', 'Fetching accounts for late health reimbursements') - const accounts = await this.getAccounts(transactions) + const accounts = await this.fetchAccounts(transactions) log( 'info', `${accounts.length} accounts fetched for late health reimbursements` @@ -186,10 +186,13 @@ class LateHealthReimbursement extends NotificationView { } /** + * Saves last notification date to transactions for which there was + * the notification. + * * Executed by `Notification` when the notification has been successfuly sent * See `Notification::sendNotification` */ - async onSendNotificationSuccess() { + async onSuccess() { this.toNotify.forEach(reimb => { if (!reimb.cozyMetadata) { reimb.cozyMetadata = {} diff --git a/src/ducks/notifications/TransactionGreater/index.js b/src/ducks/notifications/TransactionGreater/index.js index a1b44b030c..991e21381b 100644 --- a/src/ducks/notifications/TransactionGreater/index.js +++ b/src/ducks/notifications/TransactionGreater/index.js @@ -27,6 +27,17 @@ const SINGLE_TRANSACTION = 'single' const MULTI_TRANSACTION = 'multi' const MULTI_TRANSACTION_MULTI_RULES = 'multi-rules' +/** + * @typedef {object} Rule + */ + +/** + * @typedef {object} RuleResult + * @property {Rule} rule - The rule being matched + * @property {Array} transactions - Transactions that matched the rule + * + */ + // During tests, it is difficult to keep transactions with // first _rev since we replace replace existing transactions, this // is why we deactivate the isNewTransaction during tests @@ -88,12 +99,22 @@ const makeAccountOrGroupFilter = (groups, accountOrGroup) => { } } +/** + * Sends a notification when a transaction amount is greater than + * a threshold. + */ class TransactionGreater extends NotificationView { constructor(config) { super(config) this.rules = config.rules } + /** + * Creates a filtering function from a rule + * + * @param {Rule} rule - A rule + * @return {function(Transaction): Boolean} - Predicates that check if a transaction matches rule + */ filterForRule(rule) { const fourDaysAgo = subDays(new Date(), 4) @@ -114,9 +135,10 @@ class TransactionGreater extends NotificationView { } /** - * Returns a list of [{ rule, transactions }] * For each rule, returns a list of matching transactions * Rules that do not match any transactions are discarded + * + * @return {Array} */ findMatchingRules() { return this.rules @@ -132,7 +154,7 @@ class TransactionGreater extends NotificationView { const { accounts } = this.data const matchingRules = this.findMatchingRules() const transactionsFiltered = uniqBy( - flatten(matchingRules.map(x => x.transactions)), + flatten(matchingRules.map(result => result.transactions)), getDocumentId ) return { @@ -189,6 +211,9 @@ class TransactionGreater extends NotificationView { } } + /** + * @return {string} - The title of the notification + */ getTitle(templateData) { const { transactions, matchingRules } = templateData const onlyOne = transactions.length === 1 diff --git a/src/ducks/notifications/TransactionGreater/index.spec.js b/src/ducks/notifications/TransactionGreater/index.spec.js index a4be65b6ad..e19f670779 100644 --- a/src/ducks/notifications/TransactionGreater/index.spec.js +++ b/src/ducks/notifications/TransactionGreater/index.spec.js @@ -15,10 +15,7 @@ const addId = doc => { return { ...doc, _id: doc._id || Math.random().toString() } } -const prepareTransactionForTest = compose( - addRev, - addId -) +const prepareTransactionForTest = compose(addRev, addId) const unique = arr => Array.from(new Set(arr)) @@ -119,16 +116,17 @@ describe('transaction greater', () => { it('should compute relevant transactions', async () => { const { notification } = setup() const { transactions } = await notification.buildData() - expect(transactions).toHaveLength(116) + expect(transactions).toHaveLength(117) }) it('should compute relevant transactions for a different value', async () => { const { notification } = setup({ value: 100 }) const { transactions } = await notification.buildData() - expect(transactions).toHaveLength(22) + expect(transactions).toHaveLength(23) expect(unique(transactions.map(getAccountIDFromTransaction))).toEqual([ 'compteisa1', 'compteisa3', - 'comptegene1' + 'comptegene1', + 'compteisa4' ]) }) }) diff --git a/src/ducks/notifications/services.js b/src/ducks/notifications/services.js index b8d4154c6d..9301b84985 100644 --- a/src/ducks/notifications/services.js +++ b/src/ducks/notifications/services.js @@ -124,6 +124,13 @@ export const sendNotificationForClass = async ( } } +/** + * Fetches relevant data, instantiates enabled notification classes and + * sends push notifications + * + * @param {object} config - io.cozy.bank.settings document + * @param {object} transactions - Transactions that have changed + */ export const sendNotifications = async (config, transactions) => { const enabledNotificationClasses = getEnabledNotificationClasses(config) const client = CozyClient.fromEnv(process.env) diff --git a/test/fixtures/unit-tests.json b/test/fixtures/unit-tests.json index 16830464df..c4dacb3663 100644 --- a/test/fixtures/unit-tests.json +++ b/test/fixtures/unit-tests.json @@ -1435,6 +1435,15 @@ "currency": "€", "date": "2018-06-21T00:00:00Z", "label": "maintenance" + }, + { + "_id": "soldeinitialcompteisa4", + "account": "compteisa4", + "amount": 10000, + "manualCategoryId": "400140", + "currency": "€", + "date": "2018-06-21T00:00:00Z", + "label": "Solde initial" } ], "io.cozy.bills": [