ynab-unlinked 0.0.2__tar.gz

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 (35) hide show
  1. ynab_unlinked-0.0.2/.github/workflows/pypi-release.yml +38 -0
  2. ynab_unlinked-0.0.2/.gitignore +2 -0
  3. ynab_unlinked-0.0.2/LICENSE.txt +9 -0
  4. ynab_unlinked-0.0.2/PKG-INFO +36 -0
  5. ynab_unlinked-0.0.2/README.md +12 -0
  6. ynab_unlinked-0.0.2/pyproject.toml +61 -0
  7. ynab_unlinked-0.0.2/src/ynab_unlinked/__about__.py +4 -0
  8. ynab_unlinked-0.0.2/src/ynab_unlinked/__init__.py +7 -0
  9. ynab_unlinked-0.0.2/src/ynab_unlinked/__main__.py +56 -0
  10. ynab_unlinked-0.0.2/src/ynab_unlinked/commands/__init__.py +4 -0
  11. ynab_unlinked-0.0.2/src/ynab_unlinked/commands/config.py +56 -0
  12. ynab_unlinked-0.0.2/src/ynab_unlinked/commands/load.py +48 -0
  13. ynab_unlinked-0.0.2/src/ynab_unlinked/config.py +82 -0
  14. ynab_unlinked-0.0.2/src/ynab_unlinked/context_object.py +11 -0
  15. ynab_unlinked-0.0.2/src/ynab_unlinked/display.py +216 -0
  16. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/__init__.py +12 -0
  17. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/_protocol.py +29 -0
  18. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/bbva/__init__.py +3 -0
  19. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/bbva/bbva.py +61 -0
  20. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/bbva/command.py +26 -0
  21. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/cobee/__init__.py +3 -0
  22. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/cobee/cobee.py +151 -0
  23. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/cobee/command.py +43 -0
  24. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/sabadell/__init__.py +3 -0
  25. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/sabadell/command.py +31 -0
  26. ynab_unlinked-0.0.2/src/ynab_unlinked/entities/sabadell/sabadell.py +51 -0
  27. ynab_unlinked-0.0.2/src/ynab_unlinked/exceptions.py +7 -0
  28. ynab_unlinked-0.0.2/src/ynab_unlinked/matcher.py +85 -0
  29. ynab_unlinked-0.0.2/src/ynab_unlinked/models.py +136 -0
  30. ynab_unlinked-0.0.2/src/ynab_unlinked/parsers.py +92 -0
  31. ynab_unlinked-0.0.2/src/ynab_unlinked/payee.py +90 -0
  32. ynab_unlinked-0.0.2/src/ynab_unlinked/process.py +205 -0
  33. ynab_unlinked-0.0.2/src/ynab_unlinked/ynab_api/__init__.py +3 -0
  34. ynab_unlinked-0.0.2/src/ynab_unlinked/ynab_api/client.py +85 -0
  35. ynab_unlinked-0.0.2/tests/__init__.py +3 -0
@@ -0,0 +1,38 @@
1
+ name: Python Package CI
2
+
3
+ on:
4
+ release:
5
+ types: [created]
6
+
7
+ jobs:
8
+ build-and-publish:
9
+ if: startsWith(github.event.release.tag_name, 'yul-')
10
+ name: Build and publish Python distributions to PyPI
11
+ runs-on: ubuntu-latest
12
+ environment: release
13
+ permissions:
14
+ id-token: write # Needed for OIDC
15
+ contents: read # Needed to checkout the repository
16
+
17
+ steps:
18
+ - name: Checkout repository
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.12" # Or specify your desired Python version
25
+
26
+ - name: Install dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install hatch
30
+
31
+ - name: Build package
32
+ run: hatch build
33
+
34
+ - name: Publish package to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
36
+ with:
37
+ # skip_existing: true # Uncomment if you want to skip publishing if the version already exists
38
+ print_hash: true
@@ -0,0 +1,2 @@
1
+ __pycache__
2
+ *.ipynb
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Juanpe Araque <juanpe@committhatline.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: ynab-unlinked
3
+ Version: 0.0.2
4
+ Project-URL: Documentation, https://github.com/Juanpe Araque/ynab-unlinked#readme
5
+ Project-URL: Issues, https://github.com/Juanpe Araque/ynab-unlinked/issues
6
+ Project-URL: Source, https://github.com/Juanpe Araque/ynab-unlinked
7
+ Author-email: Juanpe Araque <juanpe@committhatline.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE.txt
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: Implementation :: CPython
14
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: html-text
17
+ Requires-Dist: pdfplumber
18
+ Requires-Dist: pydantic>=2.0.0
19
+ Requires-Dist: rapidfuzz
20
+ Requires-Dist: typer
21
+ Requires-Dist: unidecode
22
+ Requires-Dist: ynab
23
+ Description-Content-Type: text/markdown
24
+
25
+ # YNAB Unlinked
26
+
27
+ YNAB Unlinked is a CLI tools that allows creating transactions in your YNAB account from any input file.
28
+
29
+ Want to know more? Check out our [wiki](https://github.com/AAraKKe/ynab-unlinked/wiki) where you'll find all the details on how to get started, add new entities, and make the most of this tool. It's pretty straightforward once you get the hang of it!
30
+
31
+ > [!IMPORTANT]
32
+ > This project just started and is open to contributions!
33
+
34
+ ## License
35
+
36
+ `ynab-unlinked` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,12 @@
1
+ # YNAB Unlinked
2
+
3
+ YNAB Unlinked is a CLI tools that allows creating transactions in your YNAB account from any input file.
4
+
5
+ Want to know more? Check out our [wiki](https://github.com/AAraKKe/ynab-unlinked/wiki) where you'll find all the details on how to get started, add new entities, and make the most of this tool. It's pretty straightforward once you get the hang of it!
6
+
7
+ > [!IMPORTANT]
8
+ > This project just started and is open to contributions!
9
+
10
+ ## License
11
+
12
+ `ynab-unlinked` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ynab-unlinked"
7
+ dynamic = ["version"]
8
+ description = ''
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ keywords = []
13
+ authors = [{ name = "Juanpe Araque", email = "juanpe@committhatline.com" }]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Programming Language :: Python",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: Implementation :: CPython",
19
+ "Programming Language :: Python :: Implementation :: PyPy",
20
+ ]
21
+ dependencies = [
22
+ "ynab",
23
+ "typer",
24
+ "rapidfuzz",
25
+ "unidecode",
26
+ "html-text",
27
+ "pdfplumber",
28
+ "pydantic >= 2.0.0",
29
+ ]
30
+
31
+ [project.scripts]
32
+ yul = "ynab_unlinked.__main__:main"
33
+
34
+ [project.urls]
35
+ Documentation = "https://github.com/Juanpe Araque/ynab-unlinked#readme"
36
+ Issues = "https://github.com/Juanpe Araque/ynab-unlinked/issues"
37
+ Source = "https://github.com/Juanpe Araque/ynab-unlinked"
38
+
39
+ [tool.hatch.version]
40
+ path = "src/ynab_unlinked/__about__.py"
41
+
42
+ [tool.hatch.envs.types]
43
+ extra-dependencies = ["mypy>=1.0.0"]
44
+ [tool.hatch.envs.types.scripts]
45
+ check = "mypy --install-types --non-interactive {args:src/ynab_unlinked tests}"
46
+
47
+ [tool.hatch.envs.default.scripts]
48
+ yul = "python src/ynab_unlinked {args}"
49
+
50
+ [tool.coverage.run]
51
+ source_pkgs = ["ynab_unlinked", "tests"]
52
+ branch = true
53
+ parallel = true
54
+ omit = ["src/ynab_unlinked/__about__.py"]
55
+
56
+ [tool.coverage.paths]
57
+ ynab_unlinked = ["src/ynab_unlinked", "*/ynab-unlinked/src/ynab_unlinked"]
58
+ tests = ["tests", "*/ynab-unlinked/tests"]
59
+
60
+ [tool.coverage.report]
61
+ exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2025-present Juanpe Araque <juanpe@committhatline.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.0.2"
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025-present Juanpe Araque <juanpe@committhatline.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import typer
6
+
7
+ app = typer.Typer(name="ynab-unlinked", no_args_is_help=True)
@@ -0,0 +1,56 @@
1
+ import typer
2
+
3
+ from ynab_unlinked.config import Config, ensure_config
4
+ from ynab_unlinked.context_object import YnabUnlinkedContext
5
+ from ynab_unlinked.commands import load, config
6
+ from ynab_unlinked.display import prompt_for_api_key, prompt_for_budget
7
+
8
+ from rich import print
9
+
10
+ app = typer.Typer()
11
+
12
+ app.add_typer(load, name="load")
13
+ app.add_typer(config, name="config")
14
+
15
+
16
+ @app.command(name="setup")
17
+ def setup_command():
18
+ """Setup YNAB Unlinked"""
19
+ print("[bold]Welcome to ynab-unlinked! Lets setup your connection")
20
+ api_key = prompt_for_api_key()
21
+ config = Config(api_key=api_key, budget_id="")
22
+ config.save()
23
+
24
+ budget_id = prompt_for_budget()
25
+ config.budget_id = budget_id
26
+ config.save()
27
+
28
+ print("[bold green]All done!")
29
+
30
+
31
+ @app.callback(no_args_is_help=True)
32
+ def cli(context: typer.Context):
33
+ """
34
+ Create transations in your YNAB account from a bank export of your extract.
35
+ \n
36
+
37
+ The first time the command is run you will be asked some questions to setup your YNAB connection. After that,
38
+ transaction processing won't require any input unless there are some actions to take for specific transactions.
39
+ """
40
+
41
+ if context.invoked_subcommand == "setup":
42
+ # If we are running setup there is nothing to do here
43
+ return
44
+
45
+ if not ensure_config():
46
+ setup_command()
47
+ config = Config.load()
48
+ context.obj = YnabUnlinkedContext(config=config, extras=None)
49
+
50
+
51
+ def main():
52
+ app(prog_name="yul")
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
@@ -0,0 +1,4 @@
1
+ from .load import load
2
+ from .config import config
3
+
4
+ __all__ = ["config", "load"]
@@ -0,0 +1,56 @@
1
+ from typing_extensions import Annotated
2
+ from typing import assert_never
3
+ from enum import StrEnum
4
+
5
+ import typer
6
+ from rich import print
7
+
8
+ from ynab_unlinked.config import Config, ensure_config
9
+ from ynab_unlinked.display import prompt_for_api_key, prompt_for_budget
10
+
11
+
12
+ class ValidKeys(StrEnum):
13
+ BUDGET = "budget"
14
+ API_KEY = "api_key"
15
+
16
+
17
+ config = typer.Typer(help="Manage YNAB Unlinked configuration")
18
+
19
+
20
+ @config.command(name="set")
21
+ def set_command(
22
+ key: Annotated[
23
+ ValidKeys, typer.Argument(help="The config key to set", show_default=False)
24
+ ],
25
+ ):
26
+ """Set configuration options"""
27
+
28
+ match key:
29
+ case ValidKeys.API_KEY:
30
+ api_key = prompt_for_api_key()
31
+ # If the config exist, just update it
32
+ if ensure_config():
33
+ config = Config.load()
34
+ config.api_key = api_key
35
+ else:
36
+ config = Config(api_key=api_key, budget_id="")
37
+
38
+ config.save()
39
+ print("[bold green]🎉 The API key has been updated[/]")
40
+ case ValidKeys.BUDGET:
41
+ budget_id = prompt_for_budget()
42
+ config = Config.load()
43
+ config.budget_id = budget_id
44
+ config.save()
45
+ print("[bold green]🎉 The budget has been updated[/]")
46
+ case never:
47
+ assert_never(never)
48
+
49
+
50
+ @config.command(name="show")
51
+ def show():
52
+ if not ensure_config():
53
+ raise typer.Abort("YNAB Unlinked config not found. Run 'yul setup' to configure it.")
54
+
55
+ config = Config.load()
56
+ print(config.model_dump_json(indent=2))
@@ -0,0 +1,48 @@
1
+ import pkgutil
2
+ import importlib
3
+ from typing_extensions import Annotated
4
+
5
+ import typer
6
+
7
+ from ynab_unlinked import entities
8
+ from ynab_unlinked.context_object import YnabUnlinkedContext
9
+
10
+ load = typer.Typer(
11
+ help="Load transactions from a bank statement into your YNAB account.",
12
+ )
13
+
14
+
15
+ @load.callback()
16
+ def load_callback(
17
+ context: typer.Context,
18
+ show: Annotated[
19
+ bool,
20
+ typer.Option(
21
+ "-s",
22
+ "--show",
23
+ help="Just show the transactions available in the input file.",
24
+ ),
25
+ ] = False,
26
+ reconcile: Annotated[
27
+ bool, typer.Option("-r", "--reconcile", help="Reconcile cleared transactions")
28
+ ] = False,
29
+ ):
30
+ obj: YnabUnlinkedContext = context.obj
31
+
32
+ obj.show = show
33
+ obj.reconcile = reconcile
34
+
35
+
36
+ # Dynamically load all entities commands when present
37
+ for finder, name, ispkg in pkgutil.iter_modules(entities.__path__):
38
+ if not ispkg:
39
+ continue
40
+
41
+ module = importlib.import_module(f"{entities.__name__}.{name}")
42
+ if not hasattr(module, "command"):
43
+ continue
44
+
45
+ command = getattr(module, "command")
46
+
47
+ if callable(command):
48
+ load.command(name=name, no_args_is_help=True)(command)
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ynab_unlinked.models import Transaction, TransactionWithYnabData
9
+
10
+ CONFIG_PATH = Path.home() / ".config/ynab_unlinked/config.json"
11
+ TRANSACTION_GRACE_PERIOD_DAYS = 2
12
+
13
+
14
+ class Checkpoint(BaseModel):
15
+ latest_date_processed: dt.date
16
+ latest_transaction_hash: int
17
+
18
+
19
+ class EntityConfig(BaseModel):
20
+ account_id: str
21
+ checkpoint: Checkpoint | None = None
22
+
23
+
24
+ class Config(BaseModel):
25
+ api_key: str
26
+ budget_id: str
27
+ entities: dict[str, EntityConfig] = Field(default_factory=dict)
28
+ payee_rules: dict[str, set[str]] = Field(default_factory=dict)
29
+
30
+ def save(self):
31
+ CONFIG_PATH.write_text(self.model_dump_json(indent=4))
32
+
33
+ def update_and_save(self, last_transaction: Transaction, entity_name: str):
34
+ checkpoint = Checkpoint(
35
+ latest_date_processed=(
36
+ last_transaction.date - dt.timedelta(days=TRANSACTION_GRACE_PERIOD_DAYS)
37
+ ),
38
+ latest_transaction_hash=hash(last_transaction),
39
+ )
40
+
41
+ self.entities[entity_name].checkpoint = checkpoint
42
+
43
+ self.save()
44
+
45
+ @staticmethod
46
+ def load() -> Config:
47
+ return Config.model_validate_json(CONFIG_PATH.read_text())
48
+
49
+ def add_payee_rules(self, transactions: list[TransactionWithYnabData]):
50
+ # For each transaction, add a rule that matches both payees
51
+ for transaction in transactions:
52
+ if transaction.partial_match is None:
53
+ continue
54
+
55
+ if transaction.ynab_payee is None:
56
+ continue
57
+
58
+ imported_payee = transaction.payee
59
+ ynab_payee = transaction.ynab_payee
60
+
61
+ if imported_payee == ynab_payee:
62
+ continue
63
+
64
+ self.payee_rules.setdefault(ynab_payee, set()).add(imported_payee)
65
+ self.save()
66
+
67
+ def payee_from_fules(self, payee: str) -> str | None:
68
+ return next(
69
+ (
70
+ ynab_payee
71
+ for ynab_payee, valid_names in self.payee_rules.items()
72
+ if payee in valid_names
73
+ ),
74
+ None,
75
+ )
76
+
77
+
78
+ def ensure_config():
79
+ if not CONFIG_PATH.is_file():
80
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
81
+ return False
82
+ return True
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+ from ynab_unlinked.config import Config
4
+
5
+
6
+ @dataclass
7
+ class YnabUnlinkedContext[T]:
8
+ config: Config
9
+ extras: T
10
+ show: bool = False
11
+ reconcile: bool = False
@@ -0,0 +1,216 @@
1
+ from rich import box, print
2
+ from rich.rule import Rule
3
+ from rich.style import Style
4
+ from rich.table import Column, Table
5
+ from rich.prompt import Prompt
6
+ from rich.status import Status
7
+
8
+ from ynab_unlinked.config import Config, ensure_config
9
+ from ynab_unlinked.models import MatchStatus, Transaction, TransactionWithYnabData
10
+ from ynab_unlinked.ynab_api.client import Client
11
+
12
+ MAX_PAST_TRANSACTIONS_SHOWN = 3
13
+
14
+
15
+ def prompt_for_api_key() -> str:
16
+ return Prompt.ask("What is the API Key to connect to YNAB?", password=True)
17
+
18
+
19
+ def prompt_for_budget() -> str:
20
+ if ensure_config():
21
+ client = Client(Config.load())
22
+ else:
23
+ api_key = prompt_for_api_key()
24
+ config = Config(api_key=api_key, budget_id="")
25
+ config.save()
26
+ client = Client(config)
27
+
28
+ with Status("Getting budgets..."):
29
+ budgets = client.budgets()
30
+
31
+ print("Available budgets:")
32
+ for idx, budget in enumerate(budgets):
33
+ print(f" - {idx + 1}. {budget.name}")
34
+
35
+ budget_num = Prompt.ask(
36
+ "What budget do you want to use? (By number)",
37
+ choices=[str(i) for i in range(1, len(budgets) + 1)],
38
+ show_choices=False,
39
+ )
40
+ budget = budgets[int(budget_num) - 1]
41
+
42
+ print(f"[bold]Selected budget: {budget.name}")
43
+ return budget.id
44
+
45
+
46
+ def transaction_table(transactions: list[Transaction]):
47
+ columns = [
48
+ Column(header="Date", justify="left", max_width=10),
49
+ Column(header="Payee", justify="left", width=50),
50
+ Column(header="Inflow", justify="right", max_width=15),
51
+ Column(header="Outflow", justify="right", max_width=15),
52
+ ]
53
+ table = Table(
54
+ *columns,
55
+ title="Transactions to process",
56
+ caption=f"Only {MAX_PAST_TRANSACTIONS_SHOWN} processed transactions are shown.",
57
+ box=box.SIMPLE,
58
+ )
59
+
60
+ past_counter = 0
61
+ for transaction in transactions:
62
+ style = Style(color="gray37" if transaction.past else "default")
63
+
64
+ past_counter += int(transaction.past)
65
+ if past_counter == MAX_PAST_TRANSACTIONS_SHOWN:
66
+ # Stop adding transactions that are past after 5 for clarification
67
+ table.add_row("...", "...", "...", "...")
68
+ break
69
+
70
+ outflow = transaction.pretty_amount if transaction.amount < 0 else None
71
+ inflow = transaction.pretty_amount if transaction.amount > 0 else None
72
+
73
+ table.add_row(
74
+ transaction.date.strftime("%m/%d/%Y"),
75
+ transaction.payee,
76
+ inflow,
77
+ outflow,
78
+ style=style,
79
+ )
80
+
81
+ print(table)
82
+
83
+
84
+ def payee_line(transaction: TransactionWithYnabData) -> str:
85
+ if (
86
+ transaction.ynab_payee is not None
87
+ and transaction.payee == transaction.ynab_payee
88
+ ):
89
+ return transaction.ynab_payee
90
+
91
+ return f"{transaction.ynab_payee} [gray37] [Original payee: {transaction.payee}][/gray37]"
92
+
93
+
94
+ def updload_help_message(with_partial_matches=False) -> str:
95
+ main_message = (
96
+ "The table below shows the transactaions to be imported to YNAB. The transactions in the input file have been matched with existing transactions in YNAB.\n"
97
+ " - The [green]green[/] rows are new transactions to be imported.\n"
98
+ )
99
+ if with_partial_matches:
100
+ main_message += (
101
+ " - The [yellow]yellow[/] rows are transaction to be imported that match in date and amount with\n"
102
+ " transations that exist in YNAB but for which teh payee name could not be matched.\n"
103
+ " This is usually because the name from the import file is substantially different any payee present in YNAB.\n"
104
+ " If you accept these transactions are valid, we will keep track of this naming for future imports."
105
+ )
106
+
107
+ main_message += (
108
+ "The cleared status column shows how the transaction will be loaded to YNAB, not the current "
109
+ "status if the transaction was already in YNAB."
110
+ )
111
+
112
+ return main_message
113
+
114
+
115
+ def transactions_to_upload(transactions: list[TransactionWithYnabData]):
116
+ columns = [
117
+ Column(header="Match", justify="center", width=5),
118
+ Column(header="Date", justify="left", max_width=10),
119
+ Column(header="Payee", justify="left", width=70),
120
+ Column(header="Inflow", justify="right", max_width=15),
121
+ Column(header="Outflow", justify="right", max_width=15),
122
+ Column(header="Cleared Status", justify="left", width=15),
123
+ ]
124
+ table = Table(
125
+ *columns,
126
+ title="Recent Transactions",
127
+ caption="Transactions to [cyan bold]update[/] and [bold green]create[/].",
128
+ box=box.SIMPLE,
129
+ )
130
+
131
+ partial_matches = False
132
+ for transaction in transactions:
133
+ outflow = transaction.pretty_amount if transaction.amount < 0 else None
134
+ inflow = transaction.pretty_amount if transaction.amount > 0 else None
135
+
136
+ if transaction.needs_creation:
137
+ if transaction.match_status == MatchStatus.PARTIAL_MATCH:
138
+ style = "yellow"
139
+ partial_matches = True
140
+ else:
141
+ style = "green"
142
+ else:
143
+ style = "default"
144
+
145
+ table.add_row(
146
+ transaction.match_emoji,
147
+ transaction.date.strftime("%m/%d/%Y"),
148
+ payee_line(transaction),
149
+ inflow,
150
+ outflow,
151
+ transaction.cleared_status,
152
+ style=style,
153
+ )
154
+
155
+ print(Rule("Transactions to be imported"))
156
+ print(updload_help_message(partial_matches))
157
+ print(table)
158
+
159
+
160
+ def partial_matches(transactions: list[TransactionWithYnabData]):
161
+ columns = [
162
+ Column(header="Date", justify="left", max_width=10),
163
+ Column(header="Payee", justify="left", width=50),
164
+ Column(header="Inflow", justify="right", max_width=15),
165
+ Column(header="Outflow", justify="right", max_width=15),
166
+ Column(header="Cleared Status", justify="left", width=15),
167
+ ]
168
+ table = Table(
169
+ *columns,
170
+ title="Partial Matches",
171
+ caption="Each pair of transactions shows the imported transaction (top) and the \npartial match in YNAB (bottom).",
172
+ box=box.SIMPLE,
173
+ row_styles=["", "gray70"],
174
+ )
175
+
176
+ for transaction in transactions:
177
+ # If we do not need to import it, skip it
178
+ if not transaction.needs_creation:
179
+ continue
180
+
181
+ # Skip if no partial match
182
+ if (
183
+ transaction.match_status != MatchStatus.PARTIAL_MATCH
184
+ or transaction.partial_match is None
185
+ ):
186
+ continue
187
+
188
+ # Original transaction row
189
+ orig_outflow = transaction.pretty_amount if transaction.amount < 0 else None
190
+ orig_inflow = transaction.pretty_amount if transaction.amount > 0 else None
191
+
192
+ # YNAB transaction row (from partial_match)
193
+ ynab_amount = transaction.partial_match.amount / 1000
194
+ ynab_pretty_amount = f"{ynab_amount:.2f}€"
195
+ ynab_outflow = ynab_pretty_amount if ynab_amount < 0 else None
196
+ ynab_inflow = ynab_pretty_amount if ynab_amount > 0 else None
197
+
198
+ # Add the pair of rows
199
+ table.add_row(
200
+ transaction.date.strftime("%m/%d/%Y"),
201
+ transaction.payee,
202
+ orig_inflow,
203
+ orig_outflow,
204
+ "",
205
+ )
206
+
207
+ table.add_row(
208
+ transaction.partial_match.var_date.strftime("%m/%d/%Y"),
209
+ transaction.partial_match.payee_name or "",
210
+ ynab_inflow,
211
+ ynab_outflow,
212
+ transaction.partial_match.cleared.name.capitalize(),
213
+ end_section=True,
214
+ )
215
+
216
+ print(table)
@@ -0,0 +1,12 @@
1
+ from enum import StrEnum
2
+
3
+ from ._protocol import Entity
4
+
5
+
6
+ class InputType(StrEnum):
7
+ TXT = "txt"
8
+ CSV = "csv"
9
+ HTML = "html"
10
+
11
+
12
+ __all__ = ["Entity", "InputType"]