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.
- monopyly/CHANGELOG.md +7 -0
- monopyly/__init__.py +2 -2
- monopyly/_version.py +2 -2
- monopyly/cli/apps.py +1 -1
- monopyly/cli/launch.py +3 -0
- monopyly/common/forms/_forms.py +56 -2
- monopyly/common/transactions.py +162 -0
- monopyly/credit/actions.py +29 -0
- monopyly/credit/forms.py +25 -0
- monopyly/credit/routes.py +97 -7
- monopyly/credit/transactions/_transactions.py +15 -0
- monopyly/credit/transactions/activity/__init__.py +3 -0
- monopyly/credit/transactions/activity/data.py +161 -0
- monopyly/credit/transactions/activity/parser.py +274 -0
- monopyly/credit/transactions/activity/reconciliation.py +456 -0
- monopyly/database/models.py +6 -0
- monopyly/static/css/style.css +1141 -263
- monopyly/static/img/icons/statement-pair.png +0 -0
- monopyly/static/img/icons/statement-thick.png +0 -0
- monopyly/static/img/icons/statement.png +0 -0
- monopyly/static/js/bind-tag-actions.js +1 -1
- monopyly/static/js/create-balance-chart.js +1 -1
- monopyly/static/js/create-category-chart.js +27 -0
- monopyly/static/js/define-filter.js +1 -1
- monopyly/static/js/expand-transaction.js +10 -0
- monopyly/static/js/highlight-discrepant-transactions.js +124 -0
- monopyly/static/js/modules/expand-transaction.js +12 -3
- monopyly/static/js/modules/form-suggestions.js +60 -0
- monopyly/static/js/modules/manage-overlays.js +1 -3
- monopyly/static/js/show-credit-activity-loader.js +29 -0
- monopyly/static/js/toggle-navigation.js +35 -0
- monopyly/static/js/update-card-status.js +1 -1
- monopyly/static/js/use-suggested-amount.js +11 -0
- monopyly/static/js/use-suggested-merchant.js +11 -0
- monopyly/templates/banking/account_page.html +3 -1
- monopyly/templates/banking/account_summaries.html +1 -1
- monopyly/templates/banking/accounts_page.html +11 -15
- monopyly/templates/banking/transactions_table/expanded_row_content.html +18 -20
- monopyly/templates/common/transaction_form/subtransaction_subform.html +10 -1
- monopyly/templates/common/transactions_table/linked_bank_transaction.html +1 -1
- monopyly/templates/common/transactions_table/linked_credit_transaction.html +1 -1
- monopyly/templates/common/transactions_table/transaction_condensed.html +2 -2
- monopyly/templates/common/transactions_table/transaction_expanded.html +3 -3
- monopyly/templates/common/transactions_table/transactions.html +1 -1
- monopyly/templates/core/credits.html +10 -8
- monopyly/templates/core/index.html +10 -0
- monopyly/templates/core/profile.html +1 -1
- monopyly/templates/credit/statement_page.html +33 -0
- monopyly/templates/credit/statement_reconciliation/discrepant_records.html +25 -0
- monopyly/templates/credit/statement_reconciliation/statement_reconciliation_inquiry.html +23 -0
- monopyly/templates/credit/statement_reconciliation/statement_reconciliation_page.html +86 -0
- monopyly/templates/credit/statement_reconciliation/summary.html +45 -0
- monopyly/templates/credit/statement_reconciliation/unrecorded_activities.html +24 -0
- monopyly/templates/credit/statement_summary.html +2 -2
- monopyly/templates/credit/statements.html +1 -1
- monopyly/templates/credit/transaction_form/transaction_form.html +9 -1
- monopyly/templates/credit/transaction_form/transaction_form_page.html +2 -0
- monopyly/templates/credit/transaction_form/transaction_form_page_update.html +9 -0
- monopyly/templates/credit/transaction_submission_page.html +8 -0
- monopyly/templates/credit/transactions_table/expanded_row_content.html +18 -12
- monopyly/templates/layout.html +35 -27
- {monopyly-1.4.8.dist-info → monopyly-1.5.0.dist-info}/METADATA +2 -1
- {monopyly-1.4.8.dist-info → monopyly-1.5.0.dist-info}/RECORD +67 -51
- monopyly/static/img/icons/statement-pair.svg +0 -281
- monopyly/static/img/icons/statement.svg +0 -294
- {monopyly-1.4.8.dist-info → monopyly-1.5.0.dist-info}/WHEEL +0 -0
- {monopyly-1.4.8.dist-info → monopyly-1.5.0.dist-info}/entry_points.txt +0 -0
- {monopyly-1.4.8.dist-info → monopyly-1.5.0.dist-info}/licenses/COPYING +0 -0
- {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
|
+
)
|
monopyly/database/models.py
CHANGED
|
@@ -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))
|