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,456 @@
1
+ """
2
+ Tools for reconciling credit transactions with associated activity data.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections import UserDict
7
+ from datetime import timedelta
8
+ from itertools import chain, combinations
9
+
10
+ from nltk import wordpunct_tokenize
11
+ from nltk.metrics.distance import jaccard_distance
12
+
13
+ from .data import TransactionActivityGroup
14
+
15
+
16
+ class MatchFinder(ABC):
17
+ """An abstract base class for finding transaction-activity matches."""
18
+
19
+ @classmethod
20
+ def find(cls, transaction, data):
21
+ """
22
+ Find potential matches for the transaction in the data.
23
+
24
+ Parameters
25
+ ----------
26
+ transaction : CreditTransactionView
27
+ The transaction to use when finding potential matches.
28
+ data : TransactionActivities
29
+ The data to search for potential matches.
30
+
31
+ Returns
32
+ -------
33
+ matches : list
34
+ The full set of activities that may match this finder's
35
+ transaction.
36
+ """
37
+ matches = [row for row in data if cls.is_match(transaction, row)]
38
+ return matches
39
+
40
+ @abstractmethod
41
+ def is_match(cls, transaction, activity):
42
+ raise NotImplementedError("Define what constitutes a match in a subclass.")
43
+
44
+
45
+ class ExactMatchFinder(MatchFinder):
46
+ """
47
+ An object for finding "exact" transaction-activity matches.
48
+
49
+ Notes
50
+ -----
51
+ An "exact" match is a transaction and activity that share the same
52
+ transaction date and same transaction total/amount.
53
+ """
54
+
55
+ @classmethod
56
+ def is_match(cls, transaction, activity):
57
+ """Evaluate whether the activity is an "exact" match."""
58
+ same_date = transaction.transaction_date == activity.transaction_date
59
+ same_amount = transaction.total == activity.total
60
+ return same_date and same_amount
61
+
62
+
63
+ class NearMatchFinder(MatchFinder):
64
+ """
65
+ An object for finding "near" transaction-activity matches.
66
+
67
+ Notes
68
+ -----
69
+ A "near" match is a transaction and activity that are within some
70
+ acceptable range (e.g., the activity date is within a similar time
71
+ frame as the transaction date, and the activity transaction amount
72
+ is comparable to the recorded transaction total). Specifically,
73
+ transactions are considered a near match if they are within 1 day
74
+ of each other and the transaction total is no more than $3.00 or 10%
75
+ larger (whichever is greater) than the reported activity amount, or
76
+ they have the same transaction total but occur within two days of
77
+ each other.
78
+ """
79
+
80
+ @classmethod
81
+ def is_match(cls, transaction, activity):
82
+ """Evaluate whether the activity is a "near" match."""
83
+ near_date = cls._is_near_date(transaction, activity)
84
+ near_amount = cls._is_near_amount(transaction, activity)
85
+ less_near_date = cls._is_near_date(transaction, activity, proximity_days=2)
86
+ exact_amount = transaction.total == activity.total
87
+ return (near_date and near_amount) or (less_near_date and exact_amount)
88
+
89
+ @classmethod
90
+ def _is_near_date(cls, transaction, activity, proximity_days=1):
91
+ low_date = activity.transaction_date - timedelta(days=proximity_days)
92
+ high_date = activity.transaction_date + timedelta(days=proximity_days)
93
+ return low_date <= transaction.transaction_date <= high_date
94
+
95
+ @classmethod
96
+ def _is_near_amount(cls, transaction, activity):
97
+ # Ensure that the low amount is fixed at zero for small magnitudes
98
+ sign = 1 if activity.total >= 0 else -1
99
+ total_magnitude = abs(activity.total)
100
+ low_amount = sign * min(max(0, total_magnitude - 3), total_magnitude * 0.9)
101
+ high_amount = sign * max(total_magnitude + 3, total_magnitude * 1.1)
102
+ return low_amount <= transaction.total <= high_amount
103
+
104
+
105
+ class _Matchmaker(ABC):
106
+ """An abstract base class for defining matchmaker objects."""
107
+
108
+ class _BestMatches(UserDict):
109
+ """A dictionary with custom methods for querying membership."""
110
+
111
+ def includes_transaction(self, transaction):
112
+ return transaction in self.keys()
113
+
114
+ def includes_activity(self, activity):
115
+ for value in self.values():
116
+ if activity == value:
117
+ return True
118
+ elif isinstance(value, TransactionActivityGroup) and activity in value:
119
+ return True
120
+ return False
121
+
122
+ def pair(self, transaction, activity):
123
+ """Assign the transaction-activity pair to be a "best match"."""
124
+ self[transaction] = activity
125
+
126
+ def __init__(self, transactions, activities, best_matches=None):
127
+ self._transactions = transactions
128
+ self._activities = activities
129
+ self.best_matches = self._BestMatches(best_matches if best_matches else {})
130
+
131
+ @property
132
+ def unmatched_transactions(self):
133
+ return list(filter(self._is_unmatched_transaction, self._transactions))
134
+
135
+ @property
136
+ def unmatched_activities(self):
137
+ return list(filter(self._is_unmatched_activity, self._activities))
138
+
139
+ @property
140
+ def match_discrepancies(self):
141
+ def _match_has_discrepancy(item):
142
+ transaction, activity = item
143
+ return transaction.total != activity.total
144
+
145
+ return dict(filter(_match_has_discrepancy, self.best_matches.items()))
146
+
147
+ def _is_unmatched_transaction(self, transaction):
148
+ """Check whether the given transaction is not yet matched."""
149
+ return not self.best_matches.includes_transaction(transaction)
150
+
151
+ def _is_unmatched_activity(self, activity):
152
+ """Check whether the given activity is not yet matched."""
153
+ return not self.best_matches.includes_activity(activity)
154
+
155
+ def _get_potential_matches(self, matches):
156
+ """Get the set of potential valid matches from the set of matches."""
157
+ # Potential matches are only those where the transaction is unmatched
158
+ for transaction in filter(self._is_unmatched_transaction, matches):
159
+ activities = matches[transaction]
160
+ # Potential matches are only those where activities are unmatched
161
+ potential_activities = sorted(
162
+ set(activities) - set(self.best_matches.values())
163
+ )
164
+ if potential_activities:
165
+ yield transaction, potential_activities
166
+
167
+ def _assign_unambiguous_best_matches(self, matches):
168
+ """Find unambiguous matches and assign them as the "best" matches."""
169
+ for transaction, activities in self._get_potential_matches(matches):
170
+ # Check that this transaction only matches one activity
171
+ if len(activities) == 1:
172
+ activity = activities[0]
173
+ if self._is_unambiguous_match(transaction, activity, matches):
174
+ self.best_matches.pair(transaction, activity)
175
+
176
+ def _is_unambiguous_match(self, transaction, activity, matches):
177
+ """Check whether the transaction and activity match unambiguously."""
178
+ other_transactions_activities = filter(
179
+ lambda item: item[0] is not transaction,
180
+ self._get_potential_matches(matches),
181
+ )
182
+ for _, other_activities in other_transactions_activities:
183
+ if activity in other_activities:
184
+ return False
185
+ return True
186
+
187
+ def _disambiguate_best_matches(self, matches):
188
+ """Disambiguate the best matches from sets of potential matches."""
189
+ ambiguous_matches = self._get_potential_matches(matches)
190
+ for transaction, activities in ambiguous_matches:
191
+ activity = self._choose_best_ambiguous_match(transaction, activities)
192
+ self.best_matches.pair(transaction, activity)
193
+
194
+ def _choose_best_ambiguous_match(self, transaction, activities):
195
+ """Determine a transaction-activity match from an ambiuguous set."""
196
+ # Score each activity based on its similarity to the transaction
197
+ merchant_tokens = self.tokenize(transaction.merchant)
198
+ notes_tokens = self.tokenize(transaction.notes)
199
+ score_records = []
200
+ for activity in activities:
201
+ activity_tokens = self.tokenize(activity.description)
202
+ score = self._compute_transaction_activity_similarity_score(
203
+ merchant_tokens, notes_tokens, activity_tokens
204
+ )
205
+ score_records.append((score, activity))
206
+ # The activity with the lowest score is chosen as the best match
207
+ best_match = min(score_records)[1]
208
+ return best_match
209
+
210
+ @staticmethod
211
+ def tokenize(field):
212
+ """
213
+ Convert text in a field into tokens.
214
+
215
+ Given a string of text, convert the text into tokens via the
216
+ NLTK regex tokenizer. Before tokenization, standardize inputs
217
+ by removing all apostrophes (which are uncommon in credit
218
+ activity listings) and by computing the casefold of the input.
219
+
220
+ Parameters
221
+ ----------
222
+ field : str
223
+ The string of text to be tokenized.
224
+ """
225
+ return set(wordpunct_tokenize(field.replace("'", "").casefold()))
226
+
227
+ def _compute_transaction_activity_similarity_score(
228
+ self, merchant_tokens, notes_tokens, activity_tokens
229
+ ):
230
+ """
231
+ Use tokens for the transaction and activity to compute a score.
232
+
233
+ Evaluate the similarity of a transaction and activity by
234
+ scoring the distances between tokenized representations of the
235
+ fields. The primary scoring metric is the similarity between the
236
+ transaction merchant and activity description, but ties are
237
+ broken by a secondary metric comparing the similarity between
238
+ the transaction notes and activity description.
239
+ """
240
+ merchant_score = self.score_tokens(merchant_tokens, activity_tokens)
241
+ notes_score = self.score_tokens(notes_tokens, activity_tokens)
242
+ return merchant_score, notes_score
243
+
244
+ @staticmethod
245
+ def score_tokens(reference, test):
246
+ """Use the Jaccard distance to measure the similarity of token sets."""
247
+ return jaccard_distance(reference, test)
248
+
249
+
250
+ class ExactMatchmaker(_Matchmaker):
251
+ """
252
+ An object to find exact matches between transactions and activity data.
253
+
254
+ Given a set of database credit transactions and a dataset of
255
+ recorded activity data, this object traverses the two sets of
256
+ information and determines which transactions appear to exactly
257
+ match which activities.
258
+
259
+ The object's search takes an iterative approach. It first finds all
260
+ "exact" matches (those with the same data and same total/amount).
261
+ Then, if there are ambiguities in this information, the procedure
262
+ attempts to compare merchant and note information from the
263
+ transaction with the description of the activity to make a
264
+ determination.
265
+
266
+ Parameters
267
+ ----------
268
+ transactions : list
269
+ A list of transactions to be matched with the activities.
270
+ activities : TransactionActivities
271
+ A list-like collection of activity data to be matched with
272
+ transactions.
273
+ best_matches : dict
274
+ A mapping between any transactions and the activity believed to
275
+ represent the best match in the data.
276
+
277
+ Attributes
278
+ ----------
279
+ best_matches : dict
280
+ A mapping between transactions and their best match (as
281
+ determined by the matcher's algorithm).
282
+ unmatched_transactions : list
283
+ An list of transactions where no matching activity could be
284
+ identified.
285
+ unmatched_activities : list
286
+ An list of activities where no matching transaction could be
287
+ identified.
288
+ """
289
+
290
+ _match_finder = ExactMatchFinder
291
+
292
+ def __init__(self, transactions, activities, best_matches=None):
293
+ super().__init__(transactions, activities, best_matches=best_matches)
294
+ matches = {
295
+ transaction: self._match_finder.find(transaction, activities)
296
+ for transaction in transactions
297
+ }
298
+ self._assign_unambiguous_best_matches(matches)
299
+ self._disambiguate_best_matches(matches)
300
+
301
+
302
+ class NearMatchmaker(_Matchmaker):
303
+ """
304
+ An object to find near matches between transactions and activity data.
305
+
306
+ Given a set of database credit transactions and a dataset of
307
+ recorded activity data, this object traverses the two sets of
308
+ information and determines which transactions appear to nearly
309
+ match which activities.
310
+
311
+ The object's search takes an iterative approach. It first finds all
312
+ "near" matches (those activities with transaction dates within a
313
+ short window around the target transaction and amounts comparable to
314
+ the target total). Then, if there are ambiguities in this
315
+ information, the procedure attempts to compare merchant and note
316
+ information from the transaction with the description of the
317
+ activity to make a determination.
318
+
319
+ Parameters
320
+ ----------
321
+ transactions : list
322
+ A list of transactions to be matched with the activities.
323
+ activities : TransactionActivities
324
+ A list-like collection of activity data to be matched with
325
+ transactions.
326
+ best_matches : dict
327
+ A mapping between any transactions and the activity believed to
328
+ represent the best match in the data.
329
+
330
+ Attributes
331
+ ----------
332
+ best_matches : dict
333
+ A mapping between transactions and their best match (as
334
+ determined by the matcher's algorithm).
335
+ unmatched_transactions : list
336
+ An list of transactions where no matching activity could be
337
+ identified.
338
+ unmatched_activities : list
339
+ An list of activities where no matching transaction could be
340
+ identified.
341
+ """
342
+
343
+ _match_finder = NearMatchFinder
344
+
345
+ def __init__(self, transactions, activities, best_matches=None):
346
+ super().__init__(transactions, activities, best_matches=best_matches)
347
+ matches = {
348
+ transaction: self._match_finder.find(transaction, activities)
349
+ for transaction in transactions
350
+ }
351
+ self._assign_unambiguous_best_matches(matches)
352
+ self._disambiguate_best_matches(matches)
353
+
354
+
355
+ class ActivityMatchmaker(_Matchmaker):
356
+ """
357
+ An object to find matches between transactions and activity data.
358
+
359
+ Given a set of database credit transactions and a dataset of
360
+ recorded activity data, this object traverses the two sets of
361
+ information and determines which transactions match which
362
+ activities.
363
+
364
+ The object's search takes an iterative approach. First, it finds all
365
+ "exact" matches (those with the same data and same total/amount). If
366
+ there are ambiguities in this information, the procedure attempts to
367
+ compare merchant and note information from the transaction with
368
+ the description of the activity to make a determination.
369
+
370
+ If that first attempt fails to match transactions exactly, a second
371
+ pass of the data occurs to find "near" matches (those activities
372
+ with transaction dates within a short window around the target
373
+ transaction and amounts comparable to the target total). Again,
374
+ the procedure attempts to resolve ambiguities by falling back to
375
+ the contextual textual data.
376
+
377
+ Parameters
378
+ ----------
379
+ transactions : list
380
+ A list of transactions to be matched with the activities.
381
+ activities : TransactionActivities
382
+ A list-like collection of activity data to be matched with
383
+ transactions.
384
+ best_matches : dict
385
+ A mapping between any transactions and the activity believed to
386
+ represent the best match in the data.
387
+
388
+ Attributes
389
+ ----------
390
+ best_matches : dict
391
+ A mapping between transactions and their best match (as
392
+ determined by the matcher's algorithm).
393
+ match_discrepancies : dict
394
+ A mapping between only transactions and their best match
395
+ activity that are not considered "exact" matches (e.g., they do
396
+ not share the same transaction date and same amount).
397
+ This dictionary will be subset of the `best_matches` dictionary.
398
+ unmatched_transactions : list
399
+ A list of transactions where no matching activity could be
400
+ identified.
401
+ unmatched_activities : list
402
+ A list of activities where no matching transaction could be
403
+ identified.
404
+ """
405
+
406
+ def __init__(self, transactions, activities, best_matches=None):
407
+ # Collect all "exact" matches (same date and amount), then near matches
408
+ for matchmaker_cls in (ExactMatchmaker, NearMatchmaker):
409
+ matchmaker = matchmaker_cls(transactions, activities, best_matches)
410
+ best_matches = matchmaker.best_matches
411
+ super().__init__(transactions, activities, best_matches=best_matches)
412
+ # Find further matches between transactions and groups of activity information
413
+ for transaction in self.unmatched_transactions:
414
+ self._match_activity_groups(transaction, activities)
415
+
416
+ def _match_activity_groups(self, transaction, activities):
417
+ """Match transaction to groups of activity with the same total and merchant."""
418
+ potential_activity_groups = self._gather_potential_activity_groups(
419
+ transaction.transaction_date
420
+ )
421
+ for group in potential_activity_groups:
422
+ for group_subset in self._get_group_subsets(group):
423
+ if (
424
+ sum(activity.total for activity in group_subset)
425
+ == transaction.total
426
+ ):
427
+ self.best_matches.pair(
428
+ transaction, TransactionActivityGroup(group_subset)
429
+ )
430
+ break
431
+
432
+ def _gather_potential_activity_groups(self, transaction_date):
433
+ # Group activities on the transaction date by shared descriptions
434
+ date_activities = self._group_activities_for_date(transaction_date)
435
+ potential_activity_groups = {}
436
+ for activity in date_activities:
437
+ if activity.description in potential_activity_groups:
438
+ potential_activity_groups[activity.description].append(activity)
439
+ else:
440
+ potential_activity_groups[activity.description] = [activity]
441
+ # Only return the groups with multiple potential activities
442
+ return filter(lambda group: len(group) != 1, potential_activity_groups.values())
443
+
444
+ def _group_activities_for_date(self, transaction_date):
445
+ # Provide a list of activities that share a transaction date
446
+ return [
447
+ activity
448
+ for activity in self.unmatched_activities
449
+ if activity.transaction_date == transaction_date
450
+ ]
451
+
452
+ def _get_group_subsets(self, group):
453
+ # Return combinations of activities that represent a subset of the full group
454
+ return chain.from_iterable(
455
+ combinations(group, r=r) for r in range(len(group), 0, -1)
456
+ )
@@ -475,3 +475,9 @@ class CreditSubtransaction(AuthorizedAccessMixin, Model):
475
475
  back_populates="credit_subtransactions",
476
476
  lazy="selectin",
477
477
  )
478
+
479
+ @property
480
+ def categorizable(self):
481
+ # Categorizable if no conflicting tags of the same depth exist
482
+ tag_depths = [tag.depth for tag in self.tags]
483
+ return len(tag_depths) == len(set(tag_depths))