monopyly 1.4.8__py3-none-any.whl → 1.5.1__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 (77) hide show
  1. monopyly/CHANGELOG.md +18 -0
  2. monopyly/__init__.py +2 -2
  3. monopyly/_version.py +14 -2
  4. monopyly/auth/actions.py +12 -0
  5. monopyly/auth/routes.py +30 -21
  6. monopyly/auth/tools.py +1 -2
  7. monopyly/cli/apps.py +1 -1
  8. monopyly/cli/launch.py +3 -0
  9. monopyly/common/forms/_forms.py +56 -2
  10. monopyly/common/forms/utils.py +1 -2
  11. monopyly/common/transactions.py +162 -0
  12. monopyly/core/routes.py +0 -6
  13. monopyly/credit/actions.py +29 -0
  14. monopyly/credit/forms.py +25 -0
  15. monopyly/credit/routes.py +115 -7
  16. monopyly/credit/transactions/_transactions.py +15 -0
  17. monopyly/credit/transactions/activity/__init__.py +3 -0
  18. monopyly/credit/transactions/activity/data.py +161 -0
  19. monopyly/credit/transactions/activity/parser.py +282 -0
  20. monopyly/credit/transactions/activity/reconciliation.py +456 -0
  21. monopyly/database/models.py +6 -0
  22. monopyly/static/css/style.css +1328 -270
  23. monopyly/static/img/icons/statement-pair.png +0 -0
  24. monopyly/static/img/icons/statement-thick.png +0 -0
  25. monopyly/static/img/icons/statement.png +0 -0
  26. monopyly/static/js/bind-tag-actions.js +1 -1
  27. monopyly/static/js/create-balance-chart.js +1 -1
  28. monopyly/static/js/create-category-chart.js +27 -0
  29. monopyly/static/js/define-filter.js +1 -1
  30. monopyly/static/js/expand-transaction.js +10 -0
  31. monopyly/static/js/highlight-discrepant-transactions.js +124 -0
  32. monopyly/static/js/modules/expand-transaction.js +12 -3
  33. monopyly/static/js/modules/form-suggestions.js +60 -0
  34. monopyly/static/js/modules/manage-overlays.js +1 -3
  35. monopyly/static/js/show-credit-activity-loader.js +29 -0
  36. monopyly/static/js/toggle-navigation.js +35 -0
  37. monopyly/static/js/update-card-status.js +1 -1
  38. monopyly/static/js/use-suggested-amount.js +11 -0
  39. monopyly/static/js/use-suggested-merchant.js +11 -0
  40. monopyly/templates/auth/change_password.html +21 -0
  41. monopyly/templates/auth/login.html +3 -1
  42. monopyly/templates/auth/register.html +17 -7
  43. monopyly/templates/banking/account_page.html +3 -1
  44. monopyly/templates/banking/account_summaries.html +1 -1
  45. monopyly/templates/banking/accounts_page.html +11 -15
  46. monopyly/templates/banking/transactions_table/expanded_row_content.html +18 -20
  47. monopyly/templates/common/transaction_form/subtransaction_subform.html +10 -1
  48. monopyly/templates/common/transactions_table/linked_bank_transaction.html +1 -1
  49. monopyly/templates/common/transactions_table/linked_credit_transaction.html +1 -1
  50. monopyly/templates/common/transactions_table/transaction_condensed.html +2 -2
  51. monopyly/templates/common/transactions_table/transaction_expanded.html +3 -3
  52. monopyly/templates/common/transactions_table/transactions.html +1 -1
  53. monopyly/templates/core/credits.html +10 -8
  54. monopyly/templates/core/index.html +10 -0
  55. monopyly/templates/core/profile.html +3 -3
  56. monopyly/templates/credit/statement_page.html +33 -0
  57. monopyly/templates/credit/statement_reconciliation/discrepant_records.html +25 -0
  58. monopyly/templates/credit/statement_reconciliation/statement_reconciliation_inquiry.html +23 -0
  59. monopyly/templates/credit/statement_reconciliation/statement_reconciliation_page.html +86 -0
  60. monopyly/templates/credit/statement_reconciliation/summary.html +45 -0
  61. monopyly/templates/credit/statement_reconciliation/unrecorded_activities.html +24 -0
  62. monopyly/templates/credit/statement_summary.html +2 -2
  63. monopyly/templates/credit/statements.html +1 -1
  64. monopyly/templates/credit/transaction_form/transaction_form.html +9 -1
  65. monopyly/templates/credit/transaction_form/transaction_form_page.html +2 -0
  66. monopyly/templates/credit/transaction_form/transaction_form_page_update.html +9 -0
  67. monopyly/templates/credit/transaction_submission_page.html +64 -63
  68. monopyly/templates/credit/transactions_table/expanded_row_content.html +18 -12
  69. monopyly/templates/layout.html +37 -29
  70. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/METADATA +8 -7
  71. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/RECORD +75 -58
  72. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/WHEEL +1 -1
  73. monopyly/static/img/icons/statement-pair.svg +0 -281
  74. monopyly/static/img/icons/statement.svg +0 -294
  75. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/entry_points.txt +0 -0
  76. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/licenses/COPYING +0 -0
  77. {monopyly-1.4.8.dist-info → monopyly-1.5.1.dist-info}/licenses/LICENSE +0 -0
monopyly/CHANGELOG.md CHANGED
@@ -192,4 +192,22 @@
192
192
  - Fix bug in the ordering of balances in the bank account balance charts for transactions on duplicate dates
193
193
 
194
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
+
202
+ ### 1.5.1
203
+
204
+ - Bump dependencies (including patching security vulnerability in NLTK)
205
+ - Style credit transaction submissions as receipts
206
+ - Style flash messages according to content
207
+ - Return to statement details page after deleting a transaction (rather than returning to the general transactions page)
208
+ - Allow users to change their password
209
+ - Warn users before form submission if the configuration currently disallows registration
210
+ - Increase the flexibility of the credit activity parser
211
+
212
+
195
213
  <a name="bottom" id="bottom"></a>
monopyly/__init__.py CHANGED
@@ -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,16 @@
1
1
  # file generated by setuptools_scm
2
2
  # don't change, don't track in version control
3
- __version__ = version = '1.4.8'
4
- __version_tuple__ = version_tuple = (1, 4, 8)
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '1.5.1'
16
+ __version_tuple__ = version_tuple = (1, 5, 1)
monopyly/auth/actions.py CHANGED
@@ -1,5 +1,10 @@
1
1
  """Module describing logical authorization actions (to be used in routes)."""
2
2
 
3
+ from flask import current_app
4
+ from sqlalchemy import select
5
+
6
+ from ..database.models import User
7
+
3
8
 
4
9
  def get_username_and_password(form):
5
10
  """
@@ -11,3 +16,10 @@ def get_username_and_password(form):
11
16
  username = form["username"].lower()
12
17
  password = form["password"]
13
18
  return username, password
19
+
20
+
21
+ def identify_user(username):
22
+ """Identify the user in the database based on the username."""
23
+ user_query = select(User).where(User.username == username)
24
+ user = current_app.db.session.scalar(user_query)
25
+ return user
monopyly/auth/routes.py CHANGED
@@ -5,6 +5,7 @@ Routes for site authentication.
5
5
  from flask import (
6
6
  current_app,
7
7
  flash,
8
+ g,
8
9
  redirect,
9
10
  render_template,
10
11
  request,
@@ -16,7 +17,7 @@ from sqlalchemy import select
16
17
  from werkzeug.security import check_password_hash, generate_password_hash
17
18
 
18
19
  from ..database.models import User
19
- from .actions import get_username_and_password
20
+ from .actions import get_username_and_password, identify_user
20
21
  from .blueprint import bp
21
22
 
22
23
 
@@ -33,24 +34,17 @@ def register():
33
34
  error = "Username is required."
34
35
  elif not password:
35
36
  error = "Password is required."
37
+ elif user := identify_user(username):
38
+ error = f"User {username} is already registered."
36
39
  else:
37
- # Get user information from the database
38
- user_query = select(User).where(User.username == username)
39
- user = current_app.db.session.scalar(user_query)
40
- if user:
41
- error = f"User {username} is already registered."
42
- else:
43
- error = None
44
- # Add the username and hashed password to the database
45
- if not error:
40
+ # Create a new user
46
41
  new_user = User(
47
42
  username=username,
48
43
  password=generate_password_hash(password),
49
44
  )
50
45
  current_app.db.session.add(new_user)
51
46
  return redirect(url_for("auth.login"))
52
- else:
53
- flash(error)
47
+ flash(error)
54
48
  # Display the registration page
55
49
  return render_template("auth/register.html")
56
50
 
@@ -60,23 +54,17 @@ def login():
60
54
  if request.method == "POST":
61
55
  # Get username and passwords from the form
62
56
  username, password = get_username_and_password(request.form)
63
- # Get user information from the database
64
- user_query = select(User).where(User.username == username)
65
- user = current_app.db.session.scalar(user_query)
66
57
  # Check for errors in the accessed information
67
- if user is None:
58
+ if (user := identify_user(username)) is None:
68
59
  error = "That user is not yet registered."
69
60
  elif not check_password_hash(user.password, password):
70
61
  error = "Incorrect username and password combination."
71
62
  else:
72
- error = None
73
- # Set the user ID securely for a new session
74
- if not error:
63
+ # Set the user ID securely for a new session
75
64
  session.clear()
76
65
  session["user_id"] = user.id
77
66
  return redirect(url_for("core.index"))
78
- else:
79
- flash(error)
67
+ flash(error)
80
68
  # Display the login page
81
69
  return render_template("auth/login.html")
82
70
 
@@ -86,3 +74,24 @@ def logout():
86
74
  # End the session and clear the user ID
87
75
  session.clear()
88
76
  return redirect(url_for("core.index"))
77
+
78
+
79
+ @bp.route("/change_password", methods=("GET", "POST"))
80
+ @db_transaction
81
+ def change_password():
82
+ if request.method == "POST":
83
+ current_password = request.form["current-password"]
84
+ new_password = request.form["new-password"]
85
+ if check_password_hash(g.user.password, current_password):
86
+ # Merge the user item (dissociated from the current session) for updating
87
+ g.user = current_app.db.session.merge(g.user)
88
+ g.user.password = generate_password_hash(new_password)
89
+ flash("Password updated successfully.", category="success")
90
+ return redirect(url_for("core.load_profile"))
91
+ else:
92
+ flash(
93
+ "The provided value for the current password does not match the "
94
+ "value of the current password set on this account.",
95
+ category="error",
96
+ )
97
+ return render_template("auth/change_password.html")
monopyly/auth/tools.py CHANGED
@@ -14,8 +14,7 @@ from .blueprint import bp
14
14
  @bp.before_app_request
15
15
  def load_logged_in_user():
16
16
  # Match the user's information with the session
17
- user_id = session.get("user_id")
18
- if user_id is None:
17
+ if (user_id := session.get("user_id")) is None:
19
18
  g.user = None
20
19
  else:
21
20
  query = select(User).where(User.id == user_id)
monopyly/cli/apps.py CHANGED
@@ -31,7 +31,7 @@ class LocalApplication:
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()
34
+ self.application = create_app(debug=self._debug)
35
35
 
36
36
  def run(self):
37
37
  """Run the Monopyly application in development mode."""
monopyly/cli/launch.py CHANGED
@@ -16,6 +16,9 @@ from rich.console import Console
16
16
 
17
17
  from .apps import DevelopmentApplication, LocalApplication, ProductionApplication
18
18
 
19
+ # Set the Flask environment variable (to specify the app to use)
20
+ os.environ["FLASK_APP"] = "monopyly"
21
+
19
22
 
20
23
  def main(mode, host=None, port=None, backup=False, browser=False):
21
24
  app_launcher = Launcher(mode, host=host, port=port)
@@ -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"]
@@ -145,7 +145,6 @@ def execute_on_form_validation(func):
145
145
  else:
146
146
  # Show an error to the user and print the errors for the admin
147
147
  flash(form_err_msg)
148
- print(form.errors)
149
- raise ValidationError("The form did not validate properly.")
148
+ raise ValidationError(f"The form did not validate properly: {form.errors}")
150
149
 
151
150
  return wrapper
@@ -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
+ }
monopyly/core/routes.py CHANGED
@@ -5,7 +5,6 @@ Routes for core functionality.
5
5
  from pathlib import Path
6
6
 
7
7
  from flask import g, render_template, render_template_string, session
8
- from werkzeug.exceptions import abort
9
8
 
10
9
  from ..auth.tools import login_required
11
10
  from ..banking.accounts import BankAccountHandler
@@ -85,8 +84,3 @@ def load_profile():
85
84
  banks = BankHandler.get_banks()
86
85
  # Return banks as a list to allow multiple reuse
87
86
  return render_template("core/profile.html", banks=list(banks))
88
-
89
-
90
- @bp.route("/change_password")
91
- def change_password():
92
- abort(418)
@@ -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 := data.get("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)