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/CHANGELOG.md CHANGED
@@ -184,4 +184,19 @@
184
184
  - Refresh the table of transactions after making a payment on a credit card statement
185
185
  - Use SVG to handle long values in account/statement summary boxes; fixes bugs in page rendering (long value overflow) and hover actions not happening because of conflicting overlap with the sidebar
186
186
 
187
+
188
+ ### 1.4.8
189
+
190
+ - Set username collection to be case insensitive
191
+ - Use Flask/Gunicorn APIs (rather than subprocess CLI calls) to launch the app
192
+ - Fix bug in the ordering of balances in the bank account balance charts for transactions on duplicate dates
193
+
194
+
195
+ ## 1.5.0
196
+
197
+ - Add categorical pie charts to credit card statement details
198
+ - Provide mobile layouts for the application
199
+ - Create a tool for reconciling credit card transactions with information collected from external resources (e.g., CSVs downloaded from a user's online credit card account)
200
+
201
+
187
202
  <a name="bottom" id="bottom"></a>
monopyly/README.md CHANGED
@@ -22,7 +22,7 @@ To install the app, simply run
22
22
  $ pip install monopyly
23
23
  ```
24
24
 
25
- The package requires a recent version of Python (3.9+).
25
+ The package requires a recent version of Python (3.10+).
26
26
 
27
27
 
28
28
  ## Getting started
monopyly/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Run a development server for the Monopyly app.
2
+ Run the Monopyly app.
3
3
  """
4
4
 
5
5
  from flask import Flask
@@ -9,7 +9,7 @@ from monopyly.core.errors import render_error_template
9
9
  from monopyly.database import SQLAlchemy, register_db_cli_commands
10
10
 
11
11
 
12
- def create_app(test_config=None):
12
+ def create_app(test_config=None, debug=None):
13
13
  # Create and configure the app
14
14
  app = Flask(__name__, instance_relative_config=True)
15
15
 
@@ -18,7 +18,7 @@ def create_app(test_config=None):
18
18
  config = test_config
19
19
  else:
20
20
  # Load the development/production config when not testing
21
- if app.debug:
21
+ if app.debug or debug:
22
22
  config = DevelopmentConfig.configure_for_instance(app.instance_path)
23
23
  else:
24
24
  config = ProductionConfig.configure_for_instance(app.instance_path)
monopyly/_version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # file generated by setuptools_scm
2
2
  # don't change, don't track in version control
3
- __version__ = version = '1.4.7'
4
- __version_tuple__ = version_tuple = (1, 4, 7)
3
+ __version__ = version = '1.5.0'
4
+ __version_tuple__ = version_tuple = (1, 5, 0)
monopyly/auth/actions.py CHANGED
@@ -2,7 +2,12 @@
2
2
 
3
3
 
4
4
  def get_username_and_password(form):
5
- """Get username and password from a form."""
6
- username = form["username"]
5
+ """
6
+ Get username and password from a form.
7
+
8
+ Get the username and password from the given form. Username should
9
+ be case insensitive.
10
+ """
11
+ username = form["username"].lower()
7
12
  password = form["password"]
8
13
  return username, password
@@ -1,5 +1,7 @@
1
1
  """Module describing logical banking actions (to be used in routes)."""
2
2
 
3
+ from collections import UserList, namedtuple
4
+
3
5
  from ..common.utils import convert_date_to_midnight_timestamp
4
6
  from .accounts import BankAccountHandler, BankAccountTypeHandler
5
7
 
@@ -29,16 +31,55 @@ def get_balance_chart_data(transactions):
29
31
  Returns
30
32
  -------
31
33
  chart_data : list
32
- A list of sorted (x, y) pairs consisting of the Unix timestamp
33
- (in milliseconds) and the bank account balance.
34
+ A list containing (x, y) pairs, each consisting of the Unix
35
+ timestamp (in milliseconds) and the bank account balance.
36
+ """
37
+ return list(_BalanceChartData(transactions))
38
+
39
+
40
+ class _BalanceChartData(UserList):
34
41
  """
35
- chart_data = sorted(map(_make_transaction_balance_ordered_pair, transactions))
36
- return chart_data
42
+ A list of balances to be passed to a `chartist.js` chart constructor.
43
+
44
+ A special list-like object containing transaction data formatted for
45
+ use in a balance chart created by the `chartist.js` library. This
46
+ converts each transaction into an (x, y) pair consisting of a Unix
47
+ timestamp (in milleseconds) and a corresponding bank account
48
+ balance. For transactions occurring on the same day (the finest
49
+ granularity recorded by the Monopyly app), a slight offset is
50
+ added to each timestamp to guarantee a smooth representation in the
51
+ rendered chart.
52
+
53
+ Parameters
54
+ ----------
55
+ transactions : list
56
+ A list of transactions to be used for generating the chart data.
57
+ """
58
+
59
+ _DAILY_MILLISECONDS = 86_400_000
60
+ offset = 1
61
+ point = namedtuple("DataPoint", ["timestamp", "balance"])
62
+
63
+ def __init__(self, transactions):
64
+ super().__init__()
65
+ transaction_groups = self._group_transactions_by_date(transactions)
66
+ self._prepare_chart_data(transaction_groups)
37
67
 
68
+ @staticmethod
69
+ def _group_transactions_by_date(transactions):
70
+ date_groups = {}
71
+ for transaction in transactions:
72
+ group = date_groups.setdefault(transaction.transaction_date, [])
73
+ group.append(transaction)
74
+ return date_groups
38
75
 
39
- def _make_transaction_balance_ordered_pair(transaction):
40
- # Create an ordered pair of date (timestamp) and account balance
41
- timestamp = convert_date_to_midnight_timestamp(
42
- transaction.transaction_date, milliseconds=True
43
- )
44
- return timestamp, transaction.balance
76
+ def _prepare_chart_data(self, transaction_groups):
77
+ # Assign chart data to the list as tuples, adding offsets for duplicated dates
78
+ for transaction_date, transaction_group in transaction_groups.items():
79
+ base_timestamp = convert_date_to_midnight_timestamp(
80
+ transaction_date, milliseconds=True
81
+ )
82
+ offset = self._DAILY_MILLISECONDS / len(transaction_group)
83
+ for i, transaction in enumerate(transaction_group):
84
+ adjusted_timestamp = base_timestamp + (i * offset)
85
+ self.data.append((adjusted_timestamp, transaction.balance))
@@ -79,7 +79,8 @@ def load_account_details(account_id):
79
79
  "banking/account_page.html",
80
80
  account=account,
81
81
  account_transactions=transactions[:100],
82
- chart_data=get_balance_chart_data(transactions),
82
+ # Reverse the chart transactions to be chronologically ascending
83
+ chart_data=get_balance_chart_data(reversed(transactions)),
83
84
  )
84
85
 
85
86
 
monopyly/cli/apps.py CHANGED
@@ -21,25 +21,25 @@ class LocalApplication:
21
21
 
22
22
  mode_name = "local"
23
23
  default_port = "5001"
24
- command = ["flask"]
24
+ _debug = None
25
25
 
26
26
  def __init__(self, host=None, port=None, **options):
27
27
  """Initialize the application in development mode."""
28
28
  self._host = host
29
- self._port = port
29
+ self._port = port or self.default_port
30
30
  if options:
31
31
  raise NotImplementedError(
32
32
  f"Options besides `host` and `port` are not handled in {self.mode_name} mode."
33
33
  )
34
+ self.application = create_app(debug=self._debug)
34
35
 
35
36
  def run(self):
36
37
  """Run the Monopyly application in development mode."""
37
- instruction = self.command + ["run"]
38
- if self._host:
39
- instruction += ["--host", self._host]
40
- if self._port:
41
- instruction += ["--port", self._port]
42
- server = subprocess.Popen(instruction)
38
+ self.application.run(
39
+ host=self._host,
40
+ port=self._port,
41
+ debug=self._debug,
42
+ )
43
43
 
44
44
 
45
45
  class DevelopmentApplication(LocalApplication):
@@ -52,8 +52,8 @@ class DevelopmentApplication(LocalApplication):
52
52
  """
53
53
 
54
54
  mode_name = "development"
55
- default_port = "5000"
56
- command = LocalApplication.command + ["--debug"]
55
+ default_port = None # traditionally 5000
56
+ _debug = True
57
57
 
58
58
 
59
59
  class ProductionApplication(BaseApplication):
@@ -64,31 +64,33 @@ class ProductionApplication(BaseApplication):
64
64
  Gunicorn server instead of the built-in Python server.
65
65
  """
66
66
 
67
- default_port = "8000"
67
+ default_port = None # traditionally 8000
68
68
  _default_worker_count = (multiprocessing.cpu_count() * 2) + 1
69
69
 
70
70
  def __init__(self, host=None, port=None, **options):
71
71
  """Initialize the application in production mode."""
72
- options["bind"] = self._parse_binding(host, port, options.get("bind"))
73
- options.setdefault("workers", self._default_worker_count)
72
+ if port and not host:
73
+ raise ValueError("A host must be specified when the port is given.")
74
+ self._host = host
75
+ self._port = port or self.default_port
74
76
  self.options = options
77
+ self.options["bind"] = self._determine_binding(options.get("bind"))
78
+ self.options.setdefault("workers", self._default_worker_count)
75
79
  self.application = create_app()
76
80
  super().__init__()
77
81
 
78
- @staticmethod
79
- def _parse_binding(host, port, bind_option):
82
+ def _determine_binding(self, bind_option):
80
83
  # Parse any socket binding options
81
- if (host or port) and bind_option:
84
+ if self._host and bind_option:
82
85
  raise ValueError(
83
- "Neither `host` nor `port` parameters can be specified if the "
84
- "`bind` option is given."
86
+ "The `host` may not be specified directly if the `bind` option is used."
85
87
  )
86
- bind_values = []
87
- if host:
88
- bind_values.append(host)
89
- if port:
90
- bind_values.append(port)
91
- return bind if (bind := ":".join(bind_values)) else bind_option
88
+ if self._host:
89
+ bind_values = [self._host]
90
+ if self._port:
91
+ bind_values.append(self._port)
92
+ bind_option = ":".join(bind_values)
93
+ return bind_option
92
94
 
93
95
  def load_config(self):
94
96
  config = {
@@ -16,26 +16,37 @@ from rich.console import Console
16
16
 
17
17
  from .apps import DevelopmentApplication, LocalApplication, ProductionApplication
18
18
 
19
- # Set the Flask environment variable
19
+ # Set the Flask environment variable (to specify the app to use)
20
20
  os.environ["FLASK_APP"] = "monopyly"
21
21
 
22
22
 
23
- def main():
24
- args = parse_arguments()
25
- app_launcher = Launcher(args.mode, host=args.host, port=args.port)
23
+ def main(mode, host=None, port=None, backup=False, browser=False):
24
+ app_launcher = Launcher(mode, host=host, port=port)
26
25
  # Initialize the database and run the app
27
26
  app_launcher.initialize_database()
28
- if args.backup:
27
+ if backup:
29
28
  app_launcher.backup_database()
30
29
  app_launcher.launch()
31
- if args.mode in ("development", "local"):
30
+ if mode in ("development", "local"):
32
31
  # Enable browser viewing in development mode
33
- if args.browser:
32
+ if browser:
34
33
  app_launcher.open_browser(delay=1)
35
34
  # Wait for the exit command to stop
36
35
  app_launcher.wait_for_exit()
37
36
 
38
37
 
38
+ def main_cli():
39
+ """Run the app as a command line program."""
40
+ args = parse_arguments()
41
+ main(
42
+ args.mode,
43
+ host=args.host,
44
+ port=args.port,
45
+ backup=args.backup,
46
+ browser=args.browser,
47
+ )
48
+
49
+
39
50
  def parse_arguments():
40
51
  parser = argparse.ArgumentParser(description=__doc__)
41
52
  parser.add_argument("--host", help="the host address where the app will be run")
@@ -121,4 +132,4 @@ class Launcher:
121
132
 
122
133
 
123
134
  if __name__ == "__main__":
124
- main()
135
+ main_cli()
@@ -31,7 +31,7 @@ class EntryForm(FlaskForm, metaclass=AbstractEntryFormMixinMeta):
31
31
  that follows the same naming schema.
32
32
  """
33
33
 
34
- def prepopulate(self, entry):
34
+ def prepopulate(self, entry, data=None):
35
35
  """
36
36
  Generate a duplicate prepopulated form.
37
37
 
@@ -40,6 +40,11 @@ class EntryForm(FlaskForm, metaclass=AbstractEntryFormMixinMeta):
40
40
  entry : database.models.Model
41
41
  A database entry from which to pull information for
42
42
  prepopulating the form.
43
+ data : dict, optional
44
+ A dictionary containing extra data that will be joined with
45
+ data gathered from the given database entry. Fields defined
46
+ in this data dictionary will supersed fields defined on the
47
+ entry.
43
48
 
44
49
  Returns
45
50
  -------
@@ -57,7 +62,9 @@ class EntryForm(FlaskForm, metaclass=AbstractEntryFormMixinMeta):
57
62
  can not be used as a replacement for populating an existing
58
63
  form.
59
64
  """
60
- data = self.gather_entry_data(entry)
65
+ entry_data = self.gather_entry_data(entry) if entry else {}
66
+ # Merge data parsed from the entry with any data provided directly
67
+ data = entry_data | (data or {})
61
68
  return self.__class__(data=data)
62
69
 
63
70
  @abstractmethod
@@ -175,6 +182,53 @@ class TransactionForm(EntryForm):
175
182
  # Define an autocompleter for the form (in a sublcass)
176
183
  _autocompleter = None
177
184
 
185
+ def __init__(self, *args, **kwargs):
186
+ super().__init__(*args, **kwargs)
187
+ self.suggestions = {}
188
+
189
+ def prepopulate(self, entry, data=None, suggestion_fields=()):
190
+ """
191
+ Generate a duplicate prepopulated form.
192
+
193
+ Parameters
194
+ ----------
195
+ entry : database.models.Model
196
+ A database entry from which to pull information for
197
+ prepopulating the form.
198
+ data : dict, optional
199
+ A dictionary containing extra data that will be joined with
200
+ data gathered from the given database entry. Fields defined
201
+ in this data dictionary will supersed fields defined on the
202
+ entry.
203
+ suggestion_fields : list, optional
204
+ A list with form-specific parameters defining how the form
205
+ will extract and process a suggestion from the dictionary of
206
+ data which will be added to the form.
207
+
208
+ Returns
209
+ -------
210
+ form : TransactionForm
211
+ A duplicate form, prepopulated with the collected database
212
+ information.
213
+ """
214
+ self.suggestions |= self._extract_suggestions(data, suggestion_fields)
215
+ form = super().prepopulate(entry, data=data)
216
+ form.suggestions = self.suggestions
217
+ return form
218
+
219
+ def _extract_suggestions(self, data, suggestion_fields):
220
+ # Extract suggestion field values from the given data as specified
221
+ suggestions = {}
222
+ for field in suggestion_fields:
223
+ extraction_method = getattr(self, f"_extract_{field}_suggestion")
224
+ suggestions[field] = extraction_method(data)
225
+ return suggestions
226
+
227
+ @staticmethod
228
+ def _extract_suggestion(data, field):
229
+ # Pop the field value to provide a suggestion, rather than a deduction
230
+ return data.pop(field, None)
231
+
178
232
  def _prepare_transaction_data(self):
179
233
  subtransactions_data = [
180
234
  subform.subtransaction_data for subform in self["subtransactions"]
@@ -158,6 +158,15 @@ def _get_linked_credit_transaction(transaction_id, internal_transaction_id):
158
158
  return transaction
159
159
 
160
160
 
161
+ def highlight_unmatched_transactions(transactions, unmatched_transactions):
162
+ """Highlight transactions that are unmatched."""
163
+ unmatched_transaction_ids = [_.id for _ in unmatched_transactions]
164
+ for transaction in transactions:
165
+ if transaction.id in unmatched_transaction_ids:
166
+ transaction.highlight = True
167
+ yield transaction
168
+
169
+
161
170
  class TransactionTagHandler(DatabaseHandler, model=TransactionTag):
162
171
  """
163
172
  A database handler for managing transaction tags.
@@ -349,3 +358,156 @@ class TransactionTagHandler(DatabaseHandler, model=TransactionTag):
349
358
  query = cls.model.select_for_user().where(*criteria)
350
359
  tag = cls._db.session.execute(query).scalar_one_or_none()
351
360
  return tag
361
+
362
+
363
+ def categorize(transactions):
364
+ """
365
+ Categorize subtransactions into a tree of categories and subcategories.
366
+
367
+ Given a list of transactions, this function places each transaction
368
+ (technically each individual subtransaction of the transaction) into
369
+ categories based on its assigned tags. When a category is ambiguous
370
+ (e.g., multiple tags have been assigned from different branches in
371
+ the tag tree), the subtransaction is left uncategorized.
372
+
373
+ Parameters
374
+ ----------
375
+ transactions : list
376
+ The transactions (and corresponding subtransactions) to be
377
+ categorized.
378
+
379
+ Returns
380
+ -------
381
+ categories : CategoryTree
382
+ A tree-like structure of transaction categories, including
383
+ nested subcategories and subtotals at each level.
384
+ """
385
+ # Assign the subtransactions to categories
386
+ categories = RootCategoryTree()
387
+ for subtransaction in get_subtransactions(transactions):
388
+ categories.categorize_subtransaction(subtransaction)
389
+ return categories
390
+
391
+
392
+ def get_subtransactions(transactions):
393
+ """Given a list of transactions, return all the corresponding subtransactions."""
394
+ return [
395
+ subtransaction
396
+ for transaction in transactions
397
+ for subtransaction in transaction.subtransactions
398
+ ]
399
+
400
+
401
+ class CategoryTree:
402
+ """
403
+ Store a tree of categories.
404
+
405
+ Parameters
406
+ ----------
407
+ category : database.models.TransactionTag, str
408
+ The (root) category that this tree will represent.
409
+ subtransactions : list
410
+ A list of subtransactions that belong to this category, but
411
+ which are not included in any subcategory of this category.
412
+
413
+ Attributes
414
+ ----------
415
+ category : database.models.TransactionTag, str
416
+ The (root) category that this tree represents.
417
+ subtransactions : list
418
+ The subtransactions that belong to this category, but which are
419
+ not included in any subcategory of this category.
420
+ subcategories : dict
421
+ A mapping of subcategory names and trees that comprise this
422
+ category.
423
+ subtotal : float
424
+ The subtotal of all transactions in this category and all of its
425
+ subcategories.
426
+ """
427
+
428
+ def __init__(self, category, subtransactions=None):
429
+ self.category = category
430
+ self.subtransactions = subtransactions or []
431
+ self.subcategories = {}
432
+
433
+ @property
434
+ def subtotal(self):
435
+ subcategories = list(self.subcategories.values())
436
+ return sum(item.subtotal for item in self.subtransactions + subcategories)
437
+
438
+ def add_subcategory(self, tag):
439
+ """
440
+ Add a subcategory to the tree based on the given tag.
441
+
442
+ Add a subcategory tree to the mapping of subcategories based
443
+ on the given tag and return it. If the tag already has a
444
+ subcategory tree mapped to it, return that subcategory tree
445
+ instead.
446
+
447
+ Parameters
448
+ ----------
449
+ tag : database.models.TransactionTag
450
+ The tag for which a subcategory will be added.
451
+
452
+ Returns
453
+ -------
454
+ subcategory : CategoryTree
455
+ The subcategory tree matching the given tag.
456
+ """
457
+ return self.subcategories.setdefault(tag.tag_name, CategoryTree(tag))
458
+
459
+
460
+ class RootCategoryTree(CategoryTree):
461
+ """A special class of category tree that forms the root of a categorization."""
462
+
463
+ def __init__(self, subtransactions=None):
464
+ super().__init__("root", subtransactions=subtransactions)
465
+
466
+ def categorize_subtransaction(self, subtransaction):
467
+ """
468
+ Add a subtransaction to the tree of nested categories by tag.
469
+
470
+ Given a subtransaction, add that subtransaction to the category
471
+ tree according to its tags. If multiple tags exist at the same
472
+ level of the tree (i.e., a subtransaction with tags in diverging
473
+ branches), the tag is determined to be "uncategorizable" and the
474
+ tag is listed only as a member of the root tree and not as a
475
+ member of any other subcategory tree.
476
+
477
+ Parameters
478
+ ----------
479
+ subtransaction :
480
+ The subtransaction to be categorized.
481
+ """
482
+ tree = self
483
+ if subtransaction.categorizable:
484
+ # Collect all the tags for the subtransaction (ordered by tag depth)
485
+ tags = sorted(subtransaction.tags, key=lambda tag: tag.depth)
486
+ for tag in tags:
487
+ tree = tree.add_subcategory(tag)
488
+ tree.subtransactions.append(subtransaction)
489
+
490
+ def assemble_chart_data(self, exclude=()):
491
+ """
492
+ Create a dataset of categories and subtotals that can be used in a chart.
493
+
494
+ Parameters
495
+ ----------
496
+ exclude : ...
497
+ """
498
+ labels, subtotals = [], []
499
+ # Add chart data for categorical information
500
+ for name, subcategory in self.subcategories.items():
501
+ if name not in exclude and subcategory.subtotal > 0:
502
+ labels.append(name)
503
+ subtotals.append(subcategory.subtotal)
504
+ # Add chart data for uncategorized transactions
505
+ if (other_subtotal := sum(_.subtotal for _ in self.subtransactions)) > 0:
506
+ labels.append("")
507
+ subtotals.append(other_subtotal)
508
+ # Return the data in a format similar to what is required by the chart app
509
+ subtotal_labels = zip(subtotals, labels, strict=True)
510
+ return {
511
+ "labels": [label for _, label in sorted(subtotal_labels, reverse=True)],
512
+ "subtotals": sorted(subtotals, reverse=True),
513
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  from ..banking.transactions import record_new_transfer
4
4
  from ..common.forms.utils import execute_on_form_validation
5
+ from ..common.utils import parse_date
5
6
  from .cards import CreditCardHandler
6
7
  from .statements import CreditStatementHandler
7
8
  from .transactions import CreditTransactionHandler
@@ -174,3 +175,31 @@ def make_payment(card_id, payment_account_id, payment_date, payment_amount):
174
175
  ],
175
176
  }
176
177
  CreditTransactionHandler.add_entry(**credit_mapping)
178
+
179
+
180
+ def parse_request_transaction_data(request_args):
181
+ """
182
+ Parse transaction data given as arguments on the request.
183
+
184
+ Parameters
185
+ ----------
186
+ request_args : dict
187
+ A dictionary of URL arguments provided by the request.
188
+
189
+ Returns
190
+ -------
191
+ transaction_data : dict
192
+ A dictionary of transaction data parsed from the request
193
+ arguments.
194
+ """
195
+ if request_args:
196
+ transaction_data = {
197
+ "transaction_date": parse_date(request_args.get("transaction_date")),
198
+ }
199
+ if (subtotal := request_args.get("total")) is not None:
200
+ transaction_data["subtransactions"] = [{"subtotal": float(subtotal)}]
201
+ if (merchant := request_args.get("description")) is not None:
202
+ transaction_data["merchant"] = merchant
203
+ else:
204
+ transaction_data = {}
205
+ return transaction_data
monopyly/credit/forms.py CHANGED
@@ -36,6 +36,8 @@ from ..database.models import (
36
36
  from .accounts import CreditAccountHandler
37
37
  from .cards import CreditCardHandler
38
38
  from .statements import CreditStatementHandler
39
+ from .transactions import CreditTransactionHandler
40
+ from .transactions.activity.reconciliation import ActivityMatchmaker
39
41
 
40
42
 
41
43
  class CreditAccountSelectField(CustomChoiceSelectField):
@@ -297,6 +299,29 @@ class CreditTransactionForm(TransactionForm):
297
299
  statement = self.get_transaction_statement()
298
300
  return self._prepare_transaction_data(statement)
299
301
 
302
+ def _extract_merchant_suggestion(self, data):
303
+ # Use the merchant transaction data as a suggestion source
304
+ if merchant := self._extract_suggestion(data, "merchant"):
305
+ merchant_tokens = ActivityMatchmaker.tokenize(merchant)
306
+ # Suggest a known merchant with the closest distance to the activity merchant
307
+ score_records = []
308
+ for potential_merchant in CreditTransactionHandler.get_merchants():
309
+ test_tokens = ActivityMatchmaker.tokenize(potential_merchant)
310
+ score = ActivityMatchmaker.score_tokens(merchant_tokens, test_tokens)
311
+ # Only consider scores that have some similarity at all
312
+ if score < 1:
313
+ score_records.append((score, potential_merchant))
314
+ suggested_merchant = min(score_records)[1] if score_records else None
315
+ else:
316
+ suggested_merchant = None
317
+ return suggested_merchant
318
+
319
+ def _extract_amount_suggestion(self, data):
320
+ # Use the first subtransaction subtotal as a suggestion
321
+ subtransactions = self._extract_suggestion(data, "subtransactions")
322
+ suggested_amount = subtransactions[0]["subtotal"] if subtransactions else None
323
+ return suggested_amount
324
+
300
325
  def get_transaction_statement(self):
301
326
  """Get the credit card statement associated with the transaction."""
302
327
  return self.statement_info.get_statement(self.transaction_date.data)