Commit 093d3f13 authored by ZeHiro's avatar ZeHiro Committed by Benjamin Bouvier
Browse files

Automatic balance resync. Fixes #190 and #141.

parent 6d4e6383
......@@ -5,8 +5,8 @@ import { translate as $t } from '../../../helpers';
import { actions, get } from '../../../store';
import ConfirmDeleteModal from '../../ui/confirm-delete-modal';
import AddOperationModal from './add-operation-modal';
import SyncAccountBalanceModal from './sync-account-balance-modal';
export default connect(state => {
let defaultAccountId = get.setting(state, 'defaultAccountId');
......@@ -59,6 +59,11 @@ export default connect(state => {
data-target={ `#addOperation${a.id}` }
title={ $t('client.settings.add_operation') }>
</span>
<span className="pull-right fa fa-cog" aria-label="Resync account balance"
data-toggle="modal"
data-target={ `#syncBalanceModal${a.id}` }
title={ $t('client.settings.resync_account_button') }>
</span>
<ConfirmDeleteModal
modalId={ `confirmDeleteAccount${a.id}` }
modalBody={ $t('client.settings.erase_account', { title: a.title }) }
......@@ -66,6 +71,11 @@ export default connect(state => {
/>
<AddOperationModal
account={ a }
modalId={ `addOperation${a.id}` }
/>
<SyncAccountBalanceModal
account={ a }
modalId ={ `syncBalanceModal${a.id}` }
/>
</td>
</tr>
......
......@@ -3,12 +3,9 @@ import { connect } from 'react-redux';
import { actions, get } from '../../../store';
import {
assertHas,
translate as $t,
NONE_CATEGORY_ID,
UNKNOWN_OPERATION_TYPE
} from '../../../helpers';
import { translate as $t,
NONE_CATEGORY_ID,
UNKNOWN_OPERATION_TYPE } from '../../../helpers';
import CategorySelect from '../../operations/category-select';
import OperationTypeSelect from '../../operations/type-select';
......@@ -20,7 +17,6 @@ import ValidableInputDate from '../../ui/checked-date';
class AddOperationModal extends React.Component {
constructor(props) {
assertHas(props, 'account');
super(props);
this.state = this.makeClearState();
......@@ -76,7 +72,7 @@ class AddOperationModal extends React.Component {
}
render() {
let modalId = `addOperation${this.props.account.id}`;
let modalId = this.props.modalId;
let labelDate = $t('client.addoperationmodal.date');
let labelTitle = $t('client.addoperationmodal.label');
......@@ -166,6 +162,14 @@ class AddOperationModal extends React.Component {
}
}
AddOperationModal.propTypes = {
// Unique identifier of the modal
modalId: React.PropTypes.string.isRequired,
// The account for which the operation has to be added. instanceof Account
account: React.PropTypes.object.isRequired
};
let Export = connect(state => {
return {
categories: get.categories(state),
......
import React from 'react';
import { connect } from 'react-redux';
import { translate as $t } from '../../../helpers';
import { actions } from '../../../store';
import Modal from '../../ui/modal';
class SyncAccountBalanceModal extends React.Component {
render() {
let modalId = this.props.modalId;
let modalTitle = $t('client.settings.resync_account.title',
{ title: this.props.account.title });
let modalBody = (
<div>
{ $t('client.settings.resync_account.make_sure') }
<ul className="bullet">
<li>{ $t('client.settings.resync_account.sync_operations') }</li>
<li>{ $t('client.settings.resync_account.manage_duplicates') }</li>
<li>{ $t('client.settings.resync_account.add_operation') }</li>
<li>{ $t('client.settings.resync_account.delete_operation') }</li>
</ul>
{ $t('client.settings.resync_account.are_you_sure') }
</div>
);
let modalFooter = (
<div>
<input type="button" className="btn btn-default" data-dismiss="modal"
value={ $t('client.settings.resync_account.cancel') }
/>
<input type="button" className="btn btn-warning"
onClick = { this.props.handleResyncBalance }
value={ $t('client.settings.resync_account.submit') }
/>
</div>
);
return (
<Modal
key={ modalId }
modalId={ modalId }
modalBody={ modalBody }
modalTitle={ modalTitle }
modalFooter={ modalFooter }
/>
);
}
}
SyncAccountBalanceModal.propTypes = {
// Unique identifier of the modal
modalId: React.PropTypes.string.isRequired,
// The account to be resynced. (instanceof Account)
account: React.PropTypes.object.isRequired
};
let Export = connect(null, (dispatch, props) => {
return {
handleResyncBalance: () => {
actions.resyncBalance(dispatch, props.account.id);
}
};
})(SyncAccountBalanceModal);
export default Export;
......@@ -731,3 +731,7 @@ span.fa-star {
transform: rotate(360deg);
}
}
ul.bullet li {
list-style: disc inside;
}
......@@ -10,6 +10,7 @@ export const LOAD_ACCOUNTS = 'LOAD_ACCOUNTS';
export const LOAD_OPERATIONS = 'LOAD_OPERATIONS';
export const MERGE_OPERATIONS = 'MERGE_OPERATIONS';
export const RUN_ACCOUNTS_SYNC = 'RUN_ACCOUNTS_SYNC';
export const RUN_BALANCE_RESYNC = 'RUN_BALANCE_RESYNC';
export const RUN_SYNC = 'RUN_SYNC';
export const SET_OPERATION_CUSTOM_LABEL = 'SET_OPERATION_CUSTOM_LABEL';
export const SET_OPERATION_CATEGORY = 'SET_OPERATION_CATEGORY';
......
......@@ -65,6 +65,17 @@ export function deleteOperation(opId) {
});
}
export function resyncBalance(accountId) {
return new Promise((accept, reject) => {
$.ajax({
url: `accounts/${accountId}/resync-balance`,
type: 'GET',
success: data => accept(data.initialAmount),
error: xhrReject(reject)
});
});
}
export function deleteAccount(accountId) {
return new Promise((accept, reject) => {
$.ajax({
......
......@@ -36,7 +36,8 @@ import {
SET_OPERATION_TYPE,
RUN_ACCOUNTS_SYNC,
RUN_SYNC,
UPDATE_ALERT
UPDATE_ALERT,
RUN_BALANCE_RESYNC
} from './actions';
// Basic actions creators
......@@ -152,6 +153,14 @@ const basic = {
};
},
resyncBalance(accountId, initialAmount) {
return {
type: RUN_BALANCE_RESYNC,
accountId,
initialAmount
};
},
deleteAccount(accountId) {
return {
type: DELETE_ACCOUNT,
......@@ -314,6 +323,20 @@ export function deleteAccount(accountId) {
};
}
export function resyncBalance(accountId) {
assert(typeof accountId === 'string', 'deleteAccount expects a string id');
return dispatch => {
dispatch(basic.resyncBalance(accountId));
backend.resyncBalance(accountId)
.then(initialAmount => {
dispatch(success.resyncBalance(accountId, initialAmount));
}).catch(err => {
dispatch(fail.resyncBalance(err, accountId));
});
};
}
function loadAccounts(accessId) {
return dispatch => {
dispatch(basic.loadAccounts(accessId));
......@@ -758,6 +781,23 @@ function reduceDeleteOperation(state, action) {
return state;
}
function reduceResyncBalance(state, action) {
let { status, accountId } = action;
if (status === SUCCESS) {
debug('Successfully resynced balance.');
let { initialAmount } = action;
let s = u.updateIn('accounts', updateMapIf('id', accountId, u({ initialAmount })), state);
return u({ isSyncing: false }, s);
}
if (status === FAIL) {
debug('Failure when syncing balance:', action.error);
return u({ isSyncing: false }, state);
}
debug('Starting account balance resync...');
return u({ isSyncing: true }, state);
}
function reduceDeleteAccountInternal(state, accountId) {
let { accountNumber, bankAccess } = accountById(state, accountId);
......@@ -979,6 +1019,7 @@ const reducers = {
LOAD_ACCOUNTS: reduceLoadAccounts,
LOAD_OPERATIONS: reduceLoadOperations,
MERGE_OPERATIONS: reduceMergeOperations,
RUN_BALANCE_RESYNC: reduceResyncBalance,
RUN_ACCOUNTS_SYNC: reduceRunAccountsSync,
RUN_SYNC: reduceRunSync,
SET_ACCESS_ID: reduceSetCurrentAccessId,
......
......@@ -316,6 +316,11 @@ export const actions = {
dispatch(Bank.runAccountsSync(accessId));
},
resyncBalance(dispatch, accountId) {
assertDefined(dispatch);
dispatch(Bank.resyncBalance(accountId));
},
deleteAccount(dispatch, accountId) {
assertDefined(dispatch);
dispatch(Bank.deleteAccount(accountId));
......
......@@ -3,6 +3,7 @@ import Operation from '../models/operation';
import Access from '../models/access';
import Alert from '../models/alert';
import Config from '../models/config';
import AccountManager from '../lib/accounts-manager';
import { makeLogger, KError, asyncErr } from '../helpers';
......@@ -74,3 +75,14 @@ export async function getOperations(req, res) {
return asyncErr(res, err, 'when getting operations for a bank account');
}
}
export async function resyncBalance(req, res) {
try {
let account = req.preloaded.account;
let accountManager = new AccountManager();
let updatedAccount = await accountManager.resyncBalanceOfAccount(account);
res.status(200).send(updatedAccount);
} catch (err) {
return asyncErr(res, err, 'when getting balance of a bank account');
}
}
......@@ -50,6 +50,9 @@ module.exports = {
'accounts/:accountId/operations': {
get: accounts.getOperations
},
'accounts/:accountId/resync-balance': {
get: accounts.resyncBalance
},
// Categories
'categories': {
......
......@@ -3,6 +3,7 @@ import moment from 'moment';
import Operation from '../models/operation';
import Alert from '../models/alert';
import Account from '../models/account';
import Access from '../models/access';
import OperationType from '../models/operationtype';
import { KError, getErrorCode, makeLogger, translate as $t,
......@@ -123,19 +124,14 @@ function sumOpAmounts(acc, op) {
export default class AccountManager {
constructor() {
this.newAccountsMap = new Map;
this.newAccountsMap = new Map();
}
async retrieveAndAddAccountsByAccess(access) {
return await this.retrieveAccountsByAccess(access, true);
return await this.retrieveNewAccountsByAccess(access, true);
}
async retrieveAccountsByAccess(access, shouldAddNewAccounts) {
if (this.newAccountsMap.size) {
log.warn(`At the start of retrieveAccountsByAccess, newAccountsMap
should be empty.`);
this.newAccountsMap.clear();
}
async retrieveAllAccountsByAccess(access) {
if (!access.hasPassword()) {
log.warn("Skipping accounts fetching -- password isn't present");
......@@ -163,6 +159,19 @@ should be empty.`);
}
log.info(`-> ${accounts.length} bank account(s) found`);
return accounts;
}
async retrieveNewAccountsByAccess(access, shouldAddNewAccounts) {
if (this.newAccountsMap.size) {
log.warn(`At the start of retrieveNewAccountsByAccess, newAccountsMap
should be empty.`);
this.newAccountsMap.clear();
}
let accounts = await this.retrieveAllAccountsByAccess(access);
let oldAccounts = await Account.byAccess(access);
for (let account of accounts) {
......@@ -335,4 +344,32 @@ offset of ${balanceOffset}.`);
await access.save();
log.info('Post process: done.');
}
async resyncBalanceOfAccount(account) {
let access = await Access.find(account.bankAccess);
// Note: we do not fetch operations before, because this can lead to duplicates,
// and compute a false initial balance
let accounts = await this.retrieveAllAccountsByAccess(access);
let retrievedAccount = accounts.find(acc => acc.accountNumber === account.accountNumber);
if (typeof retrievedAccount !== 'undefined') {
let realBalance = retrievedAccount.initialAmount;
let operations = await Operation.byAccount(account);
let operationsSum = operations.reduce((amount, op) => amount + op.amount, 0);
let kresusBalance = operationsSum + account.initialAmount;
if (Math.abs(realBalance - kresusBalance) > 0.01) {
log.info(`Updating balance for account ${account.accountNumber}`);
account.initialAmount = realBalance - operationsSum;
await account.save();
}
} else {
// This case can happen if the account was closed.
throw new KError('account not found', 404);
}
return account;
}
}
......@@ -124,7 +124,7 @@ class Poller
// Only import if last poll did not raise a login/parameter error.
if (access.canAccessBePolled()) {
if (checkAccounts) {
await accountManager.retrieveAccountsByAccess(access, false);
await accountManager.retrieveNewAccountsByAccess(access, false);
}
await accountManager.retrieveOperationsByAccess(access, cb);
} else {
......
......@@ -54,7 +54,7 @@ export let fetchAccounts = async (access) => {
{
accountNumber: main,
label: 'Compte chèque',
balance: '150',
balance: Math.random() * 150,
iban: '235711131719',
currency: 'EUR'
},
......
......@@ -204,6 +204,19 @@ module.exports = {
add_bank_button: 'Add a new bank access',
set_default_account: 'Set as default account',
add_operation: 'Add an operation',
resync_account_button: 'Resync the account balance',
resync_account: {
title: "Resync the balance of the account: %{title}",
submit: "Resync",
cancel: 'Cancel',
make_sure: "You are about to resync the balance of your account. Before that, ensure you have:",
sync_operations: "imported all the operations from your bank website",
manage_duplicates: "merged the duplicates",
add_operation: "manually added the missing operation(s)",
delete_operation: "deleted the extra operation(s)",
are_you_sure: "Are you sure you want to proceed?"
},
emails: {
invalid_limit: 'Limit value is invalid',
......
......@@ -204,6 +204,19 @@ module.exports = {
add_bank_button: 'Ajouter une banque',
set_default_account: 'Définir comme compte par défaut',
add_operation: 'Ajouter une opération',
resync_account_button: 'Resynchroniser le solde du compte',
resync_account: {
title: "Resynchroniser le solde du compte : %{title}",
submit: "Resynchroniser",
cancel: 'Annuler',
make_sure: "Vous êtes sur le point de resynchroniser le solde de ce compte avec le site web de votre banque. Avant d'aller plus loin, assurez-vous que vous avez bien :",
sync_operations: "importé toutes les opérations depuis le site web de votre banque",
manage_duplicates: "supprimé les doublons",
add_operation: "ajouté la ou les opérations manquantes",
delete_operation: "supprimé la ou les operations en trop",
are_you_sure: "Êtes vous sûr.e de vouloir continuer ?"
},
emails: {
invalid_limit: 'La valeur de seuil est invalide',
......@@ -236,7 +249,7 @@ module.exports = {
blog: 'Blog',
forum_thread: 'Sujet sur le forum de Cozy',
license: 'Licence',
sources: 'Sources'
sources: 'Sources',
},
similarity: {
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment