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.
Files changed (75) hide show
  1. monopyly/CHANGELOG.md +15 -0
  2. monopyly/README.md +1 -1
  3. monopyly/__init__.py +3 -3
  4. monopyly/_version.py +2 -2
  5. monopyly/auth/actions.py +7 -2
  6. monopyly/banking/actions.py +51 -10
  7. monopyly/banking/routes.py +2 -1
  8. monopyly/cli/apps.py +26 -24
  9. monopyly/cli/{run.py → launch.py} +19 -8
  10. monopyly/common/forms/_forms.py +56 -2
  11. monopyly/common/transactions.py +162 -0
  12. monopyly/credit/actions.py +29 -0
  13. monopyly/credit/forms.py +25 -0
  14. monopyly/credit/routes.py +97 -7
  15. monopyly/credit/transactions/_transactions.py +15 -0
  16. monopyly/credit/transactions/activity/__init__.py +3 -0
  17. monopyly/credit/transactions/activity/data.py +161 -0
  18. monopyly/credit/transactions/activity/parser.py +274 -0
  19. monopyly/credit/transactions/activity/reconciliation.py +456 -0
  20. monopyly/database/models.py +6 -0
  21. monopyly/static/css/style.css +1146 -263
  22. monopyly/static/img/icons/statement-pair.png +0 -0
  23. monopyly/static/img/icons/statement-thick.png +0 -0
  24. monopyly/static/img/icons/statement.png +0 -0
  25. monopyly/static/js/bind-tag-actions.js +1 -1
  26. monopyly/static/js/create-balance-chart.js +1 -1
  27. monopyly/static/js/create-category-chart.js +27 -0
  28. monopyly/static/js/define-filter.js +1 -1
  29. monopyly/static/js/expand-transaction.js +10 -0
  30. monopyly/static/js/highlight-discrepant-transactions.js +124 -0
  31. monopyly/static/js/modules/expand-transaction.js +12 -3
  32. monopyly/static/js/modules/form-suggestions.js +60 -0
  33. monopyly/static/js/modules/manage-overlays.js +1 -3
  34. monopyly/static/js/show-credit-activity-loader.js +29 -0
  35. monopyly/static/js/toggle-navigation.js +35 -0
  36. monopyly/static/js/update-card-status.js +1 -1
  37. monopyly/static/js/use-suggested-amount.js +11 -0
  38. monopyly/static/js/use-suggested-merchant.js +11 -0
  39. monopyly/templates/banking/account_page.html +3 -1
  40. monopyly/templates/banking/account_summaries.html +1 -1
  41. monopyly/templates/banking/accounts_page.html +11 -15
  42. monopyly/templates/banking/transactions_table/expanded_row_content.html +18 -20
  43. monopyly/templates/common/transaction_form/subtransaction_subform.html +10 -1
  44. monopyly/templates/common/transactions_table/linked_bank_transaction.html +1 -1
  45. monopyly/templates/common/transactions_table/linked_credit_transaction.html +1 -1
  46. monopyly/templates/common/transactions_table/transaction_condensed.html +2 -2
  47. monopyly/templates/common/transactions_table/transaction_expanded.html +3 -3
  48. monopyly/templates/common/transactions_table/transactions.html +1 -1
  49. monopyly/templates/core/credits.html +10 -8
  50. monopyly/templates/core/index.html +10 -0
  51. monopyly/templates/core/profile.html +1 -1
  52. monopyly/templates/core/story.html +42 -27
  53. monopyly/templates/credit/statement_page.html +33 -0
  54. monopyly/templates/credit/statement_reconciliation/discrepant_records.html +25 -0
  55. monopyly/templates/credit/statement_reconciliation/statement_reconciliation_inquiry.html +23 -0
  56. monopyly/templates/credit/statement_reconciliation/statement_reconciliation_page.html +86 -0
  57. monopyly/templates/credit/statement_reconciliation/summary.html +45 -0
  58. monopyly/templates/credit/statement_reconciliation/unrecorded_activities.html +24 -0
  59. monopyly/templates/credit/statement_summary.html +2 -2
  60. monopyly/templates/credit/statements.html +1 -1
  61. monopyly/templates/credit/transaction_form/transaction_form.html +9 -1
  62. monopyly/templates/credit/transaction_form/transaction_form_page.html +2 -0
  63. monopyly/templates/credit/transaction_form/transaction_form_page_update.html +9 -0
  64. monopyly/templates/credit/transaction_submission_page.html +8 -0
  65. monopyly/templates/credit/transactions_table/expanded_row_content.html +18 -12
  66. monopyly/templates/layout.html +46 -29
  67. {monopyly-1.4.7.dist-info → monopyly-1.5.0.dist-info}/METADATA +3 -2
  68. {monopyly-1.4.7.dist-info → monopyly-1.5.0.dist-info}/RECORD +72 -56
  69. monopyly-1.5.0.dist-info/entry_points.txt +2 -0
  70. monopyly/static/img/icons/statement-pair.svg +0 -281
  71. monopyly/static/img/icons/statement.svg +0 -294
  72. monopyly-1.4.7.dist-info/entry_points.txt +0 -2
  73. {monopyly-1.4.7.dist-info → monopyly-1.5.0.dist-info}/WHEEL +0 -0
  74. {monopyly-1.4.7.dist-info → monopyly-1.5.0.dist-info}/licenses/COPYING +0 -0
  75. {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 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,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
- statement = CreditStatementHandler.get_entry(statement_id)
338
- form = form.prepopulate(statement)
421
+ entry = CreditStatementHandler.get_entry(statement_id)
339
422
  elif card_id:
340
- card = CreditCardHandler.get_entry(card_id)
341
- form = form.prepopulate(card)
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(transaction)
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,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,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)