monopyly 1.4.7__py3-none-any.whl → 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- monopyly/CHANGELOG.md +15 -0
- monopyly/README.md +1 -1
- monopyly/__init__.py +3 -3
- monopyly/_version.py +2 -2
- monopyly/auth/actions.py +7 -2
- monopyly/banking/actions.py +51 -10
- monopyly/banking/routes.py +2 -1
- monopyly/cli/apps.py +26 -24
- monopyly/cli/{run.py → launch.py} +19 -8
- monopyly/common/forms/_forms.py +56 -2
- monopyly/common/transactions.py +162 -0
- monopyly/credit/actions.py +29 -0
- monopyly/credit/forms.py +25 -0
- monopyly/credit/routes.py +97 -7
- monopyly/credit/transactions/_transactions.py +15 -0
- monopyly/credit/transactions/activity/__init__.py +3 -0
- monopyly/credit/transactions/activity/data.py +161 -0
- monopyly/credit/transactions/activity/parser.py +274 -0
- monopyly/credit/transactions/activity/reconciliation.py +456 -0
- monopyly/database/models.py +6 -0
- monopyly/static/css/style.css +1146 -263
- monopyly/static/img/icons/statement-pair.png +0 -0
- monopyly/static/img/icons/statement-thick.png +0 -0
- monopyly/static/img/icons/statement.png +0 -0
- monopyly/static/js/bind-tag-actions.js +1 -1
- monopyly/static/js/create-balance-chart.js +1 -1
- monopyly/static/js/create-category-chart.js +27 -0
- monopyly/static/js/define-filter.js +1 -1
- monopyly/static/js/expand-transaction.js +10 -0
- monopyly/static/js/highlight-discrepant-transactions.js +124 -0
- monopyly/static/js/modules/expand-transaction.js +12 -3
- monopyly/static/js/modules/form-suggestions.js +60 -0
- monopyly/static/js/modules/manage-overlays.js +1 -3
- monopyly/static/js/show-credit-activity-loader.js +29 -0
- monopyly/static/js/toggle-navigation.js +35 -0
- monopyly/static/js/update-card-status.js +1 -1
- monopyly/static/js/use-suggested-amount.js +11 -0
- monopyly/static/js/use-suggested-merchant.js +11 -0
- monopyly/templates/banking/account_page.html +3 -1
- monopyly/templates/banking/account_summaries.html +1 -1
- monopyly/templates/banking/accounts_page.html +11 -15
- monopyly/templates/banking/transactions_table/expanded_row_content.html +18 -20
- monopyly/templates/common/transaction_form/subtransaction_subform.html +10 -1
- monopyly/templates/common/transactions_table/linked_bank_transaction.html +1 -1
- monopyly/templates/common/transactions_table/linked_credit_transaction.html +1 -1
- monopyly/templates/common/transactions_table/transaction_condensed.html +2 -2
- monopyly/templates/common/transactions_table/transaction_expanded.html +3 -3
- monopyly/templates/common/transactions_table/transactions.html +1 -1
- monopyly/templates/core/credits.html +10 -8
- monopyly/templates/core/index.html +10 -0
- monopyly/templates/core/profile.html +1 -1
- monopyly/templates/core/story.html +42 -27
- monopyly/templates/credit/statement_page.html +33 -0
- monopyly/templates/credit/statement_reconciliation/discrepant_records.html +25 -0
- monopyly/templates/credit/statement_reconciliation/statement_reconciliation_inquiry.html +23 -0
- monopyly/templates/credit/statement_reconciliation/statement_reconciliation_page.html +86 -0
- monopyly/templates/credit/statement_reconciliation/summary.html +45 -0
- monopyly/templates/credit/statement_reconciliation/unrecorded_activities.html +24 -0
- monopyly/templates/credit/statement_summary.html +2 -2
- monopyly/templates/credit/statements.html +1 -1
- monopyly/templates/credit/transaction_form/transaction_form.html +9 -1
- monopyly/templates/credit/transaction_form/transaction_form_page.html +2 -0
- monopyly/templates/credit/transaction_form/transaction_form_page_update.html +9 -0
- monopyly/templates/credit/transaction_submission_page.html +8 -0
- monopyly/templates/credit/transactions_table/expanded_row_content.html +18 -12
- monopyly/templates/layout.html +46 -29
- {monopyly-1.4.7.dist-info → monopyly-1.5.0.dist-info}/METADATA +3 -2
- {monopyly-1.4.7.dist-info → monopyly-1.5.0.dist-info}/RECORD +72 -56
- monopyly-1.5.0.dist-info/entry_points.txt +2 -0
- monopyly/static/img/icons/statement-pair.svg +0 -281
- monopyly/static/img/icons/statement.svg +0 -294
- monopyly-1.4.7.dist-info/entry_points.txt +0 -2
- {monopyly-1.4.7.dist-info → monopyly-1.5.0.dist-info}/WHEEL +0 -0
- {monopyly-1.4.7.dist-info → monopyly-1.5.0.dist-info}/licenses/COPYING +0 -0
- {monopyly-1.4.7.dist-info → monopyly-1.5.0.dist-info}/licenses/LICENSE +0 -0
monopyly/credit/routes.py
CHANGED
|
@@ -4,7 +4,16 @@ Routes for credit card financials.
|
|
|
4
4
|
|
|
5
5
|
from itertools import islice
|
|
6
6
|
|
|
7
|
-
from flask import
|
|
7
|
+
from flask import (
|
|
8
|
+
flash,
|
|
9
|
+
g,
|
|
10
|
+
jsonify,
|
|
11
|
+
redirect,
|
|
12
|
+
render_template,
|
|
13
|
+
request,
|
|
14
|
+
session,
|
|
15
|
+
url_for,
|
|
16
|
+
)
|
|
8
17
|
from fuisce.database import db_transaction
|
|
9
18
|
from sqlalchemy.exc import MultipleResultsFound
|
|
10
19
|
from werkzeug.exceptions import abort
|
|
@@ -16,7 +25,11 @@ from ..banking.banks import BankHandler
|
|
|
16
25
|
from ..banking.transactions import BankTransactionHandler
|
|
17
26
|
from ..common.forms import form_err_msg
|
|
18
27
|
from ..common.forms.utils import extend_field_list_for_ajax
|
|
19
|
-
from ..common.transactions import
|
|
28
|
+
from ..common.transactions import (
|
|
29
|
+
categorize,
|
|
30
|
+
get_linked_transaction,
|
|
31
|
+
highlight_unmatched_transactions,
|
|
32
|
+
)
|
|
20
33
|
from ..common.utils import dedelimit_float, parse_date, sort_by_frequency
|
|
21
34
|
from .accounts import CreditAccountHandler
|
|
22
35
|
from .actions import (
|
|
@@ -24,6 +37,7 @@ from .actions import (
|
|
|
24
37
|
get_potential_preceding_card,
|
|
25
38
|
get_statement_and_transactions,
|
|
26
39
|
make_payment,
|
|
40
|
+
parse_request_transaction_data,
|
|
27
41
|
transfer_credit_card_statement,
|
|
28
42
|
)
|
|
29
43
|
from .blueprint import bp
|
|
@@ -31,6 +45,11 @@ from .cards import CreditCardHandler, save_card
|
|
|
31
45
|
from .forms import CardStatementTransferForm, CreditCardForm, CreditTransactionForm
|
|
32
46
|
from .statements import CreditStatementHandler
|
|
33
47
|
from .transactions import CreditTagHandler, CreditTransactionHandler, save_transaction
|
|
48
|
+
from .transactions.activity import (
|
|
49
|
+
ActivityMatchmaker,
|
|
50
|
+
TransactionActivities,
|
|
51
|
+
parse_transaction_activity_file,
|
|
52
|
+
)
|
|
34
53
|
|
|
35
54
|
|
|
36
55
|
@bp.route("/cards")
|
|
@@ -179,6 +198,7 @@ def update_statements_display():
|
|
|
179
198
|
@login_required
|
|
180
199
|
def load_statement_details(statement_id):
|
|
181
200
|
statement, transactions = get_statement_and_transactions(statement_id)
|
|
201
|
+
categories = categorize(transactions)
|
|
182
202
|
# Get bank accounts for potential payments
|
|
183
203
|
bank_accounts = BankAccountHandler.get_accounts()
|
|
184
204
|
return render_template(
|
|
@@ -186,6 +206,7 @@ def load_statement_details(statement_id):
|
|
|
186
206
|
statement=statement,
|
|
187
207
|
statement_transactions=transactions,
|
|
188
208
|
bank_accounts=bank_accounts,
|
|
209
|
+
chart_data=categories.assemble_chart_data(exclude=["Credit payments"]),
|
|
189
210
|
)
|
|
190
211
|
|
|
191
212
|
|
|
@@ -232,6 +253,68 @@ def pay_credit_card(card_id, statement_id):
|
|
|
232
253
|
return jsonify((summary_template, transactions_table_template))
|
|
233
254
|
|
|
234
255
|
|
|
256
|
+
@bp.route("/_reconcile_activity/<int:statement_id>")
|
|
257
|
+
@login_required
|
|
258
|
+
def reconcile_activity(statement_id):
|
|
259
|
+
return render_template(
|
|
260
|
+
"credit/statement_reconciliation/statement_reconciliation_inquiry.html",
|
|
261
|
+
statement_id=statement_id,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@bp.route("/reconciliation/<int:statement_id>", methods=("GET", "POST"))
|
|
266
|
+
@login_required
|
|
267
|
+
def load_statement_reconciliation_details(statement_id):
|
|
268
|
+
if request.method == "POST":
|
|
269
|
+
activity_file = request.files.get("activity-file")
|
|
270
|
+
# Parse the data and match transactions to activities
|
|
271
|
+
if activities := parse_transaction_activity_file(activity_file):
|
|
272
|
+
session["reconciliation_info"] = (statement_id, activities.jsonify())
|
|
273
|
+
else:
|
|
274
|
+
activity_data = session.get("reconciliation_info", (None, []))[1]
|
|
275
|
+
activities = TransactionActivities(activity_data)
|
|
276
|
+
if activities:
|
|
277
|
+
statement, transactions = get_statement_and_transactions(statement_id)
|
|
278
|
+
matchmaker = ActivityMatchmaker(transactions, activities)
|
|
279
|
+
non_matches = matchmaker.unmatched_transactions
|
|
280
|
+
transactions = list(highlight_unmatched_transactions(transactions, non_matches))
|
|
281
|
+
# Calculate the amount charged/refunded during this statement timeframe
|
|
282
|
+
prior_statement = CreditStatementHandler.get_prior_statement(statement)
|
|
283
|
+
prior_statement_balance = prior_statement.balance if prior_statement else 0
|
|
284
|
+
statement_transaction_balance = statement.balance - prior_statement_balance
|
|
285
|
+
return render_template(
|
|
286
|
+
"credit/statement_reconciliation/statement_reconciliation_page.html",
|
|
287
|
+
statement=statement,
|
|
288
|
+
statement_transactions=transactions,
|
|
289
|
+
discrepant_records=matchmaker.match_discrepancies,
|
|
290
|
+
discrepant_amount=abs(statement_transaction_balance - activities.total),
|
|
291
|
+
unrecorded_activities=matchmaker.unmatched_activities,
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
flash("ERROR")
|
|
295
|
+
return redirect(
|
|
296
|
+
url_for("credit.load_statement_details", statement_id=statement_id)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@bp.before_app_request
|
|
301
|
+
def clear_reconciliation_info():
|
|
302
|
+
exempt_endpoints = (
|
|
303
|
+
"credit.reconcile_activity",
|
|
304
|
+
"credit.load_statement_reconciliation_details",
|
|
305
|
+
"credit.expand_transaction",
|
|
306
|
+
"credit.add_transaction",
|
|
307
|
+
"credit.update_transaction",
|
|
308
|
+
"credit.infer_statement",
|
|
309
|
+
"credit.suggest_transaction_autocomplete",
|
|
310
|
+
"credit.delete_transacton",
|
|
311
|
+
"static",
|
|
312
|
+
None,
|
|
313
|
+
)
|
|
314
|
+
if request.endpoint not in exempt_endpoints:
|
|
315
|
+
session.pop("reconciliation_info", None)
|
|
316
|
+
|
|
317
|
+
|
|
235
318
|
@bp.route("/transactions", defaults={"card_id": None})
|
|
236
319
|
@bp.route("/transactions/<int:card_id>")
|
|
237
320
|
@login_required
|
|
@@ -333,12 +416,16 @@ def add_transaction(card_id, statement_id):
|
|
|
333
416
|
update=False,
|
|
334
417
|
)
|
|
335
418
|
else:
|
|
419
|
+
transaction_data = parse_request_transaction_data(request.args)
|
|
336
420
|
if statement_id:
|
|
337
|
-
|
|
338
|
-
form = form.prepopulate(statement)
|
|
421
|
+
entry = CreditStatementHandler.get_entry(statement_id)
|
|
339
422
|
elif card_id:
|
|
340
|
-
|
|
341
|
-
|
|
423
|
+
entry = CreditCardHandler.get_entry(card_id)
|
|
424
|
+
else:
|
|
425
|
+
entry = None
|
|
426
|
+
form = form.prepopulate(
|
|
427
|
+
entry, data=transaction_data, suggestion_fields=["merchant"]
|
|
428
|
+
)
|
|
342
429
|
# Display the form for accepting user input
|
|
343
430
|
return render_template(
|
|
344
431
|
"credit/transaction_form/transaction_form_page_new.html", form=form
|
|
@@ -360,8 +447,11 @@ def update_transaction(transaction_id):
|
|
|
360
447
|
update=True,
|
|
361
448
|
)
|
|
362
449
|
else:
|
|
450
|
+
transaction_data = parse_request_transaction_data(request.args)
|
|
363
451
|
transaction = CreditTransactionHandler.get_entry(transaction_id)
|
|
364
|
-
form = form.prepopulate(
|
|
452
|
+
form = form.prepopulate(
|
|
453
|
+
transaction, data=transaction_data, suggestion_fields=["amount"]
|
|
454
|
+
)
|
|
365
455
|
# Display the form for accepting user input
|
|
366
456
|
return render_template(
|
|
367
457
|
"credit/transaction_form/transaction_form_page_update.html",
|
|
@@ -81,6 +81,21 @@ class CreditTransactionHandler(
|
|
|
81
81
|
)
|
|
82
82
|
return transactions
|
|
83
83
|
|
|
84
|
+
@classmethod
|
|
85
|
+
@DatabaseViewHandler.view_query
|
|
86
|
+
def get_merchants(cls):
|
|
87
|
+
"""
|
|
88
|
+
Get a credit card merchants from the database.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
merchants : sqlalchemy.engine.ScalarResult
|
|
93
|
+
All known credit card transaction merchants from the
|
|
94
|
+
database.
|
|
95
|
+
"""
|
|
96
|
+
query = cls.model.select_for_user(cls.model.merchant).distinct()
|
|
97
|
+
return cls._db.session.scalars(query)
|
|
98
|
+
|
|
84
99
|
@classmethod
|
|
85
100
|
def add_entry(cls, **field_values):
|
|
86
101
|
"""
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Data structures for working with credit card activity data."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from collections import UserList, namedtuple
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from flask import current_app
|
|
8
|
+
from werkzeug.utils import secure_filename
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TransactionActivities(UserList):
|
|
12
|
+
"""
|
|
13
|
+
A list-like datatype for storing transaction activity information.
|
|
14
|
+
|
|
15
|
+
A subclass of `UserList` that stores transaction activity data in an
|
|
16
|
+
easily accessible format. The object is constructed by passing a
|
|
17
|
+
normal list of ordered lists/tuples, and converts each row into an
|
|
18
|
+
equivalent `namedtuple` object, with data recorded according to
|
|
19
|
+
its column type.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
data : list
|
|
24
|
+
A list of ordered lists/tuples that contain the data to be
|
|
25
|
+
converted into this `TransactionActivities` instance.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
column_types = ("transaction_date", "total", "description")
|
|
29
|
+
|
|
30
|
+
def __init__(self, data=()):
|
|
31
|
+
super().__init__([self._load_activity_from_data(*row) for row in data])
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def _load_activity_from_data(cls, date, total, description):
|
|
35
|
+
activity_cls = namedtuple("TransactionActivity", cls.column_types)
|
|
36
|
+
if isinstance(date, datetime.date):
|
|
37
|
+
transaction_date = date
|
|
38
|
+
else:
|
|
39
|
+
try:
|
|
40
|
+
transaction_date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
|
41
|
+
except ValueError:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"The given date '{date}' of type `{type(date)}` is not recognized. "
|
|
44
|
+
"Dates must be native `datetime.date` objects or strings given in "
|
|
45
|
+
"the form 'YYYY-MM-DD'."
|
|
46
|
+
)
|
|
47
|
+
return activity_cls(transaction_date, total, description)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def total(self):
|
|
51
|
+
"""The sum of the totals of each activity in the list."""
|
|
52
|
+
return sum(_.total for _ in self.data)
|
|
53
|
+
|
|
54
|
+
def jsonify(self):
|
|
55
|
+
"""Return a JSON serializable representation of the activities."""
|
|
56
|
+
return [(str(_.transaction_date), _.total, _.description) for _ in self.data]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TransactionActivityGroup(UserList):
|
|
60
|
+
"""A minimalistic class for aggregating individual transaction activities."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, transaction_activities):
|
|
63
|
+
transaction_activities = list(transaction_activities)
|
|
64
|
+
self._check_grouping_validity(transaction_activities)
|
|
65
|
+
super().__init__(transaction_activities)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def transaction_date(self):
|
|
69
|
+
return self.data[0].transaction_date
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def total(self):
|
|
73
|
+
return sum(activity.total for activity in self.data)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def description(self):
|
|
77
|
+
return self.data[0].description
|
|
78
|
+
|
|
79
|
+
def _check_grouping_validity(self, activities):
|
|
80
|
+
self._ensure_field_commonality("transaction_date", activities)
|
|
81
|
+
self._ensure_field_commonality("description", activities)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _ensure_field_commonality(field, activities):
|
|
85
|
+
field_value = getattr(activities[0], field)
|
|
86
|
+
if not all(getattr(activity, field) == field_value for activity in activities):
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"All transaction activities in a grouping must share the same value "
|
|
89
|
+
f"for the '{field}' field."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ActivityLoadingError(RuntimeError):
|
|
94
|
+
"""A special exception indicating that an activity CSV failed to load."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, msg="", *args, **kwargs):
|
|
97
|
+
self.msg = msg
|
|
98
|
+
super().__init__(msg, *args, **kwargs)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TransactionActivityLoader:
|
|
102
|
+
"""
|
|
103
|
+
A tool to load transaction activity CSV files.
|
|
104
|
+
|
|
105
|
+
This is an object designed to load CSV files provided by a user. It
|
|
106
|
+
loads the file, stores it in an app-local directory, and then
|
|
107
|
+
provides access to uploaded file path.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
activity_dir : pathlib.Path
|
|
112
|
+
The path to the directory where uploaded activity files will be
|
|
113
|
+
stored. The default path is a directory named `.credit_activity`
|
|
114
|
+
within the app's instance directory.
|
|
115
|
+
|
|
116
|
+
Attributes
|
|
117
|
+
----------
|
|
118
|
+
activity_dir : pathlib.Path
|
|
119
|
+
The path to the directory where uploaded activity files will be
|
|
120
|
+
stored. If left unset, the default path will be a directory
|
|
121
|
+
named `.credit_activity` within the app's instance directory.
|
|
122
|
+
loaded_files : list
|
|
123
|
+
A list of paths to loaded files; this list is empty before
|
|
124
|
+
adding any files and after cleaning up any previously loaded
|
|
125
|
+
files.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, activity_dir=None):
|
|
129
|
+
# Create and use a directory to store uploaded activity files
|
|
130
|
+
default_activity_dir = Path(current_app.instance_path) / ".credit_activity"
|
|
131
|
+
self.activity_dir = Path(activity_dir) if activity_dir else default_activity_dir
|
|
132
|
+
self.activity_dir.mkdir(exist_ok=True)
|
|
133
|
+
self.loaded_files = []
|
|
134
|
+
|
|
135
|
+
def upload(self, activity_file):
|
|
136
|
+
"""
|
|
137
|
+
Upload a CSV file containing credit transaction activity.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
activity_file : werkzeug.datastructures.FileStorage
|
|
142
|
+
The file object to be loaded.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
activity_filepath : pathlib.Path
|
|
147
|
+
The path to the uploaded activity file.
|
|
148
|
+
"""
|
|
149
|
+
activity_filename = secure_filename(activity_file.filename)
|
|
150
|
+
if not activity_filename:
|
|
151
|
+
raise ActivityLoadingError("No activity file was specified.")
|
|
152
|
+
activity_filepath = self.activity_dir / activity_filename
|
|
153
|
+
activity_file.save(activity_filepath)
|
|
154
|
+
self.loaded_files.append(activity_filepath)
|
|
155
|
+
return activity_filepath
|
|
156
|
+
|
|
157
|
+
def cleanup(self):
|
|
158
|
+
"""Clean all loaded files from the activity directory."""
|
|
159
|
+
while self.loaded_files:
|
|
160
|
+
activity_filepath = self.loaded_files.pop()
|
|
161
|
+
activity_filepath.unlink()
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Define a parser and associated functionality for reading activity data CSV files."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from flask import current_app
|
|
8
|
+
from werkzeug.utils import secure_filename
|
|
9
|
+
|
|
10
|
+
from ....common.utils import parse_date
|
|
11
|
+
from .data import ActivityLoadingError, TransactionActivities, TransactionActivityLoader
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_transaction_activity_file(transaction_file):
|
|
15
|
+
"""
|
|
16
|
+
Parse a CSV file containing reported transaction activity.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
activity_file : werkzeug.datastructures.FileStorage, pathlib.Path
|
|
21
|
+
The file object containing transaction activity data or a path
|
|
22
|
+
to a file containing transaction activity data.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
activities : TransactionActivities
|
|
27
|
+
The list-like object containing credit transaction activity
|
|
28
|
+
data.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
return _TransactionActivityParser(transaction_file).data
|
|
32
|
+
except ActivityLoadingError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _ColumnIdentifier(ABC):
|
|
37
|
+
"""
|
|
38
|
+
An object to aid in identifying the column matching a certain type.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
raw_header : list
|
|
43
|
+
The header row as a list of column titles.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, raw_header):
|
|
47
|
+
self._raw_header = raw_header
|
|
48
|
+
self._potential_column_indices = []
|
|
49
|
+
|
|
50
|
+
def determine_index(self):
|
|
51
|
+
"""
|
|
52
|
+
Given the data header, determine the current column type index.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
index : int, None
|
|
57
|
+
The index of the column corresponding to the current column
|
|
58
|
+
type.
|
|
59
|
+
"""
|
|
60
|
+
for column_index, column_title in enumerate(self._raw_header):
|
|
61
|
+
standardized_title = column_title.strip().casefold()
|
|
62
|
+
column_match = self.check(standardized_title)
|
|
63
|
+
if column_match is True:
|
|
64
|
+
return column_index
|
|
65
|
+
elif column_match is None:
|
|
66
|
+
self._potential_column_indices.append(column_index)
|
|
67
|
+
return self._infer_column_index()
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def check(self, standardized_title):
|
|
71
|
+
raise NotImplementedError(
|
|
72
|
+
"Define how a column identification check is performed in a subclass. "
|
|
73
|
+
"A check should return `True` if the title does describe the class's "
|
|
74
|
+
"column type, `False` if the title does *not* describe the class's column "
|
|
75
|
+
"type, and `None` if the title may describe the column type but more "
|
|
76
|
+
"information is required."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _infer_column_index(self):
|
|
80
|
+
# Attempt to infer the current column type index from a potential match
|
|
81
|
+
if len(self._potential_column_indices) == 1:
|
|
82
|
+
return self._potential_column_indices[0]
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _TransactionDateColumnIdentifier(_ColumnIdentifier):
|
|
87
|
+
def check(self, standardized_title):
|
|
88
|
+
"""Check if the title indicates this column contains a transaction date."""
|
|
89
|
+
if standardized_title == "transaction date":
|
|
90
|
+
return True
|
|
91
|
+
elif "date" in standardized_title.split():
|
|
92
|
+
if "trans." in standardized_title:
|
|
93
|
+
return True
|
|
94
|
+
elif standardized_title == "date":
|
|
95
|
+
return None
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _TransactionTotalColumnIdentifier(_ColumnIdentifier):
|
|
100
|
+
def check(self, standardized_title):
|
|
101
|
+
"""Check if the title indicates this column contains an amount."""
|
|
102
|
+
return standardized_title in ("amount", "total")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _TransactionDescriptionColumnIdentifier(_ColumnIdentifier):
|
|
106
|
+
def check(self, standardized_title):
|
|
107
|
+
"""Check if the title indicates this column contains a description."""
|
|
108
|
+
return standardized_title in ("description", "desc.")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _TransactionCategoryColumnIdentifier(_ColumnIdentifier):
|
|
112
|
+
def check(self, standardized_title):
|
|
113
|
+
"""Check if the title indicates this column contains a category."""
|
|
114
|
+
return standardized_title == "category"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _TransactionTypeColumnIdentifier(_ColumnIdentifier):
|
|
118
|
+
def check(self, standardized_title):
|
|
119
|
+
"""Check if the title indicates this column contains a transaction type."""
|
|
120
|
+
return standardized_title == "type"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _TransactionActivityParser:
|
|
124
|
+
"""
|
|
125
|
+
A parser for arbitrary CSV files containing transaction activity.
|
|
126
|
+
|
|
127
|
+
This object contains logic and utilities for parsing aribtrary CSV
|
|
128
|
+
files containing transaction activity data. It is generalized to
|
|
129
|
+
work with various CSV labeling schemes, inferring information about
|
|
130
|
+
the CSV data from the contents of the file.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
activity_file : werkzeug.datastructures.FileStorage, pathlib.Path
|
|
135
|
+
The file object containing transaction activity data or a path
|
|
136
|
+
to a file containing transaction activity data.
|
|
137
|
+
activity_dir : pathlib.Path
|
|
138
|
+
The path to the directory where activity files to be parsed will
|
|
139
|
+
be stored after uploading.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
_column_identifiers = {
|
|
143
|
+
"transaction_date": _TransactionDateColumnIdentifier,
|
|
144
|
+
"total": _TransactionTotalColumnIdentifier,
|
|
145
|
+
"description": _TransactionDescriptionColumnIdentifier,
|
|
146
|
+
"category": _TransactionCategoryColumnIdentifier,
|
|
147
|
+
"type": _TransactionTypeColumnIdentifier,
|
|
148
|
+
}
|
|
149
|
+
_raw_column_types = list(_column_identifiers.keys())
|
|
150
|
+
column_types = _raw_column_types[:3]
|
|
151
|
+
|
|
152
|
+
def __init__(self, activity_file, activity_dir=None):
|
|
153
|
+
file_loader = TransactionActivityLoader(activity_dir=activity_dir)
|
|
154
|
+
if isinstance(activity_file, Path):
|
|
155
|
+
activity_filepath = activity_file
|
|
156
|
+
else:
|
|
157
|
+
activity_filepath = file_loader.upload(activity_file)
|
|
158
|
+
# Load data from the activity file
|
|
159
|
+
raw_header, raw_data = self._load_data(activity_filepath)
|
|
160
|
+
if not raw_data:
|
|
161
|
+
raise ActivityLoadingError("The activity file contains no actionable data.")
|
|
162
|
+
# Parse the loaded activity data
|
|
163
|
+
self._raw_column_indices = self._determine_column_indices(raw_header)
|
|
164
|
+
self._negative_charges = self._determine_expenditure_sign(raw_data)
|
|
165
|
+
self.column_indices = {name: i for i, name in enumerate(self.column_types)}
|
|
166
|
+
self.data = TransactionActivities(self._process_data(row) for row in raw_data)
|
|
167
|
+
# Remove the loaded activity file
|
|
168
|
+
file_loader.cleanup()
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _load_data(transaction_filepath):
|
|
172
|
+
# Load raw header information and data from the transaction file
|
|
173
|
+
with transaction_filepath.open() as csv_file:
|
|
174
|
+
csv_reader = csv.reader(csv_file)
|
|
175
|
+
raw_header = next(csv_reader)
|
|
176
|
+
raw_data = list(csv_reader)
|
|
177
|
+
return raw_header, raw_data
|
|
178
|
+
|
|
179
|
+
def _determine_column_indices(self, raw_header):
|
|
180
|
+
# Determine the indices of various columns in the raw header/data
|
|
181
|
+
raw_column_indices = {
|
|
182
|
+
column_type: identifier(raw_header).determine_index()
|
|
183
|
+
for column_type, identifier in self._column_identifiers.items()
|
|
184
|
+
}
|
|
185
|
+
for column_type in self.column_types:
|
|
186
|
+
if raw_column_indices[column_type] is None:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
f"The '{column_type}' column could not be identified in the data."
|
|
189
|
+
)
|
|
190
|
+
return raw_column_indices
|
|
191
|
+
|
|
192
|
+
def _determine_expenditure_sign(self, raw_data):
|
|
193
|
+
# Determine the sign of expenditures
|
|
194
|
+
# - Charges may be reported as either positive or negative amounts
|
|
195
|
+
# - Negatively valued charges (positively valued payments) return `True`;
|
|
196
|
+
# positively valued charges (negatively valued payments) return `False`
|
|
197
|
+
# Note: This method assumes that an activity file will not report a standard
|
|
198
|
+
# transaction as a "payment"
|
|
199
|
+
contextual_column_types = ("category", "description", "type")
|
|
200
|
+
contextual_column_indices = [
|
|
201
|
+
index
|
|
202
|
+
for column_type, index in self._raw_column_indices.items()
|
|
203
|
+
if column_type in contextual_column_types and index is not None
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
def _infer_payment_row(row):
|
|
207
|
+
# Infer whether the row constitutes a payment transaction
|
|
208
|
+
contextual_info = [row[i].lower() for i in contextual_column_indices]
|
|
209
|
+
return any("payment" in element for element in contextual_info)
|
|
210
|
+
|
|
211
|
+
payment_rows = list(filter(_infer_payment_row, raw_data))
|
|
212
|
+
return self._extrapolate_payments_positive(payment_rows, raw_data)
|
|
213
|
+
|
|
214
|
+
def _extrapolate_payments_positive(self, payment_rows, raw_data):
|
|
215
|
+
if payment_rows:
|
|
216
|
+
payments_positive = self._evaluate_payment_signs(payment_rows)
|
|
217
|
+
else:
|
|
218
|
+
payments_positive = self._evaluate_nonpayment_signs(raw_data)
|
|
219
|
+
if payments_positive is None:
|
|
220
|
+
raise RuntimeError(
|
|
221
|
+
"The sign of credits/debits could not be determined from the data."
|
|
222
|
+
)
|
|
223
|
+
return payments_positive
|
|
224
|
+
|
|
225
|
+
def _evaluate_payment_signs(self, payment_rows):
|
|
226
|
+
# Iterate over each payment row and collect sign information
|
|
227
|
+
amount_index = self._raw_column_indices["total"]
|
|
228
|
+
payment_signs_positive = [float(row[amount_index]) > 0 for row in payment_rows]
|
|
229
|
+
# Evaluate whether payment amounts are treated as positively valued
|
|
230
|
+
if all(payment_signs_positive):
|
|
231
|
+
payments_positive = True
|
|
232
|
+
elif not any(payment_signs_positive):
|
|
233
|
+
payments_positive = False
|
|
234
|
+
else:
|
|
235
|
+
payments_positive = None
|
|
236
|
+
return payments_positive
|
|
237
|
+
|
|
238
|
+
def _evaluate_nonpayment_signs(self, nonpayment_rows):
|
|
239
|
+
# Iterate over each non-payment and collect sign information
|
|
240
|
+
# - Assume the majority of transactions are charges if no payments are found
|
|
241
|
+
amount_index = self._raw_column_indices["total"]
|
|
242
|
+
negative_charge_count = sum(
|
|
243
|
+
float(row[amount_index]) < 0 for row in nonpayment_rows
|
|
244
|
+
)
|
|
245
|
+
# Evaluate whether assumed non-payment amounts seem positively valued
|
|
246
|
+
negative_charge_frac = negative_charge_count / len(nonpayment_rows)
|
|
247
|
+
if negative_charge_frac == 0.5:
|
|
248
|
+
payments_positive = None
|
|
249
|
+
else:
|
|
250
|
+
# Payments are assumed to be positive if most of the transactions are
|
|
251
|
+
# charges and most of the charges are negative
|
|
252
|
+
payments_positive = negative_charge_frac > 0.5
|
|
253
|
+
return payments_positive
|
|
254
|
+
|
|
255
|
+
def _process_data(self, row):
|
|
256
|
+
# Process the raw data into an output format
|
|
257
|
+
processed_row = []
|
|
258
|
+
for column_type in self.column_types:
|
|
259
|
+
value = row[self._raw_column_indices[column_type]]
|
|
260
|
+
if column_type == "transaction_date":
|
|
261
|
+
value = self._process_date_data(value)
|
|
262
|
+
elif column_type == "total":
|
|
263
|
+
value = self._process_amount_data(value)
|
|
264
|
+
processed_row.append(value)
|
|
265
|
+
return processed_row
|
|
266
|
+
|
|
267
|
+
def _process_amount_data(self, value):
|
|
268
|
+
# Charges should be positively valued, payments negatively valued
|
|
269
|
+
value = float(value)
|
|
270
|
+
return -value if self._negative_charges else value
|
|
271
|
+
|
|
272
|
+
def _process_date_data(self, value):
|
|
273
|
+
# Output dates as `datetime.date` objects
|
|
274
|
+
return parse_date(value)
|