diff --git a/CHANGES.rst b/CHANGES.rst index b8f472b3..cf5e8c2d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,7 @@ Unreleased Changes * `Issue #198 `_ - Fix broken method of retrieving current US Prime Rate. Previously we used marketwatch.com for this but they've introduced javascript-based bot protection on their site (which is ironic since we were reading a value from the page's ``meta`` tags, which are specifically intended to be read by machines). Switch to using wsj.com instead and (ugh) parsing a HTML table. This *will* break when the format of the table changes. As previously, we cache this value in the DB for 48 hours in order to be a good citizen. * `Issue #197 `_ - Add notification for case where balance of all budget-funding accounts is *more* than sum of standing budgets, current payperiod remaining, and unreconciled. This is the opposite of the similar notification that already exists, intended to detect if there is money in accounts not accounted for in the budgets. * `Issue #196 `_ - Don't include inactive budgets in Budget select elements on Transaction Modal form, unless it's an existing Transaction using that budget. +* `Issue #204 `_ - Add support for account transfer between non-Credit accounts. * Many dependency updates: * Upgrade SQLAlchemy from 1.2.0 to 1.2.11 for `python 3 bug fix (4291) `_. diff --git a/biweeklybudget/flaskapp/static/js/account_transfer_modal.js b/biweeklybudget/flaskapp/static/js/account_transfer_modal.js index 7a496369..33a53b29 100644 --- a/biweeklybudget/flaskapp/static/js/account_transfer_modal.js +++ b/biweeklybudget/flaskapp/static/js/account_transfer_modal.js @@ -38,33 +38,33 @@ Jason Antman /** * Generate the HTML for the form on the Modal */ -function budgetTransferDivForm() { - return new FormBuilder('budgetTransferForm') - .addDatePicker('budg_txfr_frm_date', 'date', 'Date') - .addCurrency('budg_txfr_frm_amount', 'amount', 'Amount', { helpBlock: 'Transfer amount relative to from account; must be positive.' }) - .addLabelToValueSelect('budg_txfr_frm_account', 'account', 'Account', acct_names_to_id, 'None', true) - .addLabelToValueSelect('budg_txfr_frm_from_budget', 'from_budget', 'From Budget', active_budget_names_to_id, 'None', true) - .addLabelToValueSelect('budg_txfr_frm_to_budget', 'to_budget', 'To Budget', active_budget_names_to_id, 'None', true) - .addText('budg_txfr_frm_notes', 'notes', 'Notes') +function accountTransferDivForm() { + return new FormBuilder('accountTransferForm') + .addDatePicker('acct_txfr_frm_date', 'date', 'Date') + .addCurrency('acct_txfr_frm_amount', 'amount', 'Amount', { helpBlock: 'Transfer amount relative to from account; must be positive.' }) + .addLabelToValueSelect('acct_txfr_frm_budget', 'budget', 'Budget', active_budget_names_to_id, 'None', true) + .addLabelToValueSelect('acct_txfr_frm_from_account', 'from_account', 'From Account', acct_names_to_id, 'None', true) + .addLabelToValueSelect('acct_txfr_frm_to_account', 'to_account', 'To Account', acct_names_to_id, 'None', true) + .addText('acct_txfr_frm_notes', 'notes', 'Notes') .render(); } /** - * Show the modal popup for transferring between budgets. - * Uses :js:func:`budgetTransferDivForm` to generate the form. + * Show the modal popup for transferring between accounts. + * Uses :js:func:`accountTransferDivForm` to generate the form. * * @param {string} txfr_date - The date, as a "yyyy-mm-dd" string, to default * the form to. If null or undefined, will default to * ``BIWEEKLYBUDGET_DEFAULT_DATE``. */ -function budgetTransferModal(txfr_date) { +function accountTransferModal(txfr_date) { if (txfr_date === undefined || txfr_date === null) { txfr_date = isoformat(BIWEEKLYBUDGET_DEFAULT_DATE); } $('#modalBody').empty(); - $('#modalBody').append(budgetTransferDivForm()); - $('#budg_txfr_frm_date').val(txfr_date); - $('#budg_txfr_frm_date_input_group').datepicker({ + $('#modalBody').append(accountTransferDivForm()); + $('#acct_txfr_frm_date').val(txfr_date); + $('#acct_txfr_frm_date_input_group').datepicker({ todayBtn: "linked", autoclose: true, todayHighlight: true, @@ -72,9 +72,8 @@ function budgetTransferModal(txfr_date) { }); $('#modalSaveButton').off(); $('#modalSaveButton').click(function() { - handleForm('modalBody', 'budgetTransferForm', '/forms/budget_transfer', null); + handleForm('modalBody', 'accountTransferForm', '/forms/account_transfer', null); }).show(); - $('#modalLabel').text('Budget Transfer'); - $('#budg_txfr_frm_account option[value=' + default_account_id + ']').prop('selected', 'selected').change(); + $('#modalLabel').text('Account Transfer'); $("#modalDiv").modal('show'); } diff --git a/biweeklybudget/flaskapp/templates/accounts.html b/biweeklybudget/flaskapp/templates/accounts.html index 8239ca71..e8a25dec 100644 --- a/biweeklybudget/flaskapp/templates/accounts.html +++ b/biweeklybudget/flaskapp/templates/accounts.html @@ -10,6 +10,18 @@ {% for name in min_pay_class_names|sort %} min_pay_class_names["{{ name }}"] = "{{ name }}"; {% endfor %} + var acct_names_to_id = {}; + {% for name in accts.keys()|sort %} + acct_names_to_id["{{ name }}"] = "{{ accts[name] }}"; + {% endfor %} + var budget_names_to_id = {}; + {% for id in budgets.keys()|sort %} + budget_names_to_id["{{ budgets[id] }}"] = "{{ id }}"; + {% endfor %} + var active_budget_names_to_id = {}; + {% for id in active_budgets.keys()|sort %} + active_budget_names_to_id["{{ active_budgets[id] }}"] = "{{ id }}"; + {% endfor %} {% endblock %} {% block body %} @@ -20,11 +32,12 @@
Bank Accounts +
- +
@@ -106,11 +119,12 @@
Investment Accounts +
-
Account
+
@@ -146,14 +160,21 @@ {% include 'modal.html' %} {% endblock %} {% block extra_foot_script %} + + + + {% endblock %} {% block body %} {% include 'notifications.html' %} @@ -95,11 +109,12 @@
Bank Accounts +
-
Account
+
@@ -175,11 +190,12 @@
Investment Accounts +
-
Account
+
@@ -212,10 +228,24 @@ +{% include 'modal.html' %} {% endblock %} {% block extra_foot_script %} + + + + + + + {% endblock %} \ No newline at end of file diff --git a/biweeklybudget/flaskapp/views/accounts.py b/biweeklybudget/flaskapp/views/accounts.py index d7cd234c..266f6d48 100644 --- a/biweeklybudget/flaskapp/views/accounts.py +++ b/biweeklybudget/flaskapp/views/accounts.py @@ -39,12 +39,15 @@ from flask.views import MethodView from flask import render_template, jsonify from decimal import Decimal +from datetime import datetime import json import re from biweeklybudget.flaskapp.app import app from biweeklybudget.flaskapp.views.formhandlerview import FormHandlerView from biweeklybudget.models.account import Account, AcctType +from biweeklybudget.models.budget_model import Budget +from biweeklybudget.models.transaction import Transaction from biweeklybudget.db import db_session from biweeklybudget.interest import ( INTEREST_CALCULATION_NAMES, MIN_PAYMENT_FORMULA_NAMES @@ -67,6 +70,16 @@ class AccountsView(MethodView): """ def get(self): + accts = {a.name: a.id for a in db_session.query(Account).all()} + budgets = {} + active_budgets = {} + for b in db_session.query(Budget).all(): + k = b.name + if b.is_income: + k = '%s (i)' % b.name + budgets[b.id] = k + if b.is_active: + active_budgets[b.id] = k return render_template( 'accounts.html', bank_accounts=db_session.query(Account).filter( @@ -79,7 +92,10 @@ def get(self): Account.acct_type == AcctType.Investment, Account.is_active == True).all(), # noqa interest_class_names=INTEREST_CALCULATION_NAMES.keys(), - min_pay_class_names=MIN_PAYMENT_FORMULA_NAMES.keys() + min_pay_class_names=MIN_PAYMENT_FORMULA_NAMES.keys(), + accts=accts, + budgets=budgets, + active_budgets=active_budgets ) @@ -238,6 +254,144 @@ def submit(self, data): return 'Successfully saved Account %d in database.' % account.id +class AccountTxfrFormHandler(FormHandlerView): + """ + Handle POST /forms/account_transfer + """ + + def validate(self, data): + """ + Validate the form data. Return None if it is valid, or else a hash of + field names to list of error strings for each field. + + :param data: submitted form data + :type data: dict + :return: None if no errors, or hash of field name to errors for that + field + """ + have_errors = False + errors = {k: [] for k in data.keys()} + if data['date'].strip() == '': + errors['date'].append('Transactions must have a date') + have_errors = True + else: + try: + datetime.strptime(data['date'], '%Y-%m-%d').date() + except Exception: + errors['date'].append( + 'Date "%s" is not valid (YYYY-MM-DD)' % data['date'] + ) + have_errors = True + if float(data['amount']) == 0: + errors['amount'].append('Amount cannot be zero') + have_errors = True + if float(data['amount']) < 0: + errors['amount'].append('Amount cannot be negative') + have_errors = True + if data['budget'] == 'None': + errors['budget'].append('Transactions must have a budget') + have_errors = True + if data['from_account'] == 'None': + errors['from_account'].append('From Account cannot be empty') + have_errors = True + else: + from_acct = db_session.query(Account).get(int(data['from_account'])) + if from_acct is None: + errors['from_account'].append('from_account ID does not exist') + have_errors = True + else: + if not from_acct.is_active: + errors['from_account'].append('From Account must be active') + have_errors = True + if from_acct.acct_type not in AcctType.transferrable_types(): + errors['from_account'].append( + 'From Account type is not transferrable' + ) + have_errors = True + if data['to_account'] == 'None': + errors['to_account'].append('To Account cannot be empty') + have_errors = True + else: + to_acct = db_session.query(Account).get(int(data['to_account'])) + if to_acct is None: + errors['to_account'].append('to_account ID does not exist') + have_errors = True + else: + if not to_acct.is_active: + errors['to_account'].append('To Account must be active') + have_errors = True + if to_acct.acct_type not in AcctType.transferrable_types(): + errors['to_account'].append( + 'To Account type is not transferrable' + ) + have_errors = True + if have_errors: + return errors + return None + + def submit(self, data): + """ + Handle form submission; create or update models in the DB. Raises an + Exception for any errors. + + :param data: submitted form data + :type data: dict + :return: message describing changes to DB (i.e. link to created record) + :rtype: str + """ + # get the data + trans_date = datetime.strptime(data['date'], '%Y-%m-%d').date() + amt = Decimal(data['amount']) + from_acct = db_session.query(Account).get(int(data['from_account'])) + if from_acct is None: + raise RuntimeError( + "Error: no Account with ID %s" % data['from_account'] + ) + to_acct = db_session.query(Account).get(int(data['to_account'])) + if to_acct is None: + raise RuntimeError( + "Error: no Account with ID %s" % data['to_account'] + ) + budget = db_session.query(Budget).get(int(data['budget'])) + if budget is None: + raise RuntimeError( + "Error: no Budget with ID %s" % data['budget'] + ) + notes = data['notes'].strip() + desc = 'Account Transfer - %s from %s (%d) to %s (%d)' % ( + amt, from_acct.name, from_acct.id, to_acct.name, to_acct.id + ) + logger.info(desc) + t1 = Transaction( + date=trans_date, + budget_amounts={budget: amt}, + budgeted_amount=amt, + description=desc, + account=from_acct, + notes=notes, + planned_budget=budget + ) + db_session.add(t1) + t2 = Transaction( + date=trans_date, + budget_amounts={budget: (-1 * amt)}, + budgeted_amount=(-1 * amt), + description=desc, + account=to_acct, + notes=notes, + planned_budget=budget + ) + db_session.add(t2) + t1.transfer = t2 + db_session.add(t1) + t2.transfer = t1 + db_session.add(t2) + db_session.commit() + return 'Successfully saved Transactions %d and %d in database.' % ( + t1.id, t2.id + ) + + app.add_url_rule('/accounts', view_func=AccountsView.as_view('accounts_view')) app.add_url_rule( '/ajax/account/', @@ -251,3 +405,7 @@ def submit(self, data): '/forms/account', view_func=AccountFormHandler.as_view('account_form') ) +app.add_url_rule( + '/forms/account_transfer', + view_func=AccountTxfrFormHandler.as_view('account_transfer_form') +) diff --git a/biweeklybudget/flaskapp/views/index.py b/biweeklybudget/flaskapp/views/index.py index e35a719e..89c89c1a 100644 --- a/biweeklybudget/flaskapp/views/index.py +++ b/biweeklybudget/flaskapp/views/index.py @@ -70,6 +70,16 @@ def get(self): # trigger calculation/cache of data before passing on to jinja for p in periods: p.overall_sums + accts = {a.name: a.id for a in db_session.query(Account).all()} + budgets = {} + active_budgets = {} + for b in db_session.query(Budget).all(): + k = b.name + if b.is_income: + k = '%s (i)' % b.name + budgets[b.id] = k + if b.is_active: + active_budgets[b.id] = k return render_template( 'index.html', bank_accounts=db_session.query(Account).filter( @@ -86,7 +96,10 @@ def get(self): curr_pp=pp, pp_curr_idx=pp_curr_idx, pp_next_idx=pp_next_idx, - pp_following_idx=pp_following_idx + pp_following_idx=pp_following_idx, + accts=accts, + budgets=budgets, + active_budgets=active_budgets ) diff --git a/biweeklybudget/models/account.py b/biweeklybudget/models/account.py index 92152f37..f699146b 100644 --- a/biweeklybudget/models/account.py +++ b/biweeklybudget/models/account.py @@ -82,6 +82,13 @@ class AcctType(enum.Enum): def as_dict(self): return {'name': self.name, 'value': self.value} + @classmethod + def transferrable_types(self): + """Return a list of the transferrable types.""" + return [ + self.Bank, self.Investment, self.Cash, self.Other + ] + class Account(Base, ModelAsDict): diff --git a/biweeklybudget/tests/acceptance/flaskapp/views/test_accounts.py b/biweeklybudget/tests/acceptance/flaskapp/views/test_accounts.py index 4b151e69..b3dd973e 100644 --- a/biweeklybudget/tests/acceptance/flaskapp/views/test_accounts.py +++ b/biweeklybudget/tests/acceptance/flaskapp/views/test_accounts.py @@ -39,7 +39,10 @@ from decimal import Decimal from biweeklybudget.tests.acceptance_helpers import AcceptanceHelper from biweeklybudget.models.account import Account, AcctType +from biweeklybudget.models.transaction import Transaction from selenium.webdriver.support.ui import Select +from biweeklybudget.utils import dtnow +import requests @pytest.mark.acceptance @@ -1113,3 +1116,571 @@ def test_82_verify_db(self, testdb): assert acct.re_payment is None assert acct.re_late_fee is None assert acct.re_other_fee is None + + +@pytest.mark.acceptance +@pytest.mark.usefixtures('class_refresh_db', 'refreshdb', 'testflask') +@pytest.mark.incremental +class TestAccountTransfer(AcceptanceHelper): + + def test_01_verify_db(self, testdb): + max_t = max([ + t.id for t in testdb.query(Transaction).all() + ]) + assert max_t == 4 + accts = { + t.id: t for t in testdb.query(Account).all() + } + assert accts[1].acct_type == AcctType.Bank + assert accts[1].balance.ledger == Decimal('12789.01') + assert accts[1].unreconciled_sum == Decimal('0.0') + assert accts[2].acct_type == AcctType.Bank + assert accts[2].balance.ledger == Decimal('100.23') + assert accts[2].unreconciled_sum == Decimal('-333.33') + assert accts[3].acct_type == AcctType.Credit + assert accts[4].acct_type == AcctType.Credit + assert accts[5].acct_type == AcctType.Investment + assert accts[5].balance.ledger == Decimal('10362.91') + + def test_02_transfer_modal(self, base_url, selenium): + # Fill in the form + self.get(selenium, base_url + '/accounts') + # check the table content on the page + btable = selenium.find_element_by_id('table-accounts-bank') + btexts = self.tbody2textlist(btable) + assert btexts == [ + [ + 'BankOne', + '$12,789.01 (14 hours ago)', + '$0.00', + '$12,789.01' + ], + [ + 'BankTwoStale', + '$100.23 (18 days ago)', + '-$333.33', + '$433.56' + ] + ] + itable = selenium.find_element_by_id('table-accounts-investment') + itexts = self.tbody2textlist(itable) + assert itexts == [ + [ + 'InvestmentOne', + '$10,362.91 (13 days ago)' + ] + ] + # open the modal to do a transfer + link = selenium.find_element_by_id('btn_acct_txfr_bank') + modal, title, body = self.try_click_and_get_modal(selenium, link) + self.assert_modal_displayed(modal, title, body) + assert title.text == 'Account Transfer' + assert body.find_element_by_id( + 'acct_txfr_frm_date').get_attribute('value') == dtnow( + ).strftime('%Y-%m-%d') + amt = body.find_element_by_id('acct_txfr_frm_amount') + amt.clear() + amt.send_keys('123.45') + budget_sel = Select( + body.find_element_by_id('acct_txfr_frm_budget') + ) + opts = [] + for o in budget_sel.options: + opts.append([o.get_attribute('value'), o.text]) + assert opts == [ + ['None', ''], + ['1', 'Periodic1'], + ['2', 'Periodic2'], + ['4', 'Standing1'], + ['5', 'Standing2'], + ['7', 'Income (i)'] + ] + assert budget_sel.first_selected_option.get_attribute( + 'value') == 'None' + budget_sel.select_by_value('2') + from_acct_sel = Select( + body.find_element_by_id('acct_txfr_frm_from_account') + ) + opts = [] + for o in from_acct_sel.options: + opts.append([o.get_attribute('value'), o.text]) + assert opts == [ + ['None', ''], + ['1', 'BankOne'], + ['2', 'BankTwoStale'], + ['3', 'CreditOne'], + ['4', 'CreditTwo'], + ['6', 'DisabledBank'], + ['5', 'InvestmentOne'] + ] + assert from_acct_sel.first_selected_option.get_attribute( + 'value' + ) == 'None' + from_acct_sel.select_by_value('1') + to_acct_sel = Select( + body.find_element_by_id('acct_txfr_frm_to_account') + ) + opts = [] + for o in to_acct_sel.options: + opts.append([o.get_attribute('value'), o.text]) + assert opts == [ + ['None', ''], + ['1', 'BankOne'], + ['2', 'BankTwoStale'], + ['3', 'CreditOne'], + ['4', 'CreditTwo'], + ['6', 'DisabledBank'], + ['5', 'InvestmentOne'] + ] + assert to_acct_sel.first_selected_option.get_attribute( + 'value' + ) == 'None' + to_acct_sel.select_by_value('2') + notes = selenium.find_element_by_id('acct_txfr_frm_notes') + notes.clear() + notes.send_keys('Account Transfer Notes') + # submit the form + selenium.find_element_by_id('modalSaveButton').click() + self.wait_for_jquery_done(selenium) + # check that we got positive confirmation + _, _, body = self.get_modal_parts(selenium) + x = body.find_elements_by_tag_name('div')[0] + assert 'alert-success' in x.get_attribute('class') + assert x.text.strip() == 'Successfully saved Transactions 5 and 6' \ + ' in database.' + # dismiss the modal + selenium.find_element_by_id('modalCloseButton').click() + self.wait_for_load_complete(selenium) + self.wait_for_id(selenium, 'table-accounts-bank') + # ensure that the page content updated after refreshing + btable = selenium.find_element_by_id('table-accounts-bank') + btexts = self.tbody2textlist(btable) + assert btexts == [ + [ + 'BankOne', + '$12,789.01 (14 hours ago)', + '$123.45', + '$12,665.56' + ], + [ + 'BankTwoStale', + '$100.23 (18 days ago)', + '-$456.78', + '$557.01' + ] + ] + itable = selenium.find_element_by_id('table-accounts-investment') + itexts = self.tbody2textlist(itable) + assert itexts == [ + [ + 'InvestmentOne', + '$10,362.91 (13 days ago)' + ] + ] + + def test_03_verify_db(self, testdb): + max_t = max([ + t.id for t in testdb.query(Transaction).all() + ]) + assert max_t == 6 + desc = 'Account Transfer - 123.45 from BankOne (1) to BankTwoStale (2)' + t2 = testdb.query(Transaction).get(5) + assert t2.date == dtnow().date() + assert t2.actual_amount == Decimal('123.45') + assert t2.budgeted_amount == Decimal('123.45') + assert t2.description == desc + assert t2.notes == 'Account Transfer Notes' + assert t2.account_id == 1 + assert t2.scheduled_trans_id is None + assert len(t2.budget_transactions) == 1 + assert t2.budget_transactions[0].budget_id == 2 + assert t2.budget_transactions[0].amount == Decimal('123.45') + t1 = testdb.query(Transaction).get(6) + assert t1.date == dtnow().date() + assert t1.actual_amount == Decimal('-123.45') + assert t1.budgeted_amount == Decimal('-123.45') + assert t1.description == desc + assert t1.notes == 'Account Transfer Notes' + assert t1.account_id == 2 + assert t1.scheduled_trans_id is None + assert len(t1.budget_transactions) == 1 + assert t1.budget_transactions[0].budget_id == 2 + assert t1.budget_transactions[0].amount == Decimal('-123.45') + acct1 = testdb.query(Account).get(1) + assert acct1.balance.ledger == Decimal('12789.01') + assert acct1.unreconciled_sum == Decimal('123.45') + acct2 = testdb.query(Account).get(2) + assert acct2.balance.ledger == Decimal('100.23') + assert acct2.unreconciled_sum == Decimal('-456.78') + + def test_04_no_date_error(self, base_url, selenium): + # Fill in the form + self.get(selenium, base_url + '/accounts') + # open the modal to do a transfer + link = selenium.find_element_by_id('btn_acct_txfr_bank') + modal, title, body = self.try_click_and_get_modal(selenium, link) + self.assert_modal_displayed(modal, title, body) + assert title.text == 'Account Transfer' + date_box = body.find_element_by_id('acct_txfr_frm_date') + assert date_box.get_attribute('value') == dtnow().strftime('%Y-%m-%d') + date_box.clear() + amt = body.find_element_by_id('acct_txfr_frm_amount') + amt.clear() + amt.send_keys('123.45') + budget_sel = Select( + body.find_element_by_id('acct_txfr_frm_budget') + ) + opts = [] + for o in budget_sel.options: + opts.append([o.get_attribute('value'), o.text]) + assert opts == [ + ['None', ''], + ['1', 'Periodic1'], + ['2', 'Periodic2'], + ['4', 'Standing1'], + ['5', 'Standing2'], + ['7', 'Income (i)'] + ] + assert budget_sel.first_selected_option.get_attribute( + 'value') == 'None' + budget_sel.select_by_value('2') + from_acct_sel = Select( + body.find_element_by_id('acct_txfr_frm_from_account') + ) + opts = [] + for o in from_acct_sel.options: + opts.append([o.get_attribute('value'), o.text]) + assert opts == [ + ['None', ''], + ['1', 'BankOne'], + ['2', 'BankTwoStale'], + ['3', 'CreditOne'], + ['4', 'CreditTwo'], + ['6', 'DisabledBank'], + ['5', 'InvestmentOne'] + ] + assert from_acct_sel.first_selected_option.get_attribute( + 'value' + ) == 'None' + from_acct_sel.select_by_value('1') + to_acct_sel = Select( + body.find_element_by_id('acct_txfr_frm_to_account') + ) + opts = [] + for o in to_acct_sel.options: + opts.append([o.get_attribute('value'), o.text]) + assert opts == [ + ['None', ''], + ['1', 'BankOne'], + ['2', 'BankTwoStale'], + ['3', 'CreditOne'], + ['4', 'CreditTwo'], + ['6', 'DisabledBank'], + ['5', 'InvestmentOne'] + ] + assert to_acct_sel.first_selected_option.get_attribute( + 'value' + ) == 'None' + to_acct_sel.select_by_value('2') + notes = selenium.find_element_by_id('acct_txfr_frm_notes') + notes.clear() + notes.send_keys('Account Transfer Notes') + # submit the form + selenium.find_element_by_id('modalSaveButton').click() + self.wait_for_jquery_done(selenium) + # check that we got positive confirmation + _, _, body = self.get_modal_parts(selenium) + x = body.find_elements_by_class_name('formfeedback') + assert len(x) == 1 + assert x[0].text.strip() == 'Transactions must have a date' + # dismiss the modal + selenium.find_element_by_id('modalCloseButton').click() + self.wait_for_load_complete(selenium) + self.wait_for_id(selenium, 'table-accounts-bank') + # ensure that the page content updated after refreshing + btable = selenium.find_element_by_id('table-accounts-bank') + btexts = self.tbody2textlist(btable) + assert btexts == [ + [ + 'BankOne', + '$12,789.01 (14 hours ago)', + '$123.45', + '$12,665.56' + ], + [ + 'BankTwoStale', + '$100.23 (18 days ago)', + '-$456.78', + '$557.01' + ] + ] + itable = selenium.find_element_by_id('table-accounts-investment') + itexts = self.tbody2textlist(itable) + assert itexts == [ + [ + 'InvestmentOne', + '$10,362.91 (13 days ago)' + ] + ] + + def test_05_no_date_error_requests(self, base_url): + r = requests.post( + base_url + '/forms/account_transfer', + json={ + 'date': "", + 'amount': "100", + 'notes': "", + 'budget': "1", + 'from_account': "1", + 'to_account': "2" + } + ) + assert r.status_code == 200 + assert r.json() == { + "errors": { + "amount": [], + "budget": [], + "date": [ + "Transactions must have a date" + ], + "from_account": [], + "notes": [], + "to_account": [] + }, + "success": False + } + + def test_06_zero_amount(self, base_url): + r = requests.post( + base_url + '/forms/account_transfer', + json={ + 'date': dtnow().strftime('%Y-%m-%d'), + 'amount': "0", + 'notes': "", + 'budget': "1", + 'from_account': "1", + 'to_account': "2" + } + ) + assert r.status_code == 200 + assert r.json() == { + "errors": { + "amount": ['Amount cannot be zero'], + "budget": [], + "date": [], + "from_account": [], + "notes": [], + "to_account": [] + }, + "success": False + } + + def test_07_negative_amount(self, base_url): + r = requests.post( + base_url + '/forms/account_transfer', + json={ + 'date': dtnow().strftime('%Y-%m-%d'), + 'amount': "-120", + 'notes': "", + 'budget': "1", + 'from_account': "1", + 'to_account': "2" + } + ) + assert r.status_code == 200 + assert r.json() == { + "errors": { + "amount": ['Amount cannot be negative'], + "budget": [], + "date": [], + "from_account": [], + "notes": [], + "to_account": [] + }, + "success": False + } + + def test_08_empty_from_account(self, base_url): + r = requests.post( + base_url + '/forms/account_transfer', + json={ + 'date': dtnow().strftime('%Y-%m-%d'), + 'amount': "120", + 'notes': "", + 'budget': "1", + 'from_account': "None", + 'to_account': "2" + } + ) + assert r.status_code == 200 + assert r.json() == { + "errors": { + "amount": [], + "budget": [], + "date": [], + "from_account": ['From Account cannot be empty'], + "notes": [], + "to_account": [] + }, + "success": False + } + + def test_09_non_transferrable_from_account(self, base_url): + r = requests.post( + base_url + '/forms/account_transfer', + json={ + 'date': dtnow().strftime('%Y-%m-%d'), + 'amount': "120", + 'notes': "", + 'budget': "1", + 'from_account': "3", + 'to_account': "2" + } + ) + assert r.status_code == 200 + assert r.json() == { + "errors": { + "amount": [], + "budget": [], + "date": [], + "from_account": ['From Account type is not transferrable'], + "notes": [], + "to_account": [] + }, + "success": False + } + + def test_10_invalid_from_account(self, base_url): + r = requests.post( + base_url + '/forms/account_transfer', + json={ + 'date': dtnow().strftime('%Y-%m-%d'), + 'amount': "120", + 'notes': "", + 'budget': "1", + 'from_account': "999", + 'to_account': "2" + } + ) + assert r.status_code == 200 + assert r.json() == { + "errors": { + "amount": [], + "budget": [], + "date": [], + "from_account": ['from_account ID does not exist'], + "notes": [], + "to_account": [] + }, + "success": False + } + + def test_11_empty_to_account(self, base_url): + r = requests.post( + base_url + '/forms/account_transfer', + json={ + 'date': dtnow().strftime('%Y-%m-%d'), + 'amount': "120", + 'notes': "", + 'budget': "1", + 'to_account': "None", + 'from_account': "2" + } + ) + assert r.status_code == 200 + assert r.json() == { + "errors": { + "amount": [], + "budget": [], + "date": [], + "to_account": ['To Account cannot be empty'], + "notes": [], + "from_account": [] + }, + "success": False + } + + def test_12_non_transferrable_to_account(self, base_url): + r = requests.post( + base_url + '/forms/account_transfer', + json={ + 'date': dtnow().strftime('%Y-%m-%d'), + 'amount': "120", + 'notes': "", + 'budget': "1", + 'to_account': "3", + 'from_account': "2" + } + ) + assert r.status_code == 200 + assert r.json() == { + "errors": { + "amount": [], + "budget": [], + "date": [], + "to_account": ['To Account type is not transferrable'], + "notes": [], + "from_account": [] + }, + "success": False + } + + def test_13_invalid_to_account(self, base_url): + r = requests.post( + base_url + '/forms/account_transfer', + json={ + 'date': dtnow().strftime('%Y-%m-%d'), + 'amount': "120", + 'notes': "", + 'budget': "1", + 'to_account': "999", + 'from_account': "2" + } + ) + assert r.status_code == 200 + assert r.json() == { + "errors": { + "amount": [], + "budget": [], + "date": [], + "to_account": ['to_account ID does not exist'], + "notes": [], + "from_account": [] + }, + "success": False + } + + def test_99_verify_db_no_changes_from_errors(self, testdb): + max_t = max([ + t.id for t in testdb.query(Transaction).all() + ]) + assert max_t == 6 + desc = 'Account Transfer - 123.45 from BankOne (1) to BankTwoStale (2)' + t2 = testdb.query(Transaction).get(5) + assert t2.date == dtnow().date() + assert t2.actual_amount == Decimal('123.45') + assert t2.budgeted_amount == Decimal('123.45') + assert t2.description == desc + assert t2.notes == 'Account Transfer Notes' + assert t2.account_id == 1 + assert t2.scheduled_trans_id is None + assert len(t2.budget_transactions) == 1 + assert t2.budget_transactions[0].budget_id == 2 + assert t2.budget_transactions[0].amount == Decimal('123.45') + t1 = testdb.query(Transaction).get(6) + assert t1.date == dtnow().date() + assert t1.actual_amount == Decimal('-123.45') + assert t1.budgeted_amount == Decimal('-123.45') + assert t1.description == desc + assert t1.notes == 'Account Transfer Notes' + assert t1.account_id == 2 + assert t1.scheduled_trans_id is None + assert len(t1.budget_transactions) == 1 + assert t1.budget_transactions[0].budget_id == 2 + assert t1.budget_transactions[0].amount == Decimal('-123.45') + acct1 = testdb.query(Account).get(1) + assert acct1.balance.ledger == Decimal('12789.01') + assert acct1.unreconciled_sum == Decimal('123.45') + acct2 = testdb.query(Account).get(2) + assert acct2.balance.ledger == Decimal('100.23') + assert acct2.unreconciled_sum == Decimal('-456.78') diff --git a/biweeklybudget/tests/acceptance/flaskapp/views/test_index.py b/biweeklybudget/tests/acceptance/flaskapp/views/test_index.py index 8d402ec9..f7dc70da 100644 --- a/biweeklybudget/tests/acceptance/flaskapp/views/test_index.py +++ b/biweeklybudget/tests/acceptance/flaskapp/views/test_index.py @@ -40,6 +40,8 @@ from pytz import UTC from decimal import Decimal +from selenium.webdriver.support.ui import Select +from biweeklybudget.utils import dtnow from biweeklybudget.tests.acceptance_helpers import AcceptanceHelper from biweeklybudget.tests.sqlhelpers import restore_mysqldump from biweeklybudget.tests.conftest import get_db_engine @@ -428,3 +430,199 @@ def test_5_pay_periods_table(self, base_url, selenium, testdb): tbody = table.find_element_by_tag_name('tbody') trs = tbody.find_elements_by_tag_name('tr') assert trs[0].get_attribute('class') == 'info' + + +@pytest.mark.acceptance +@pytest.mark.usefixtures('class_refresh_db', 'refreshdb', 'testflask') +@pytest.mark.incremental +class TestAccountTransfer(AcceptanceHelper): + + def test_01_verify_db(self, testdb): + max_t = max([ + t.id for t in testdb.query(Transaction).all() + ]) + assert max_t == 4 + accts = { + t.id: t for t in testdb.query(Account).all() + } + assert accts[1].acct_type == AcctType.Bank + assert accts[1].balance.ledger == Decimal('12789.01') + assert accts[1].unreconciled_sum == Decimal('0.0') + assert accts[2].acct_type == AcctType.Bank + assert accts[2].balance.ledger == Decimal('100.23') + assert accts[2].unreconciled_sum == Decimal('-333.33') + assert accts[3].acct_type == AcctType.Credit + assert accts[4].acct_type == AcctType.Credit + assert accts[5].acct_type == AcctType.Investment + assert accts[5].balance.ledger == Decimal('10362.91') + + def test_02_transfer_modal(self, base_url, selenium): + # Fill in the form + self.get(selenium, base_url + '/') + # check the table content on the page + btable = selenium.find_element_by_id('table-accounts-bank') + btexts = self.tbody2textlist(btable) + assert btexts == [ + [ + 'BankOne', + '$12,789.01 (14 hours ago)', + '$0.00', + '$12,789.01' + ], + [ + 'BankTwoStale', + '$100.23 (18 days ago)', + '-$333.33', + '$433.56' + ] + ] + itable = selenium.find_element_by_id('table-accounts-investment') + itexts = self.tbody2textlist(itable) + assert itexts == [ + [ + 'InvestmentOne', + '$10,362.91 (13 days ago)' + ] + ] + # open the modal to do a transfer + link = selenium.find_element_by_id('btn_acct_txfr_bank') + modal, title, body = self.try_click_and_get_modal(selenium, link) + self.assert_modal_displayed(modal, title, body) + assert title.text == 'Account Transfer' + assert body.find_element_by_id( + 'acct_txfr_frm_date').get_attribute('value') == dtnow( + ).strftime('%Y-%m-%d') + amt = body.find_element_by_id('acct_txfr_frm_amount') + amt.clear() + amt.send_keys('123.45') + budget_sel = Select( + body.find_element_by_id('acct_txfr_frm_budget') + ) + opts = [] + for o in budget_sel.options: + opts.append([o.get_attribute('value'), o.text]) + assert opts == [ + ['None', ''], + ['1', 'Periodic1'], + ['2', 'Periodic2'], + ['4', 'Standing1'], + ['5', 'Standing2'], + ['7', 'Income (i)'] + ] + assert budget_sel.first_selected_option.get_attribute( + 'value') == 'None' + budget_sel.select_by_value('2') + from_acct_sel = Select( + body.find_element_by_id('acct_txfr_frm_from_account') + ) + opts = [] + for o in from_acct_sel.options: + opts.append([o.get_attribute('value'), o.text]) + assert opts == [ + ['None', ''], + ['1', 'BankOne'], + ['2', 'BankTwoStale'], + ['3', 'CreditOne'], + ['4', 'CreditTwo'], + ['6', 'DisabledBank'], + ['5', 'InvestmentOne'] + ] + assert from_acct_sel.first_selected_option.get_attribute( + 'value' + ) == 'None' + from_acct_sel.select_by_value('1') + to_acct_sel = Select( + body.find_element_by_id('acct_txfr_frm_to_account') + ) + opts = [] + for o in to_acct_sel.options: + opts.append([o.get_attribute('value'), o.text]) + assert opts == [ + ['None', ''], + ['1', 'BankOne'], + ['2', 'BankTwoStale'], + ['3', 'CreditOne'], + ['4', 'CreditTwo'], + ['6', 'DisabledBank'], + ['5', 'InvestmentOne'] + ] + assert to_acct_sel.first_selected_option.get_attribute( + 'value' + ) == 'None' + to_acct_sel.select_by_value('2') + notes = selenium.find_element_by_id('acct_txfr_frm_notes') + notes.clear() + notes.send_keys('Account Transfer Notes') + # submit the form + selenium.find_element_by_id('modalSaveButton').click() + self.wait_for_jquery_done(selenium) + # check that we got positive confirmation + _, _, body = self.get_modal_parts(selenium) + x = body.find_elements_by_tag_name('div')[0] + assert 'alert-success' in x.get_attribute('class') + assert x.text.strip() == 'Successfully saved Transactions 5 and 6' \ + ' in database.' + # dismiss the modal + selenium.find_element_by_id('modalCloseButton').click() + self.wait_for_load_complete(selenium) + self.wait_for_id(selenium, 'table-accounts-bank') + # ensure that the page content updated after refreshing + btable = selenium.find_element_by_id('table-accounts-bank') + btexts = self.tbody2textlist(btable) + assert btexts == [ + [ + 'BankOne', + '$12,789.01 (14 hours ago)', + '$123.45', + '$12,665.56' + ], + [ + 'BankTwoStale', + '$100.23 (18 days ago)', + '-$456.78', + '$557.01' + ] + ] + itable = selenium.find_element_by_id('table-accounts-investment') + itexts = self.tbody2textlist(itable) + assert itexts == [ + [ + 'InvestmentOne', + '$10,362.91 (13 days ago)' + ] + ] + + def test_03_verify_db(self, testdb): + max_t = max([ + t.id for t in testdb.query(Transaction).all() + ]) + assert max_t == 6 + desc = 'Account Transfer - 123.45 from BankOne (1) to BankTwoStale (2)' + t2 = testdb.query(Transaction).get(5) + assert t2.date == dtnow().date() + assert t2.actual_amount == Decimal('123.45') + assert t2.budgeted_amount == Decimal('123.45') + assert t2.description == desc + assert t2.notes == 'Account Transfer Notes' + assert t2.account_id == 1 + assert t2.scheduled_trans_id is None + assert len(t2.budget_transactions) == 1 + assert t2.budget_transactions[0].budget_id == 2 + assert t2.budget_transactions[0].amount == Decimal('123.45') + t1 = testdb.query(Transaction).get(6) + assert t1.date == dtnow().date() + assert t1.actual_amount == Decimal('-123.45') + assert t1.budgeted_amount == Decimal('-123.45') + assert t1.description == desc + assert t1.notes == 'Account Transfer Notes' + assert t1.account_id == 2 + assert t1.scheduled_trans_id is None + assert len(t1.budget_transactions) == 1 + assert t1.budget_transactions[0].budget_id == 2 + assert t1.budget_transactions[0].amount == Decimal('-123.45') + acct1 = testdb.query(Account).get(1) + assert acct1.balance.ledger == Decimal('12789.01') + assert acct1.unreconciled_sum == Decimal('123.45') + acct2 = testdb.query(Account).get(2) + assert acct2.balance.ledger == Decimal('100.23') + assert acct2.unreconciled_sum == Decimal('-456.78')
Account