monopyly 1.4.8__py3-none-any.whl → 1.5.1__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.
Files changed (77) hide show
  1. monopyly/CHANGELOG.md +18 -0
  2. monopyly/__init__.py +2 -2
  3. monopyly/_version.py +14 -2
  4. monopyly/auth/actions.py +12 -0
  5. monopyly/auth/routes.py +30 -21
  6. monopyly/auth/tools.py +1 -2
  7. monopyly/cli/apps.py +1 -1
  8. monopyly/cli/launch.py +3 -0
  9. monopyly/common/forms/_forms.py +56 -2
  10. monopyly/common/forms/utils.py +1 -2
  11. monopyly/common/transactions.py +162 -0
  12. monopyly/core/routes.py +0 -6
  13. monopyly/credit/actions.py +29 -0
  14. monopyly/credit/forms.py +25 -0
  15. monopyly/credit/routes.py +115 -7
  16. monopyly/credit/transactions/_transactions.py +15 -0
  17. monopyly/credit/transactions/activity/__init__.py +3 -0
  18. monopyly/credit/transactions/activity/data.py +161 -0
  19. monopyly/credit/transactions/activity/parser.py +282 -0
  20. monopyly/credit/transactions/activity/reconciliation.py +456 -0
  21. monopyly/database/models.py +6 -0
  22. monopyly/static/css/style.css +1328 -270
  23. monopyly/static/img/icons/statement-pair.png +0 -0
  24. monopyly/static/img/icons/statement-thick.png +0 -0
  25. monopyly/static/img/icons/statement.png +0 -0
  26. monopyly/static/js/bind-tag-actions.js +1 -1
  27. monopyly/static/js/create-balance-chart.js +1 -1
  28. monopyly/static/js/create-category-chart.js +27 -0
  29. monopyly/static/js/define-filter.js +1 -1
  30. monopyly/static/js/expand-transaction.js +10 -0
  31. monopyly/static/js/highlight-discrepant-transactions.js +124 -0
  32. monopyly/static/js/modules/expand-transaction.js +12 -3
  33. monopyly/static/js/modules/form-suggestions.js +60 -0
  34. monopyly/static/js/modules/manage-overlays.js +1 -3
  35. monopyly/static/js/show-credit-activity-loader.js +29 -0
  36. monopyly/static/js/toggle-navigation.js +35 -0
  37. monopyly/static/js/update-card-status.js +1 -1
  38. monopyly/static/js/use-suggested-amount.js +11 -0
  39. monopyly/static/js/use-suggested-merchant.js +11 -0
  40. monopyly/templates/auth/change_password.html +21 -0
  41. monopyly/templates/auth/login.html +3 -1
  42. monopyly/templates/auth/register.html +17 -7
  43. monopyly/templates/banking/account_page.html +3 -1
  44. monopyly/templates/banking/account_summaries.html +1 -1
  45. monopyly/templates/banking/accounts_page.html +11 -15
  46. monopyly/templates/banking/transactions_table/expanded_row_content.html +18 -20
  47. monopyly/templates/common/transaction_form/subtransaction_subform.html +10 -1
  48. monopyly/templates/common/transactions_table/linked_bank_transaction.html +1 -1
  49. monopyly/templates/common/transactions_table/linked_credit_transaction.html +1 -1
  50. monopyly/templates/common/transactions_table/transaction_condensed.html +2 -2
  51. monopyly/templates/common/transactions_table/transaction_expanded.html +3 -3
  52. monopyly/templates/common/transactions_table/transactions.html +1 -1
  53. monopyly/templates/core/credits.html +10 -8
  54. monopyly/templates/core/index.html +10 -0
  55. monopyly/templates/core/profile.html +3 -3
  56. monopyly/templates/credit/statement_page.html +33 -0
  57. monopyly/templates/credit/statement_reconciliation/discrepant_records.html +25 -0
  58. monopyly/templates/credit/statement_reconciliation/statement_reconciliation_inquiry.html +23 -0
  59. monopyly/templates/credit/statement_reconciliation/statement_reconciliation_page.html +86 -0
  60. monopyly/templates/credit/statement_reconciliation/summary.html +45 -0
  61. monopyly/templates/credit/statement_reconciliation/unrecorded_activities.html +24 -0
  62. monopyly/templates/credit/statement_summary.html +2 -2
  63. monopyly/templates/credit/statements.html +1 -1
  64. monopyly/templates/credit/transaction_form/transaction_form.html +9 -1
  65. monopyly/templates/credit/transaction_form/transaction_form_page.html +2 -0
  66. monopyly/templates/credit/transaction_form/transaction_form_page_update.html +9 -0
  67. monopyly/templates/credit/transaction_submission_page.html +64 -63
  68. monopyly/templates/credit/transactions_table/expanded_row_content.html +18 -12
  69. monopyly/templates/layout.html +37 -29
  70. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/METADATA +8 -7
  71. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/RECORD +75 -58
  72. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/WHEEL +1 -1
  73. monopyly/static/img/icons/statement-pair.svg +0 -281
  74. monopyly/static/img/icons/statement.svg +0 -294
  75. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/entry_points.txt +0 -0
  76. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/licenses/COPYING +0 -0
  77. {monopyly-1.4.8.dist-info → monopyly-1.5.1.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 flash, g, jsonify, redirect, render_template, request, url_for
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 get_linked_transaction
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,16 +198,32 @@ 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()
204
+ # Save a pointer to this statement to allow easy returns
205
+ session["statement_focus"] = statement_id
184
206
  return render_template(
185
207
  "credit/statement_page.html",
186
208
  statement=statement,
187
209
  statement_transactions=transactions,
188
210
  bank_accounts=bank_accounts,
211
+ chart_data=categories.assemble_chart_data(exclude=["Credit payments"]),
189
212
  )
190
213
 
191
214
 
215
+ @bp.before_app_request
216
+ def clear_statement_focus():
217
+ exempt_endpoints = (
218
+ "credit.expand_transaction",
219
+ "credit.delete_transaction",
220
+ "static",
221
+ None,
222
+ )
223
+ if request.endpoint not in exempt_endpoints:
224
+ session.pop("statement_focus", None)
225
+
226
+
192
227
  @bp.route("/_update_statement_due_date/<int:statement_id>", methods=("POST",))
193
228
  @login_required
194
229
  @db_transaction
@@ -232,6 +267,68 @@ def pay_credit_card(card_id, statement_id):
232
267
  return jsonify((summary_template, transactions_table_template))
233
268
 
234
269
 
270
+ @bp.route("/_reconcile_activity/<int:statement_id>")
271
+ @login_required
272
+ def reconcile_activity(statement_id):
273
+ return render_template(
274
+ "credit/statement_reconciliation/statement_reconciliation_inquiry.html",
275
+ statement_id=statement_id,
276
+ )
277
+
278
+
279
+ @bp.route("/reconciliation/<int:statement_id>", methods=("GET", "POST"))
280
+ @login_required
281
+ def load_statement_reconciliation_details(statement_id):
282
+ if request.method == "POST":
283
+ activity_file = request.files.get("activity-file")
284
+ # Parse the data and match transactions to activities
285
+ if activities := parse_transaction_activity_file(activity_file):
286
+ session["reconciliation_info"] = (statement_id, activities.jsonify())
287
+ else:
288
+ activity_data = session.get("reconciliation_info", (None, []))[1]
289
+ activities = TransactionActivities(activity_data)
290
+ if activities:
291
+ statement, transactions = get_statement_and_transactions(statement_id)
292
+ matchmaker = ActivityMatchmaker(transactions, activities)
293
+ non_matches = matchmaker.unmatched_transactions
294
+ transactions = list(highlight_unmatched_transactions(transactions, non_matches))
295
+ # Calculate the amount charged/refunded during this statement timeframe
296
+ prior_statement = CreditStatementHandler.get_prior_statement(statement)
297
+ prior_statement_balance = prior_statement.balance if prior_statement else 0
298
+ statement_transaction_balance = statement.balance - prior_statement_balance
299
+ return render_template(
300
+ "credit/statement_reconciliation/statement_reconciliation_page.html",
301
+ statement=statement,
302
+ statement_transactions=transactions,
303
+ discrepant_records=matchmaker.match_discrepancies,
304
+ discrepant_amount=abs(statement_transaction_balance - activities.total),
305
+ unrecorded_activities=matchmaker.unmatched_activities,
306
+ )
307
+ else:
308
+ flash("ERROR")
309
+ return redirect(
310
+ url_for("credit.load_statement_details", statement_id=statement_id)
311
+ )
312
+
313
+
314
+ @bp.before_app_request
315
+ def clear_reconciliation_info():
316
+ exempt_endpoints = (
317
+ "credit.reconcile_activity",
318
+ "credit.load_statement_reconciliation_details",
319
+ "credit.expand_transaction",
320
+ "credit.add_transaction",
321
+ "credit.update_transaction",
322
+ "credit.infer_statement",
323
+ "credit.suggest_transaction_autocomplete",
324
+ "credit.delete_transaction",
325
+ "static",
326
+ None,
327
+ )
328
+ if request.endpoint not in exempt_endpoints:
329
+ session.pop("reconciliation_info", None)
330
+
331
+
235
332
  @bp.route("/transactions", defaults={"card_id": None})
236
333
  @bp.route("/transactions/<int:card_id>")
237
334
  @login_required
@@ -333,12 +430,16 @@ def add_transaction(card_id, statement_id):
333
430
  update=False,
334
431
  )
335
432
  else:
433
+ transaction_data = parse_request_transaction_data(request.args)
336
434
  if statement_id:
337
- statement = CreditStatementHandler.get_entry(statement_id)
338
- form = form.prepopulate(statement)
435
+ entry = CreditStatementHandler.get_entry(statement_id)
339
436
  elif card_id:
340
- card = CreditCardHandler.get_entry(card_id)
341
- form = form.prepopulate(card)
437
+ entry = CreditCardHandler.get_entry(card_id)
438
+ else:
439
+ entry = None
440
+ form = form.prepopulate(
441
+ entry, data=transaction_data, suggestion_fields=["merchant"]
442
+ )
342
443
  # Display the form for accepting user input
343
444
  return render_template(
344
445
  "credit/transaction_form/transaction_form_page_new.html", form=form
@@ -360,8 +461,11 @@ def update_transaction(transaction_id):
360
461
  update=True,
361
462
  )
362
463
  else:
464
+ transaction_data = parse_request_transaction_data(request.args)
363
465
  transaction = CreditTransactionHandler.get_entry(transaction_id)
364
- form = form.prepopulate(transaction)
466
+ form = form.prepopulate(
467
+ transaction, data=transaction_data, suggestion_fields=["amount"]
468
+ )
365
469
  # Display the form for accepting user input
366
470
  return render_template(
367
471
  "credit/transaction_form/transaction_form_page_update.html",
@@ -393,6 +497,10 @@ def add_subtransaction_fields():
393
497
  @db_transaction
394
498
  def delete_transaction(transaction_id):
395
499
  CreditTransactionHandler.delete_entry(transaction_id)
500
+ if statement_id := session.pop("statement_focus", None):
501
+ return redirect(
502
+ url_for("credit.load_statement_details", statement_id=statement_id)
503
+ )
396
504
  return redirect(url_for("credit.load_transactions"))
397
505
 
398
506
 
@@ -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,3 @@
1
+ from .data import TransactionActivities
2
+ from .parser import parse_transaction_activity_file
3
+ from .reconciliation import ActivityMatchmaker
@@ -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,282 @@
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 abort, 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
+ SUPPORTED_BANKS = ("Bank of America", "Chase", "Discover")
14
+
15
+
16
+ def parse_transaction_activity_file(transaction_file):
17
+ """
18
+ Parse a CSV file containing reported transaction activity.
19
+
20
+ Parameters
21
+ ----------
22
+ activity_file : werkzeug.datastructures.FileStorage, pathlib.Path
23
+ The file object containing transaction activity data or a path
24
+ to a file containing transaction activity data.
25
+
26
+ Returns
27
+ -------
28
+ activities : TransactionActivities
29
+ The list-like object containing credit transaction activity
30
+ data.
31
+ """
32
+ try:
33
+ return _TransactionActivityParser(transaction_file).data
34
+ except ActivityLoadingError:
35
+ return None
36
+
37
+
38
+ class _ColumnIdentifier(ABC):
39
+ """
40
+ An object to aid in identifying the column matching a certain type.
41
+
42
+ Parameters
43
+ ----------
44
+ raw_header : list
45
+ The header row as a list of column titles.
46
+ """
47
+
48
+ def __init__(self, raw_header):
49
+ self._raw_header = raw_header
50
+ self._potential_column_indices = []
51
+
52
+ def determine_index(self):
53
+ """
54
+ Given the data header, determine the current column type index.
55
+
56
+ Returns
57
+ -------
58
+ index : int, None
59
+ The index of the column corresponding to the current column
60
+ type.
61
+ """
62
+ for column_index, column_title in enumerate(self._raw_header):
63
+ standardized_title = column_title.strip().casefold()
64
+ column_match = self.check(standardized_title)
65
+ if column_match is True:
66
+ return column_index
67
+ elif column_match is None:
68
+ self._potential_column_indices.append(column_index)
69
+ return self._infer_column_index()
70
+
71
+ @abstractmethod
72
+ def check(self, standardized_title):
73
+ raise NotImplementedError(
74
+ "Define how a column identification check is performed in a subclass. "
75
+ "A check should return `True` if the title does describe the class's "
76
+ "column type, `False` if the title does *not* describe the class's column "
77
+ "type, and `None` if the title may describe the column type but more "
78
+ "information is required."
79
+ )
80
+
81
+ def _infer_column_index(self):
82
+ # Attempt to infer the current column type index from a potential match
83
+ if len(self._potential_column_indices) == 1:
84
+ return self._potential_column_indices[0]
85
+ return None
86
+
87
+
88
+ class _TransactionDateColumnIdentifier(_ColumnIdentifier):
89
+ def check(self, standardized_title):
90
+ """Check if the title indicates this column contains a transaction date."""
91
+ if standardized_title == "transaction date":
92
+ return True
93
+ elif "date" in standardized_title.split():
94
+ if "trans." in standardized_title:
95
+ return True
96
+ elif standardized_title == "date" or standardized_title == "posted date":
97
+ return None
98
+ return False
99
+
100
+
101
+ class _TransactionTotalColumnIdentifier(_ColumnIdentifier):
102
+ def check(self, standardized_title):
103
+ """Check if the title indicates this column contains an amount."""
104
+ return standardized_title in ("amount", "total")
105
+
106
+
107
+ class _TransactionDescriptionColumnIdentifier(_ColumnIdentifier):
108
+ def check(self, standardized_title):
109
+ """Check if the title indicates this column contains a description (payee)."""
110
+ return standardized_title in ("description", "desc.", "payee")
111
+
112
+
113
+ class _TransactionCategoryColumnIdentifier(_ColumnIdentifier):
114
+ def check(self, standardized_title):
115
+ """Check if the title indicates this column contains a category."""
116
+ return standardized_title == "category"
117
+
118
+
119
+ class _TransactionTypeColumnIdentifier(_ColumnIdentifier):
120
+ def check(self, standardized_title):
121
+ """Check if the title indicates this column contains a transaction type."""
122
+ return standardized_title == "type"
123
+
124
+
125
+ class _TransactionActivityParser:
126
+ """
127
+ A parser for arbitrary CSV files containing transaction activity.
128
+
129
+ This object contains logic and utilities for parsing aribtrary CSV
130
+ files containing transaction activity data. It is generalized to
131
+ work with various CSV labeling schemes, inferring information about
132
+ the CSV data from the contents of the file.
133
+
134
+ Parameters
135
+ ----------
136
+ activity_file : werkzeug.datastructures.FileStorage, pathlib.Path
137
+ The file object containing transaction activity data or a path
138
+ to a file containing transaction activity data.
139
+ activity_dir : pathlib.Path
140
+ The path to the directory where activity files to be parsed will
141
+ be stored after uploading.
142
+ """
143
+
144
+ _column_identifiers = {
145
+ "transaction_date": _TransactionDateColumnIdentifier,
146
+ "total": _TransactionTotalColumnIdentifier,
147
+ "description": _TransactionDescriptionColumnIdentifier,
148
+ "category": _TransactionCategoryColumnIdentifier,
149
+ "type": _TransactionTypeColumnIdentifier,
150
+ }
151
+ _raw_column_types = list(_column_identifiers.keys())
152
+ column_types = _raw_column_types[:3]
153
+
154
+ def __init__(self, activity_file, activity_dir=None):
155
+ file_loader = TransactionActivityLoader(activity_dir=activity_dir)
156
+ if isinstance(activity_file, Path):
157
+ activity_filepath = activity_file
158
+ else:
159
+ activity_filepath = file_loader.upload(activity_file)
160
+ # Load data from the activity file
161
+ raw_header, raw_data = self._load_data(activity_filepath)
162
+ if not raw_data:
163
+ raise ActivityLoadingError("The activity file contains no actionable data.")
164
+ # Parse the loaded activity data
165
+ self._raw_column_indices = self._determine_column_indices(raw_header)
166
+ self._negative_charges = self._determine_expenditure_sign(raw_data)
167
+ self.column_indices = {name: i for i, name in enumerate(self.column_types)}
168
+ self.data = TransactionActivities(self._process_data(row) for row in raw_data)
169
+ # Remove the loaded activity file
170
+ file_loader.cleanup()
171
+
172
+ @staticmethod
173
+ def _load_data(transaction_filepath):
174
+ # Load raw header information and data from the transaction file
175
+ with transaction_filepath.open() as csv_file:
176
+ csv_reader = csv.reader(csv_file)
177
+ raw_header = next(csv_reader)
178
+ raw_data = list(csv_reader)
179
+ return raw_header, raw_data
180
+
181
+ def _determine_column_indices(self, raw_header):
182
+ # Determine the indices of various columns in the raw header/data
183
+ raw_column_indices = {
184
+ column_type: identifier(raw_header).determine_index()
185
+ for column_type, identifier in self._column_identifiers.items()
186
+ }
187
+ for column_type in self.column_types:
188
+ if raw_column_indices[column_type] is None:
189
+ current_app.logger.debug(
190
+ f"The '{column_type}' column could not be identified in the data. "
191
+ )
192
+ msg = (
193
+ "The data was unable to be parsed, most likely because it did not "
194
+ "match a recognized format. Supported data formats include those "
195
+ f"from the following banks: {', '.join(SUPPORTED_BANKS)}."
196
+ )
197
+ abort(400, msg)
198
+ return raw_column_indices
199
+
200
+ def _determine_expenditure_sign(self, raw_data):
201
+ # Determine the sign of expenditures
202
+ # - Charges may be reported as either positive or negative amounts
203
+ # - Negatively valued charges (positively valued payments) return `True`;
204
+ # positively valued charges (negatively valued payments) return `False`
205
+ # Note: This method assumes that an activity file will not report a standard
206
+ # transaction as a "payment"
207
+ contextual_column_types = ("category", "description", "type")
208
+ contextual_column_indices = [
209
+ index
210
+ for column_type, index in self._raw_column_indices.items()
211
+ if column_type in contextual_column_types and index is not None
212
+ ]
213
+
214
+ def _infer_payment_row(row):
215
+ # Infer whether the row constitutes a payment transaction
216
+ contextual_info = [row[i].lower() for i in contextual_column_indices]
217
+ return any("payment" in element for element in contextual_info)
218
+
219
+ payment_rows = list(filter(_infer_payment_row, raw_data))
220
+ return self._extrapolate_payments_positive(payment_rows, raw_data)
221
+
222
+ def _extrapolate_payments_positive(self, payment_rows, raw_data):
223
+ if payment_rows:
224
+ payments_positive = self._evaluate_payment_signs(payment_rows)
225
+ else:
226
+ payments_positive = self._evaluate_nonpayment_signs(raw_data)
227
+ if payments_positive is None:
228
+ raise RuntimeError(
229
+ "The sign of credits/debits could not be determined from the data."
230
+ )
231
+ return payments_positive
232
+
233
+ def _evaluate_payment_signs(self, payment_rows):
234
+ # Iterate over each payment row and collect sign information
235
+ amount_index = self._raw_column_indices["total"]
236
+ payment_signs_positive = [float(row[amount_index]) > 0 for row in payment_rows]
237
+ # Evaluate whether payment amounts are treated as positively valued
238
+ if all(payment_signs_positive):
239
+ payments_positive = True
240
+ elif not any(payment_signs_positive):
241
+ payments_positive = False
242
+ else:
243
+ payments_positive = None
244
+ return payments_positive
245
+
246
+ def _evaluate_nonpayment_signs(self, nonpayment_rows):
247
+ # Iterate over each non-payment and collect sign information
248
+ # - Assume the majority of transactions are charges if no payments are found
249
+ amount_index = self._raw_column_indices["total"]
250
+ negative_charge_count = sum(
251
+ float(row[amount_index]) < 0 for row in nonpayment_rows
252
+ )
253
+ # Evaluate whether assumed non-payment amounts seem positively valued
254
+ negative_charge_frac = negative_charge_count / len(nonpayment_rows)
255
+ if negative_charge_frac == 0.5:
256
+ payments_positive = None
257
+ else:
258
+ # Payments are assumed to be positive if most of the transactions are
259
+ # charges and most of the charges are negative
260
+ payments_positive = negative_charge_frac > 0.5
261
+ return payments_positive
262
+
263
+ def _process_data(self, row):
264
+ # Process the raw data into an output format
265
+ processed_row = []
266
+ for column_type in self.column_types:
267
+ value = row[self._raw_column_indices[column_type]]
268
+ if column_type == "transaction_date":
269
+ value = self._process_date_data(value)
270
+ elif column_type == "total":
271
+ value = self._process_amount_data(value)
272
+ processed_row.append(value)
273
+ return processed_row
274
+
275
+ def _process_amount_data(self, value):
276
+ # Charges should be positively valued, payments negatively valued
277
+ value = float(value)
278
+ return -value if self._negative_charges else value
279
+
280
+ def _process_date_data(self, value):
281
+ # Output dates as `datetime.date` objects
282
+ return parse_date(value)