Skip to content

Commit

Permalink
Merge pull request #176 from jantman/cc-min-pymt
Browse files Browse the repository at this point in the history
Credit Payoff minimum payment fix
  • Loading branch information
jantman committed Jan 21, 2018
2 parents d8dadce + ffa88bd commit bbf2e5f
Show file tree
Hide file tree
Showing 29 changed files with 478 additions and 42 deletions.
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Changelog
=========

Unreleased Changes
------------------

* Fix major logic error in Credit Card Payoff calculations; interest fees were ignored for the current month/statement, resulting in "Next Payment" values significantly lower than they should be. Fixed to use the last Interest Charge retrieved via OFX (or, if no interest charges present in OFX statements, prompt users to manually enter the last Interest Charge via a new modal that will create an OFXTransaction for it) as the interest amount on the first month/statement when calculating payoffs. This fix now returns Next Payment values that aren't identical to sample cards, but are significantly closer (within 1-2%).

0.7.1 (2018-01-10)
------------------

Expand Down
72 changes: 72 additions & 0 deletions biweeklybudget/flaskapp/static/js/creditPayoffErrorModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
The latest version of this package is available at:
<http://github.com/jantman/biweeklybudget>
################################################################################
Copyright 2016 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com>
This file is part of biweeklybudget, also known as biweeklybudget.
biweeklybudget is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
biweeklybudget is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with biweeklybudget. If not, see <http://www.gnu.org/licenses/>.
The Copyright and Authors attributions contained herein may not be removed or
otherwise altered, except to add the Author attribution of a contributor to
this work. (Additional Terms pursuant to Section 7b of the AGPL v3)
################################################################################
While not legally required, I sincerely request that anyone who finds
bugs please submit them at <https://github.com/jantman/biweeklybudget> or
to me via email, and that you send any contributions or improvements
either as a pull request on GitHub, or to me via email.
################################################################################
AUTHORS:
Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com>
################################################################################
*/

/**
* Trigger Ajax to get account OFX statement information. Ajax callback to
* create the form and display the modal is :js:func:`creditPayoffErrorModalForm`.
*
* @param {number} acct_id - the ID of the Account to show data for.
*/
function creditPayoffErrorModal(acct_id) {
$('#modalBody').empty();
$.ajax("/ajax/account_ofx_ajax/" + acct_id).done(creditPayoffErrorModalForm);
}

/**
* Generate the HTML for the form on the Credit Payoff Error Modal and show the
* modal. This is an Ajax callback triggered by a request to
* ``/ajax/account_ofx_ajax/<int:account_id>`` in :js:func:`creditPayoffErrorModal`.
* The response data is generated by :py:class:`~.AccountOfxAjax`.
*/
function creditPayoffErrorModalForm(data) {
var opts = [];
for (var idx in data['statements']) {
var s = data['statements'][idx];
opts.push({ label: s['as_of'] + " (" + fmt_currency(s['ledger_bal']) + ")", value: s['filename'] });
}
var frm = new FormBuilder('payoffAcctForm')
.addHidden('payoff_acct_frm_id', 'id', data['account_id'])
.addSelect('payoff_acct_frm_statement_filename', 'filename', 'Statement', opts)
.addCurrency('payoff_acct_frm_interest_amt', 'interest_amt', 'Interest Charge');
$('#modalBody').append(frm.render());
$('#modalSaveButton').off();
$('#modalSaveButton').click(function() {
handleForm('modalBody', 'payoffAcctForm', '/forms/credit-payoff-account-ofx', null);
}).show();
$('#modalLabel').text('Add Manual Interest Charge for Account ' + data['account_id']);
$("#modalDiv").modal('show');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}ERROR - Credit Card Payoffs - BiweeklyBudget{% endblock %}
{% block body %}
{% include 'notifications.html' %}
<div class="row">
<div class="col-lg-12">
<div class="alert alert-danger" id="account-interest-error-message">
<p><strong>Account Interest Error.</strong></p>
<p>The account "<a href="/accounts/{{ acct_id }}">{{ acct_name }}</a>" (ID <a href="/accounts/{{ acct_id }}">{{ acct_id }}</a>) does not have a record of Interest Charged in the last 32 days. Credit card payments cannot be calculated without this information. Either this software does not know how to find the interest charges in your account's OFX statements (in which case you should <a href="https://github.com/jantman/biweeklybudget/issues">open an issue on GitHub</a>) or your financial institution (such as Discover Cards) does not report interest charges in their OFX statements, in which case you must <a href="#" onclick="creditPayoffErrorModal({{ acct_id }})">manually input the interest charge from your last statement</a>.</p>
</div>
</div>
<!-- /.col-lg-12 -->
</div>
{% include 'modal.html' %}
{% endblock %}
{% block extra_foot_script %}
<script src="/utils/datetest.js"></script>
<script src="/static/bootstrap-datepicker/js/bootstrap-datepicker.js"></script>
<script src="/static/js/custom.js"></script>
<script src="/static/js/forms.js"></script>
<script src="/static/js/formBuilder.js"></script>
<script src="/static/js/creditPayoffErrorModal.js"></script>
{% endblock %}
113 changes: 108 additions & 5 deletions biweeklybudget/flaskapp/views/credit_payoffs.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
import logging
import json
from decimal import Decimal, ROUND_UP
from datetime import datetime
from datetime import datetime, timedelta

from flask.views import MethodView
from flask import render_template, request, jsonify
Expand All @@ -48,7 +48,11 @@
from biweeklybudget.db import db_session
from biweeklybudget.interest import InterestHelper
from biweeklybudget.models.dbsetting import DBSetting
from biweeklybudget.utils import fmt_currency
from biweeklybudget.utils import fmt_currency, dtnow
from biweeklybudget.models.account import NoInterestChargedError, Account
from biweeklybudget.models.ofx_statement import OFXStatement
from biweeklybudget.models.ofx_transaction import OFXTransaction
from biweeklybudget.flaskapp.views.formhandlerview import FormHandlerView

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -145,9 +149,17 @@ def get(self):
else:
pymt_settings_json = setting.value
pymt_settings_kwargs = self._payment_settings_dict(pymt_settings_json)
ih = InterestHelper(db_session, **pymt_settings_kwargs)
mps = sum(ih.min_payments.values())
payoffs = self._payoffs_list(ih)
try:
ih = InterestHelper(db_session, **pymt_settings_kwargs)
mps = sum(ih.min_payments.values())
payoffs = self._payoffs_list(ih)
except NoInterestChargedError as ex:
resp = render_template(
'credit-payoffs-no-interest-error.html',
acct_name=ex.account.name,
acct_id=ex.account.id
)
return resp, 500
return render_template(
'credit-payoffs.html',
monthly_pymt_sum=mps.quantize(Decimal('.01'), rounding=ROUND_UP),
Expand Down Expand Up @@ -201,6 +213,89 @@ def post(self):
})


class AccountOfxAjax(MethodView):
"""
Handle GET /ajax/account_ofx_ajax/<int:account_id> endpoint.
"""

def get(self, account_id):
res = []
q = db_session.query(OFXStatement).filter(
OFXStatement.account_id.__eq__(account_id)
).order_by(
OFXStatement.as_of.desc(),
OFXStatement.file_mtime.desc()
).all()
for stmt in q:
if stmt.as_of < (dtnow() - timedelta(days=32)):
break
res.append({
'filename': stmt.filename,
'as_of': stmt.as_of.strftime('%Y-%m-%d'),
'ledger_bal': stmt.ledger_bal
})
return jsonify({
'account_id': account_id,
'statements': res
})


class AccountOfxFormHandler(FormHandlerView):
"""
Handle POST /forms/credit-payoff-account-ofx
"""

def validate(self, data):
pass

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
"""
acct = db_session.query(Account).get(int(data['id']))
if acct is None:
raise RuntimeError('ERROR: No Account with ID %s' % data['id'])
stmt = db_session.query(OFXStatement).filter(
OFXStatement.account_id.__eq__(acct.id),
OFXStatement.filename.__eq__(data['filename'])
).one()
if stmt is None:
raise RuntimeError(
'ERROR: No OFXStatement for account %d with filename %s' % (
acct.id, data['filename']
)
)
int_amt = Decimal(data['interest_amt'])
if int_amt < Decimal('0'):
int_amt = int_amt * Decimal('-1')
trans = OFXTransaction(
account=acct,
statement=stmt,
fitid='%s-MANUAL-CCPAYOFF' % dtnow().strftime('%Y%m%d%H%M%S'),
trans_type='debit',
date_posted=stmt.as_of,
amount=int_amt,
name='Interest Charged - MANUALLY ENTERED',
is_interest_charge=True
)
logger.info(
'Adding manual interest transaction to OFXTransactions: '
'account_id=%d statement_filename=%s statement=%s '
'OFXTransaction=%s', acct.id, data['filename'], stmt,
trans
)
db_session.add(trans)
db_session.commit()
return 'Successfully saved OFXTransaction with FITID %s in database' \
'.' % trans.fitid


app.add_url_rule(
'/accounts/credit-payoff',
view_func=CreditPayoffsView.as_view('credit_payoffs_view')
Expand All @@ -209,3 +304,11 @@ def post(self):
'/settings/credit-payoff',
view_func=PayoffSettingsFormHandler.as_view('payoff_settings_form')
)
app.add_url_rule(
'/ajax/account_ofx_ajax/<int:account_id>',
view_func=AccountOfxAjax.as_view('account_ofx_ajax')
)
app.add_url_rule(
'/forms/credit-payoff-account-ofx',
view_func=AccountOfxFormHandler.as_view('payoff_account_ofx_form')
)
4 changes: 2 additions & 2 deletions biweeklybudget/interest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from dateutil.relativedelta import relativedelta
from calendar import monthrange

from biweeklybudget.models import Account, AcctType
from biweeklybudget.models.account import Account, AcctType

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -119,7 +119,7 @@ def _make_statements(self, accounts):
min_pay_cls,
bill_period,
end_balance=abs(acct.balance.ledger),
interest_amt=Decimal('0')
interest_amt=acct.last_interest_charge
)
logger.debug('Statements: %s', res)
return res
Expand Down
44 changes: 44 additions & 0 deletions biweeklybudget/models/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@
from sqlalchemy import (
Column, Integer, String, Boolean, Text, Enum, Numeric, inspect, or_
)
from datetime import timedelta
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from sqlalchemy.sql.expression import null

from biweeklybudget.models.base import Base, ModelAsDict
from biweeklybudget.models.account_balance import AccountBalance
from biweeklybudget.models.transaction import Transaction
from biweeklybudget.models.ofx_transaction import OFXTransaction
from biweeklybudget.utils import dtnow
from biweeklybudget.prime_rate import PrimeRateCalculator
import json
Expand All @@ -55,6 +57,19 @@
logger = logging.getLogger(__name__)


class NoInterestChargedError(Exception):
"""
Exception raised when an :py:class:`~.Account` does not have an
OFXTransaction for interest charged within the last 32 days.
"""

def __init__(self, acct):
self.account = acct
super(NoInterestChargedError, self).__init__(
'Could not find last interest charge for account %s' % self
)


class AcctType(enum.Enum):
Bank = 1
Credit = 2
Expand Down Expand Up @@ -308,3 +323,32 @@ def effective_apr(self):
self.prime_rate_margin
)
return self.apr

@property
def last_interest_charge(self):
"""
Return the amount of the last interest charge for this account. Raise an
exception if one could not be identified.
:return: amount of last interest charge for this account
:rtype: decimal.Decimal
"""
sess = inspect(self).session
for t in sess.query(OFXTransaction).filter(
OFXTransaction.account_id.__eq__(self.id)
).order_by(
OFXTransaction.date_posted.desc()
):
if t.trans_type != 'debit':
continue
if 'interest charge' not in t.name.lower():
continue
if t.date_posted < (dtnow() - timedelta(days=32)):
continue
logger.debug(
'Account %s found last interest charge as %s (%s)',
self, (t.amount * -1), t
)
return t.amount * -1
logger.warning('Could not find last interest charge for: %s', self)
raise NoInterestChargedError(self)

0 comments on commit bbf2e5f

Please sign in to comment.