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