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.
- ynab_unlinked-0.0.2/.github/workflows/pypi-release.yml +38 -0
- ynab_unlinked-0.0.2/.gitignore +2 -0
- ynab_unlinked-0.0.2/LICENSE.txt +9 -0
- ynab_unlinked-0.0.2/PKG-INFO +36 -0
- ynab_unlinked-0.0.2/README.md +12 -0
- ynab_unlinked-0.0.2/pyproject.toml +61 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/__about__.py +4 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/__init__.py +7 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/__main__.py +56 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/commands/__init__.py +4 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/commands/config.py +56 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/commands/load.py +48 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/config.py +82 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/context_object.py +11 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/display.py +216 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/__init__.py +12 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/_protocol.py +29 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/bbva/__init__.py +3 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/bbva/bbva.py +61 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/bbva/command.py +26 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/cobee/__init__.py +3 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/cobee/cobee.py +151 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/cobee/command.py +43 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/sabadell/__init__.py +3 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/sabadell/command.py +31 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/entities/sabadell/sabadell.py +51 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/exceptions.py +7 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/matcher.py +85 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/models.py +136 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/parsers.py +92 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/payee.py +90 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/process.py +205 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/ynab_api/__init__.py +3 -0
- ynab_unlinked-0.0.2/src/ynab_unlinked/ynab_api/client.py +85 -0
- 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,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,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,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,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)
|