diff --git a/.gitignore b/.gitignore index 44e3ce6..72194d1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ .idea .DS_Store env*/ -config.yaml +*.yaml dist/ live.py .pytest_cache/ diff --git a/README.md b/README.md index e685224..49040f8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![GitHub Release](https://img.shields.io/github/release/dnbasta/ynab-split-budget?style=flat)]() [![Github Release](https://img.shields.io/maintenance/yes/2100)]() +[![Monthly downloads](https://img.shields.io/pypi/dm/ynab-split-budget)]() This library enables cost sharing across two YNAB budgets. It requires two dedicated accounts in each budget to which each budget owner can transfer amounts from their own budget. Each transfer is considered as its opposite in the other @@ -18,99 +19,84 @@ account. ```bash pip install ynab-split-budget ``` +## Usage +### 1. Create a transaction +Create a transaction in the budget and add a specific flag in a specific color to be used for the splits. The +transaction needs to be cleared in order to be considered by this library. By default, the transaction will be split +in half, but you can specify a different split by adding `@x%` for percentage or `@x` for specific amount in the memo +of the transaction. The amount you specify in this split will be transferred to your sharing account. You can also +create a plain transfer to the shared account which will be completely allocated to the partner account. -## Create config -Create a config `dict` with the below structure. -```py -CONFIG = { - '': { - 'budget': '', - 'account': '', - 'token': '', - 'flag': ''}, - '': { - 'budget': '', - 'account': '', - 'token': '', - 'flag': ''} -} -``` +### 2. Initialize library You can find the ID of the budget and of the account if you go to https://app.ynab.com/ and open the target account by clicking on the name on the left hand side menu. The URL does now contain both IDs `https://app.ynab.com//accounts/` Possible colors for the flag value are `red`, `orange`, `yellow`, `green`, `blue` and `purple` +```py +from ynabsplitbudget import YnabSplitBudget, User -Alternatively you can save the config in a yaml file with the below structure and provide the path to the library -when initializing -```yaml -: - token: - budget: - account: - flag: -: - token: - budget: - account: - flag: -``` +user = User(name='', token='', budget_id='', + account_id='', token='', budget_id='', + account_id=' -p --split +``` +For a complete list of available bash options please use ```bash -$ python -m ynabsplitbudget -c -s | --split-transactions -$ python -m ynabsplitbudget -c -i | --insert-complements [-d | --since-date "YYYY-mm-dd"] -$ python -m ynabsplitbudget -c -b | --check-balances +$ python -m ynabsplitbudget -h | -- help ``` diff --git a/poetry.lock b/poetry.lock index 61e38fb..7f00a04 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,18 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} [[package]] name = "certifi" @@ -121,15 +135,32 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "deprecated" +version = "1.2.14" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] + [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, ] [package.extras] @@ -137,13 +168,13 @@ test = ["pytest (>=6)"] [[package]] name = "idna" -version = "3.6" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] [[package]] @@ -159,39 +190,149 @@ files = [ [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] name = "pluggy" -version = "1.4.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, - {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pydantic" +version = "2.7.1" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, + {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.18.2" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.18.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, + {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, + {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, + {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, + {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, + {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, + {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, + {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, + {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, + {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, + {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, + {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, + {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, + {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pytest" -version = "8.0.2" +version = "8.2.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"}, - {file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"}, + {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, + {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, ] [package.dependencies] @@ -199,11 +340,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.3.0,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +pluggy = ">=1.5,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pyyaml" @@ -296,6 +437,17 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "typing-extensions" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, +] + [[package]] name = "urllib3" version = "2.2.1" @@ -313,7 +465,103 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + +[[package]] +name = "ynab-transaction-adjuster" +version = "2.0.0" +description = "Library to adjust transactions in YNAB based on custom patterns" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "ynab_transaction_adjuster-2.0.0-py3-none-any.whl", hash = "sha256:ae8a13f870a57aeb58140ca502261ccf1a91307d63f15cccc064070ed556c330"}, + {file = "ynab_transaction_adjuster-2.0.0.tar.gz", hash = "sha256:9047fdd08658abe27cfe6060a9660b8dbdca96c232c101ffe73a9baa65d6f824"}, +] + +[package.dependencies] +deprecated = ">=1.2.14,<2.0.0" +pydantic = ">=2.7.0,<3.0.0" +pytest = ">=8.2.0,<9.0.0" +requests = ">=2.28.0,<3.0.0" + [metadata] lock-version = "2.0" -python-versions = ">=3.8" -content-hash = "132c9564a074324ee27b6174e51eb4fa476b90c7e5d18ae39f15963b5ad35ef8" +python-versions = "<4.0,>=3.8" +content-hash = "717e041e3d2d3abef49fe992966d8d5bb1709e7fad725f05e78240c61ee448e3" diff --git a/pyproject.toml b/pyproject.toml index 84cd4da..20b5925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,10 @@ classifiers = [ ] [tool.poetry.dependencies] -python = '>=3.8' +python = '<4.0,>=3.8' pyyaml = '>=6.0' requests = '>=2.28' +ynab-transaction-adjuster= '>=2.0.0' [tool.poetry.group.dev.dependencies] pytest = ">=8.0.0" diff --git a/tests/test_adjusters.py b/tests/test_adjusters.py new file mode 100644 index 0000000..7dce113 --- /dev/null +++ b/tests/test_adjusters.py @@ -0,0 +1,80 @@ +from unittest.mock import MagicMock, PropertyMock, patch + +from ynabtransactionadjuster import Payee, Transaction +from ynabtransactionadjuster.models import Category + +from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster + + +def test_reconcile_filter(): + + ra = ReconcileAdjuster(credentials=MagicMock()) + # Act + t = ra.filter([PropertyMock(cleared='cleared'), PropertyMock(cleared='uncleared'), PropertyMock(cleared='reconciled')]) + assert len(t) == 1 + + +@patch('ynabsplitbudget.adjusters.ReconcileAdjuster.categories', new_callable=PropertyMock()) +def test_reconcile_adjust(mock_categories): + # Arrange + ma = ReconcileAdjuster(credentials=MagicMock()) + mock_categories.fetch_by_name.return_value = MagicMock(spec=Category) + t = ma.adjust(PropertyMock(cleared='cleared'), PropertyMock(cleared='cleared')) + assert t.cleared == 'reconciled' + assert isinstance(t.category, Category) + + +def test_split_filter(): + sa = SplitAdjuster(credentials=MagicMock(), flag_color='red', transfer_payee_id='transfer_payee_id', + account_id='account_id') + f = sa.filter([PropertyMock(id='a', cleared='cleared', flag_color='red', account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='b', cleared='cleared', flag_color=None, account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='c', cleared='cleared', flag_color='yellow', account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='d', cleared='uncleared', flag_color='red', account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='c', cleared='reconciled', flag_color='red', account=MagicMock(id='account_idx'), subtransactions=[]), + PropertyMock(id='c', cleared='cleared', flag_color='red', account=MagicMock(id='account_id'), subtransactions=[]), + PropertyMock(id='c', cleared='cleared', flag_color='red', account=MagicMock(id='account_idx'), subtransactions=[MagicMock()])]) + assert len(f) == 1 + assert f[0].id == 'a' + + +@patch('ynabsplitbudget.adjusters.SplitAdjuster.payees', new_callable=PropertyMock()) +def test_split_adjust(mock_payees): + sa = SplitAdjuster(credentials=MagicMock(), flag_color='red', transfer_payee_id='transfer_payee_id', + account_id='account_id') + mock_payees.fetch_by_id.return_value = Payee(name='transfer_payee') + + mt = sa.adjust(PropertyMock(category=Category(id='category_id', name='category_name'), + amount=-1000, + payee=Payee(name='payee_name'), + memo='@25% memo'), PropertyMock()) + + assert len(mt.subtransactions) == 2 + assert mt.subtransactions[0].amount == -250 + assert mt.subtransactions[0].payee.name == 'transfer_payee' + assert mt.subtransactions[1].amount == -750 + assert mt.subtransactions[1].memo == '@25% memo' + assert mt.subtransactions[1].category.name == 'category_name' + + +def test_clear_filter(): + ca = ClearAdjuster(credentials=MagicMock(), split_transaction_ids=['transaction_id']) + r = ca.filter([PropertyMock(spec=Transaction, cleared='uncleared', id='transaction_id'), + PropertyMock(spec=Transaction, cleared='uncleared', id='transaction_id2'), + PropertyMock(spec=Transaction, cleared='cleared', id='transaction_id')]) + assert len(r) == 1 + + +@patch('ynabsplitbudget.adjusters.ClearAdjuster.categories', new_callable=PropertyMock()) +def test_clear_adjust(mock_categories): + # Arrange + ca = ClearAdjuster(credentials=MagicMock(), split_transaction_ids=[]) + mock_category = Category(name='category_name', id='category_id') + mock_categories.fetch_by_name.return_value = mock_category + + # Act + mt = ca.adjust(PropertyMock(cleared='uncleared'), PropertyMock(cleared='uncleared')) + + # Assert + assert mt.cleared == 'cleared' + assert mt.category == mock_category diff --git a/tests/test_client.py b/tests/test_client.py index 28996f2..7976b11 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,43 +1,45 @@ -from datetime import date -from unittest.mock import patch, MagicMock, ANY +from datetime import date, datetime +from unittest.mock import MagicMock, ANY import pytest -from requests import Response +from requests import Response, HTTPError -from ynabsplitbudget.client import BaseClient, SyncClient, SplitClient +from ynabsplitbudget.client import Client from ynabsplitbudget.models.account import Account -from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound, SplitNotValid -from ynabsplitbudget.models.splittransaction import SplitTransaction -from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction -from ynabsplitbudget.models.user import User -from ynabsplitbudget.transactionbuilder import SplitParser, SplitTransactionBuilder +from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound +from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction, ComplementTransaction + + +@pytest.fixture +def mock_client(): + client = Client(token='', user_name='', budget_id='', account_id='account_id') + client.session = MagicMock() + return client + + +def mock_response(data: dict) -> MagicMock: + mock_resp_obj = MagicMock(spec=Response) + mock_resp_obj.json.return_value = data + return mock_resp_obj @pytest.mark.parametrize('budget, account, expected', [('bullshit', 'bullshit', BudgetNotFound), ('sample_budget_id', 'bullshit', AccountNotFound)]) -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_account_fails(mock_response, mock_budget, budget, account, expected): +def test_fetch_account_fails(mock_client, mock_budget, budget, account, expected): # Arrange - mock_resp_obj = MagicMock(spec=Response) - mock_resp_obj.json.return_value = {'data': {'budgets': [mock_budget]}} - mock_response.return_value = mock_resp_obj + mock_client.session.get.return_value = mock_response({'data': {'budgets': [mock_budget]}}) # Act with pytest.raises(expected): - c = BaseClient(token='', user_name='') - c.fetch_account(budget_id=budget, account_id=account) + mock_client.fetch_account(budget_id=budget, account_id=account) -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_account_passes(mock_response, mock_budget): +def test_fetch_account_passes(mock_client, mock_budget): # Arrange - mock_resp_obj = MagicMock(spec=Response) - mock_resp_obj.json.return_value = {'data': {'budgets': [mock_budget]}} - mock_response.return_value = mock_resp_obj + mock_client.session.get.return_value = mock_response({'data': {'budgets': [mock_budget]}}) # Act - c = BaseClient(token='', user_name='') - a = c.fetch_account(budget_id='sample_budget_id', account_id='sample_account_id') + a = mock_client.fetch_account(budget_id='sample_budget_id', account_id='sample_account_id') # Assert assert isinstance(a, Account) @@ -49,16 +51,12 @@ def test_fetch_account_passes(mock_response, mock_budget): assert a.transfer_payee_id == 'sample_transfer_payee_id' -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_new(mock_response, mock_transaction_dict): +def test_fetch_new(mock_client, mock_transaction_dict): # Arrange - mock_resp_obj = MagicMock() - mock_resp_obj.json.return_value = {'data': {'transactions': [mock_transaction_dict], - 'server_knowledge': 100}} - mock_response.return_value = mock_resp_obj + mock_client.session.get.return_value = mock_response({'data': {'transactions': [mock_transaction_dict], + 'server_knowledge': 100}}) # Act - c = SyncClient(MagicMock(account=MagicMock(account_id='sample_account'))) - r = c.fetch_roots(since=date(2024,1, 1)) + r = mock_client.fetch_roots(since=date(2024, 1, 1)) # Assert t = r[0] @@ -72,76 +70,83 @@ def test_fetch_new(mock_response, mock_transaction_dict): assert t.transaction_date == date(2024, 1, 1) -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_new_empty(mock_response, mock_transaction_dict): +def test_fetch_new_empty(mock_client, mock_transaction_dict): # Arrange - mock_resp_obj = MagicMock() - mock_resp_obj.json.return_value = {'data': {'transactions': [], - 'server_knowledge': 100}} - mock_response.return_value = mock_resp_obj + mock_client.session.get.return_value = mock_response({'data': {'transactions': [], + 'server_knowledge': 100}}) # Act - c = SyncClient(MagicMock()) - r = c.fetch_roots(since=date(2024, 1, 1)) + r = mock_client.fetch_roots(since=date(2024, 1, 1)) # Assert assert len(r) == 0 -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_new_to_split_flag(mock_response, mock_transaction_dict): +def test_fetch_balance(mock_client): # Arrange - mock_resp_obj = MagicMock(spec=Response) - mock_resp_obj.json.return_value = {'data': {'transactions': [mock_transaction_dict]}} - mock_response.return_value = mock_resp_obj - - u = User(name='user_name', flag='purple', - token='sample_token', - account=MagicMock()) - c = SplitClient(u) - + mock_client.session.get.return_value = mock_response({'data': {'account': {'cleared_balance': 100}}}) # Act - st = c.fetch_new_to_split() + b = mock_client.fetch_balance() # Assert - assert isinstance(st[0], SplitTransaction) - assert st[0].split_amount == 500 + assert b == 100 -@patch('ynabsplitbudget.client.requests.get') -def test_fetch_balance(mock_response): +def test_fetch_lookup_no_since(mock_client, mock_transaction_dict): # Arrange - r = MagicMock() - r.json.return_value = {'data': {'account': {'cleared_balance': 100}}} - mock_response.return_value = r + mock_client.session.get.return_value = mock_response({'data': {'transactions': [mock_transaction_dict]}}) + # Act + r = mock_client.fetch_lookup(since=date(2024, 1, 1)) + + assert len(r) == 1 + assert isinstance(r[0], LookupTransaction) - c = SyncClient(user=MagicMock()) - b = c.fetch_balance() - # Assert - assert b == 100 +def test_fetch_lookup_since(mock_client, mock_transaction_dict): + # Arrange + mock_client.session.get.return_value = mock_response({'data': {'transactions': [mock_transaction_dict]}}) + + # Act + mock_client.fetch_lookup(since=date(2024, 1, 1)) + mock_client.session.get.assert_called_with(ANY, params={'since_date': '2024-01-01'}) -@patch('ynabsplitbudget.client.SyncClient._get') -def test_fetch_lookup_no_since(mock_response, mock_transaction_dict): + +def test_insert_complement(mock_client, mock_transaction_dict): # Arrange - mock_response.return_value = {'transactions': [mock_transaction_dict]} + mock_root = RootTransaction(id='id', share_id='share_id', account_id='account_id_o', + transaction_date=date(2024, 1, 1), memo='memo', payee_name='payee_name', amount=1000) + mock_transaction_dict['import_id'] = 's||xxx' + mock_client.session.post.return_value = mock_response({'data': {'transaction': mock_transaction_dict}}) # Act - c = SyncClient(MagicMock()) - r = c.fetch_lookup(since=date(2024, 1, 1)) + c = mock_client.insert_complement(mock_root) - assert len(r) == 1 - assert isinstance(r[0], LookupTransaction) + # Assert + mock_client.session.post.assert_called_once() + post_dict = mock_client.session.post.call_args_list[0][1]['json']['transaction'] + assert post_dict['account_id'] == mock_client.account_id + assert post_dict['date'] == datetime.strftime(mock_root.transaction_date, '%Y-%m-%d') + assert post_dict['amount'] == - mock_root.amount + assert post_dict['payee_name'] == mock_root.payee_name + assert post_dict['memo'] == mock_root.memo + assert post_dict['import_id'] == f's||{mock_root.share_id}||0' + + assert isinstance(c, ComplementTransaction) -@patch('ynabsplitbudget.client.SyncClient._get') -def test_fetch_lookup_since(mock_get, mock_transaction_dict): +def test_insert_complement_iteration(mock_client, mock_transaction_dict): # Arrange - mock_get.return_value = {'transactions': [mock_transaction_dict]} + mock_root = RootTransaction(id='id', share_id='share_id', account_id='account_id_o', + transaction_date=date(2024, 1, 1), memo='memo', payee_name='payee_name', amount=1000) + mock_transaction_dict['import_id'] = 's||xxx' + mock_client.session.post.side_effect = [HTTPError(response=MagicMock(status_code=409)), + mock_response({'data': {'transaction': mock_transaction_dict}})] # Act - c = SyncClient(MagicMock()) - c.fetch_lookup(since=date(2024, 1, 1)) + c = mock_client.insert_complement(mock_root) - mock_get.assert_called_with(ANY, params={'since_date': '2024-01-01'}) + # Assert + assert mock_client.session.post.call_count == 2 + post_dict = mock_client.session.post.call_args_list[1][1]['json']['transaction'] + assert post_dict['import_id'] == f's||{mock_root.share_id}||1' diff --git a/tests/test_splitparser.py b/tests/test_splitparser.py index 94ad31e..ac95540 100644 --- a/tests/test_splitparser.py +++ b/tests/test_splitparser.py @@ -1,7 +1,9 @@ +from unittest.mock import MagicMock + import pytest from ynabsplitbudget.models.exception import SplitNotValid -from ynabsplitbudget.transactionbuilder import SplitParser +from ynabsplitbudget.splitparser import SplitParser @pytest.mark.parametrize('test_input, expected', [('xxx', -1000), ('xxx @25%:xxx', -500), ('@33%', -660), ('@0.7', -700), @@ -9,10 +11,10 @@ def test_parse_split_pass(test_input, expected): # Arrange c = SplitParser() - t_dict = {'date': '2023-12-01', 'payee_name': 'payee', 'amount': -2000, 'memo': test_input} + transaction = MagicMock(date='2023-12-01', amount=-2000, memo=test_input) # Act - split_amount = c.parse_split(t_dict) + split_amount = c.parse_split(transaction) # Assert assert split_amount == expected @@ -22,9 +24,9 @@ def test_parse_split_pass(test_input, expected): def test_parse_split_fail(test_input): # Arrange c = SplitParser() - t_dict = {'date': '2023-12-01', 'payee_name': 'payee', 'amount': -1000, 'memo': test_input} + transaction = MagicMock(date='2023-12-01', amount=-1000, memo=test_input) # Assert with pytest.raises(SplitNotValid): # Act - c.parse_split(t_dict) + c.parse_split(transaction) diff --git a/tests/test_splitransactionbuilder.py b/tests/test_splitransactionbuilder.py deleted file mode 100644 index 722b338..0000000 --- a/tests/test_splitransactionbuilder.py +++ /dev/null @@ -1,35 +0,0 @@ -from unittest.mock import patch, MagicMock - -from ynabsplitbudget.models.splittransaction import SplitTransaction -from ynabsplitbudget.transactionbuilder import SplitTransactionBuilder - - -@patch('ynabsplitbudget.client.SplitTransaction.from_dict', return_value=MagicMock(spec=SplitTransaction)) -def test_build_transaction_success(mock): - # Arrange - c = SplitTransactionBuilder() - t_dict = {'date': '2023-12-01', 'payee_name': 'payee', 'amount': 1000, 'memo': 'xxx'} - - # Act - st = c.build(t_dict=t_dict) - - # Assert - mock.assert_called_once_with(t_dict=t_dict, split_amount=500) - - -@patch('ynabsplitbudget.client.SplitTransaction.from_dict', return_value=MagicMock(spec=SplitTransaction)) -def test_build_transaction_fail(mock, caplog): - # Arrange - c = SplitTransactionBuilder() - t_dict = {'date': '2023-12-01', 'payee_name': 'payee', 'amount': 1000, 'memo': '@110%'} - - # Act - with caplog.at_level('ERROR'): - st = c.build(t_dict=t_dict) - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == 'ERROR' - assert len(caplog.records[0].message) > 0 - - # Assert - assert not mock.called - assert st is None diff --git a/tests/test_syncrepository.py b/tests/test_syncrepository.py index a366e13..fbd1f4a 100644 --- a/tests/test_syncrepository.py +++ b/tests/test_syncrepository.py @@ -5,8 +5,8 @@ from ynabsplitbudget.syncrepository import SyncRepository -@patch('ynabsplitbudget.client.SyncClient.fetch_roots') -@patch('ynabsplitbudget.client.SyncClient.fetch_lookup') +@patch('ynabsplitbudget.client.Client.fetch_roots') +@patch('ynabsplitbudget.client.Client.fetch_lookup') def test_fetch_new_to_insert_new(mock_lookup, mock_changed): # Arrange mock_transaction = MagicMock(spec=RootTransaction, transaction_date=date(2023, 10, 1), id='id', @@ -21,8 +21,8 @@ def test_fetch_new_to_insert_new(mock_lookup, mock_changed): assert isinstance(t[0], RootTransaction) -@patch('ynabsplitbudget.client.SyncClient.fetch_roots') -@patch('ynabsplitbudget.client.SyncClient.fetch_lookup') +@patch('ynabsplitbudget.client.Client.fetch_roots') +@patch('ynabsplitbudget.client.Client.fetch_lookup') def test_fetch_new_to_insert_not_new(mock_lookup, mock_changed): # Arrange mock_transaction = MagicMock(spec=RootTransaction, transaction_date=date(2023, 10, 1), id='id', @@ -37,8 +37,8 @@ def test_fetch_new_to_insert_not_new(mock_lookup, mock_changed): assert len(t) == 0 -@patch('ynabsplitbudget.client.SyncClient.fetch_roots') -@patch('ynabsplitbudget.client.SyncClient.fetch_lookup') +@patch('ynabsplitbudget.client.Client.fetch_roots') +@patch('ynabsplitbudget.client.Client.fetch_lookup') def test_fetch_new_to_insert_empty(mock_lookup, mock_changed): # Arrange mock_changed.return_value = [] diff --git a/tests/test_transactionbuilder.py b/tests/test_transactionbuilder.py index d14f2cd..1bd2a30 100644 --- a/tests/test_transactionbuilder.py +++ b/tests/test_transactionbuilder.py @@ -5,10 +5,11 @@ from ynabsplitbudget.models.transaction import ComplementTransaction from ynabsplitbudget.transactionbuilder import TransactionBuilder + @pytest.mark.parametrize('test_input, expected', [('s||xxx||1', 1), ('s||xxx', 0)] ) def test_build_complement_import_ids(test_input, expected, mock_transaction_dict): # Arrange - tb = TransactionBuilder(user=MagicMock()) + tb = TransactionBuilder(account_id='') mock_transaction_dict['import_id'] = test_input # Act diff --git a/tests/test_fileloader.py b/tests/test_user.py similarity index 55% rename from tests/test_fileloader.py rename to tests/test_user.py index c2ccdd3..bfd1f55 100644 --- a/tests/test_fileloader.py +++ b/tests/test_user.py @@ -4,17 +4,18 @@ import pytest from yaml.parser import ParserError -from ynabsplitbudget.fileloader import FileLoader +from ynabsplitbudget import User -def test_load_file_not_found(): +def test_from_yaml_file_not_found(): + with pytest.raises(FileNotFoundError): - ul = FileLoader(path='/test/path/config.yaml').load() + User.from_yaml(path='/test/path/config.yaml') -@patch('ynabsplitbudget.fileloader.Path.open') +@patch('ynabsplitbudget.models.user.Path.open') def test_user_loader_wrong_file_format(mock_config, mock_config_dict): mock_config.return_value = StringIO('{{xxx') with pytest.raises(ParserError): - ul = FileLoader(path='/test/path/config.yaml').load() + User.from_yaml(path='/test/path/config.yaml') diff --git a/tests/test_userloader.py b/tests/test_userloader.py deleted file mode 100644 index 778afba..0000000 --- a/tests/test_userloader.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -from io import StringIO -from unittest.mock import patch, MagicMock - -import pytest -from yaml.parser import ParserError - -from ynabsplitbudget.models.exception import UserNotFound, ConfigNotValid -from ynabsplitbudget.models.user import User -from ynabsplitbudget.userloader import UserLoader - - -def test_user_loader_pass(mock_config_dict): - ul = UserLoader(mock_config_dict) - assert isinstance(ul, UserLoader) - - -@pytest.mark.parametrize('test_input', [{'u1': {}}, - {'u1': {}, 'u2': {}, 'u3': {}}, - {'u1': {}, 'u2': {}}]) -def test_user_loader_config_not_valid(test_input): - - # Act - with pytest.raises(ConfigNotValid) as e: - UserLoader(config_dict=test_input) - - -def test_user_loader_config_valid(mock_config_dict): - # Act - ul = UserLoader(mock_config_dict) - - # Assert - assert isinstance(ul, UserLoader) - assert ul._config_dict == mock_config_dict - - -def test_load_fail(mock_config_dict): - # Arrange - ul = UserLoader(mock_config_dict) - # Act - with pytest.raises(UserNotFound): - u = ul.load('xxx') - - -@patch('ynabsplitbudget.userloader.BaseClient.fetch_account', return_value=MagicMock()) -def test_load_pass(mock_account, mock_config_dict): - # Arrange - ul = UserLoader(mock_config_dict) - # Act - u = ul.load('user_1') - # Assert - assert isinstance(u, User) - assert u.name == 'user_1' - assert u.flag == 'purple' - assert u.token == 'token1' - - -@patch('ynabsplitbudget.userloader.BaseClient.fetch_account', return_value=MagicMock()) -def test_partner(mock_account, mock_config_dict): - # Arrange - ul = UserLoader(mock_config_dict) - - # Act - p = ul.load_partner('user_1') - - # Assert - assert isinstance(p, User) - assert p.name == 'user_2' - assert p.flag == 'red' - assert p.token == 'token2' diff --git a/tests/test_ynabsplitbudget.py b/tests/test_ynabsplitbudget.py new file mode 100644 index 0000000..fd40910 --- /dev/null +++ b/tests/test_ynabsplitbudget.py @@ -0,0 +1,4 @@ + + + + diff --git a/ynabsplitbudget/__init__.py b/ynabsplitbudget/__init__.py index 4f809aa..f8971ea 100644 --- a/ynabsplitbudget/__init__.py +++ b/ynabsplitbudget/__init__.py @@ -1,2 +1,4 @@ from ynabsplitbudget.ynabsplitbudget import YnabSplitBudget +from ynabsplitbudget.models.transaction import ComplementTransaction +from ynabsplitbudget.models.user import User diff --git a/ynabsplitbudget/__main__.py b/ynabsplitbudget/__main__.py index a41232c..01d3185 100644 --- a/ynabsplitbudget/__main__.py +++ b/ynabsplitbudget/__main__.py @@ -4,6 +4,7 @@ import logging from datetime import datetime +from ynabsplitbudget import User from ynabsplitbudget.ynabsplitbudget import YnabSplitBudget logging.basicConfig(level=logging.INFO) @@ -14,36 +15,56 @@ def custom_warn(message, category, filename, lineno, file=None, line=None): if __name__ == '__main__': - # execute only if run as the entry point into the program - parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, - usage='ynabsplitaccount [-c | --config] ' - '[-s | --split-transactions] [-i | --insert-complements] ' - '[-d | --since-date "YYYY-mm-dd"]') - parser.add_argument("-c", "--config", type=str, help="path of config YAML to use and user", required=True) - parser.add_argument("-s", "--split-transactions", action="store_true", + usage='ynabsplitbudget [-u | --user] ' + '[-p | --partner] ' + '[-s | --split] ' + '[-i | --push] ' + '[-b | --balances]' + '[-d | --delete-orphans]' + '[-r | --reconcile]' + '[--since "YYYY-mm-dd"]') + parser.add_argument("-u", "--user", type=str, required=True, + help="path of config YAML to use for user") + parser.add_argument("-p", "--partner", type=str, required=True, + help="path of config YAML to use for partner") + parser.add_argument("-s", "--split", action="store_true", help="split transactions from account") - parser.add_argument("-i", "--insert-complements", action="store_true", - help="fetch new transactions from both accounts and insert complements") - parser.add_argument("-b", "--check-balances", action="store_true", + parser.add_argument("-i", "--push", action="store_true", + help="push split transactions to partner account") + parser.add_argument("-b", "--balances", action="store_true", help="raise error if balances of the two accounts don't match") - parser.add_argument("-d", "--since-date", action="store_true", - help='provide date since which insert function should compare transactions in the format of "YYYY-mm-dd"') + parser.add_argument("-d", "--delete-orphans", action="store_true", + help="deletes orphaned transactions in partner account") + parser.add_argument("-r", "--reconcile", action="store_true", + help="reconciles account if balance matches with partner account") + parser.add_argument("--since", type=str, + help='provide optional date if library should use something else than 30 days default') + args = parser.parse_args() warnings.showwarning = custom_warn - path, user_name = args.config.split('#') - ysb = YnabSplitBudget.from_yaml(path=path, user=user_name) - - if args.split_transactions: - ysb.split_transactions() - if args.insert_complements: - if args.since_date: - since = datetime.strptime(args.since_date, "%Y-%m-%d") - else: - since = None - ysb.insert_complements(since=since) - if args.check_balances: + user = User.from_yaml(args.user) + partner = User.from_yaml(args.partner) + ysb = YnabSplitBudget(user=user, partner=partner) + + if args.since: + try: + since = datetime.strptime(args.since, "%Y-%m-%d") + except ValueError as e: + raise ValueError(f"Incorrect date format {args.since}, should be YYYY-mm-dd") from e + else: + since = None + + if args.split: + ysb.split() + if args.push: + ysb.push(since=since) + if args.delete_orphans: + ysb.delete_orphans(since=since) + if args.balances or args.reconcile: ysb.raise_on_balances_off() + if args.reconcile: + ysb.reconcile() diff --git a/ynabsplitbudget/adjusters.py b/ynabsplitbudget/adjusters.py new file mode 100644 index 0000000..ab88654 --- /dev/null +++ b/ynabsplitbudget/adjusters.py @@ -0,0 +1,55 @@ +from typing import List + +from ynabtransactionadjuster import Adjuster, Credentials, ModifierSubTransaction +from ynabtransactionadjuster.models import Transaction, Modifier + +from ynabsplitbudget.splitparser import SplitParser + + +class ReconcileAdjuster(Adjuster): + + def filter(self, transactions: List[Transaction]) -> List[Transaction]: + return [t for t in transactions if t.cleared == 'cleared'] + + def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: + modifier.cleared = 'reconciled' + modifier.category = self.categories.fetch_by_name('Inflow: Ready to Assign') + return modifier + + +class ClearAdjuster(Adjuster): + + def __init__(self, credentials: Credentials, split_transaction_ids: List[str]): + super().__init__(credentials=credentials) + self.split_transaction_ids = split_transaction_ids + + def filter(self, transactions: List[Transaction]) -> List[Transaction]: + return [t for t in transactions + if t.cleared == 'uncleared' + and t.id in self.split_transaction_ids] + + def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: + modifier.cleared = 'cleared' + modifier.category = self.categories.fetch_by_name('Inflow: Ready to Assign') + return modifier + + +class SplitAdjuster(Adjuster): + + def __init__(self, credentials: Credentials, flag_color: str, transfer_payee_id: str, account_id: str): + super().__init__(credentials=credentials) + self.flag_color = flag_color + self.transfer_payee_id = transfer_payee_id + self.account_id = account_id + + def filter(self, transactions: List[Transaction]) -> List[Transaction]: + return [t for t in transactions if t.cleared == 'cleared' and t.flag_color == self.flag_color + and not t.subtransactions and not t.account.id == self.account_id] + + def adjust(self, original: Transaction, modifier: Modifier) -> Modifier: + split_amount = SplitParser().parse_split(transaction=original) + s1 = ModifierSubTransaction(amount=split_amount, payee=self.payees.fetch_by_id(self.transfer_payee_id), + memo=f"{original.payee.name} | {original.memo}") + s2 = ModifierSubTransaction(amount=original.amount - split_amount, category=original.category, memo=original.memo) + modifier.subtransactions = [s1, s2] + return modifier diff --git a/ynabsplitbudget/client.py b/ynabsplitbudget/client.py index ed91364..5e4be35 100644 --- a/ynabsplitbudget/client.py +++ b/ynabsplitbudget/client.py @@ -5,48 +5,40 @@ import requests from requests import HTTPError -from ynabsplitbudget.models.splittransaction import SplitTransaction -from ynabsplitbudget.transactionbuilder import TransactionBuilder, SplitTransactionBuilder -from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound from ynabsplitbudget.models.account import Account +from ynabsplitbudget.models.exception import BudgetNotFound, AccountNotFound +from ynabsplitbudget.transactionbuilder import TransactionBuilder from ynabsplitbudget.models.transaction import RootTransaction, LookupTransaction, ComplementTransaction -from ynabsplitbudget.models.user import User YNAB_BASE_URL = 'https://api.ynab.com/v1/' -class ClientMixin: - _token: str - - def _header(self): - return {'Authorization': f'Bearer {self._token}'} - - def _get(self, url: str, params: dict = None) -> dict: - r = requests.get(url, params=params, headers=self._header()) - r.raise_for_status() - return r.json()['data'] - - -class BaseClient(ClientMixin): +@dataclass +class Client: - def __init__(self, token: str, user_name: str): - self._token = token - self._user_name = user_name + def __init__(self, user_name: str, budget_id: str, account_id: str, token: str): + self.session = requests.Session() + self.session.headers.update({'Authorization': f'Bearer {token}'}) + self.user_name = user_name + self.budget_id = budget_id + self.account_id = account_id + self.transaction_builder = TransactionBuilder(account_id=self.account_id) def fetch_account(self, budget_id: str, account_id: str) -> Account: - url = f'{YNAB_BASE_URL}budgets?include_accounts=true' - data_dict = self._get(url) + r = self.session.get(f'{YNAB_BASE_URL}budgets', params=dict(include_accounts=True)) + r.raise_for_status() + data_dict = r.json()['data'] try: budget = next(b for b in data_dict['budgets'] if b['id'] == budget_id) except StopIteration: - raise BudgetNotFound(f"No budget with id '{budget_id} found for {self._user_name}'") + raise BudgetNotFound(f"No budget with id '{budget_id} found for {self.user_name}'") try: account = next(a for a in budget['accounts'] if a['id'] == account_id and a['deleted'] is False) except StopIteration: raise AccountNotFound(f"No Account with id '{account_id}' fund in budget '{budget['name']} " - f"for user {self._user_name}'") + f"for user {self.user_name}'") return Account(budget_id=budget_id, budget_name=budget['name'], @@ -55,20 +47,11 @@ def fetch_account(self, budget_id: str, account_id: str) -> Account: transfer_payee_id=account['transfer_payee_id'], currency=budget['currency_format']['iso_code']) - -@dataclass -class SyncClient(ClientMixin): - - def __init__(self, user: User): - self._token = user.token - self.user = user - self.transaction_builder = TransactionBuilder(user=user) - def fetch_roots(self, since: date) -> List[RootTransaction]: - url = (f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/accounts/' - f'{self.user.account.account_id}/transactions') - - transactions_dicts = self._get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')})['transactions'] + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/accounts/{self.account_id}/transactions' + r = self.session.get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')}) + r.raise_for_status() + transactions_dicts = r.json()['data']['transactions'] transactions_filtered = [t for t in transactions_dicts if not t['cleared'] == 'uncleared' and t['deleted'] is False and (t['import_id'] is None or 's||' not in t['import_id']) @@ -77,26 +60,26 @@ def fetch_roots(self, since: date) -> List[RootTransaction]: return transactions def fetch_lookup(self, since: date) -> List[Union[RootTransaction, LookupTransaction, ComplementTransaction]]: - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions' - data_dict = self._get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')}) - transactions = [self.transaction_builder.build(t_dict=t) for t in data_dict['transactions']] + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/transactions' + r = self.session.get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')}) + r.raise_for_status() + data_dict = r.json()['data']['transactions'] + transactions = [self.transaction_builder.build(t_dict=t) for t in data_dict] return transactions - def insert_complement(self, t: RootTransaction): + def insert_complement(self, t: RootTransaction) -> ComplementTransaction: iteration = 0 - try_again = True - while try_again: + while True: try: - self._insert(t, iteration=iteration) - try_again = False + return self._insert(t, iteration=iteration) except HTTPError as e: if e.response.status_code == 409: iteration += 1 - def _insert(self, t: RootTransaction, iteration: int): - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions' + def _insert(self, t: RootTransaction, iteration: int) -> ComplementTransaction: + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/transactions' data = {'transaction': { - "account_id": self.user.account.account_id, + "account_id": self.account_id, "date": t.transaction_date.strftime("%Y-%m-%d"), "amount": - t.amount, "payee_name": t.payee_name, @@ -105,50 +88,19 @@ def _insert(self, t: RootTransaction, iteration: int): "approved": False, "import_id": f's||{t.share_id}||{iteration}' }} - r = requests.post(url, json=data, headers=self._header()) + r = self.session.post(url, json=data) r.raise_for_status() + complement = self.transaction_builder.build_complement(r.json()['data']['transaction']) + return complement def fetch_balance(self) -> int: - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/accounts/{self.user.account.account_id}' - data_dict = self._get(url) - return data_dict['account']['cleared_balance'] - - def delete_complement(self, transaction_id: str) -> None: - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions/{transaction_id}' - r = requests.delete(url, headers=self._header()) + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/accounts/{self.account_id}' + r = self.session.get(url) r.raise_for_status() + balance = r.json()['data']['account']['cleared_balance'] + return balance - -class SplitClient(ClientMixin): - - def __init__(self, user: User): - self._token = user.token - self.user = user - - def fetch_new_to_split(self) -> List[SplitTransaction]: - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions' - - data_dict = self._get(url) - transactions_dicts = [t for t in data_dict['transactions'] if not t['cleared'] == 'uncleared' - and t['deleted'] is False and len(t['subtransactions']) == 0] - stb = SplitTransactionBuilder() - flag_splits = [stb.build(t) for t in transactions_dicts if t['flag_color'] == self.user.flag] - - return [s for s in flag_splits if s is not None] - - def insert_split(self, t: SplitTransaction): - data = {'transaction': { - "subtransactions": [{"amount": int(round(t.split_amount)), - "payee_id": self.user.account.transfer_payee_id, - "memo": t.memo, - "cleared": "cleared" - }, - {"amount": int(t.amount - round(t.split_amount)), - "category_id": t.category.id, - "cleared": "cleared" - }] - }} - url = f'{YNAB_BASE_URL}budgets/{self.user.account.budget_id}/transactions/{t.id}' - - r = requests.put(url, json=data, headers=self._header()) + def delete_complement(self, transaction_id: str) -> None: + url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/transactions/{transaction_id}' + r = self.session.delete(url) r.raise_for_status() diff --git a/ynabsplitbudget/fileloader.py b/ynabsplitbudget/fileloader.py deleted file mode 100644 index cba3db6..0000000 --- a/ynabsplitbudget/fileloader.py +++ /dev/null @@ -1,14 +0,0 @@ -from pathlib import Path - -import yaml - - -class FileLoader: - - def __init__(self, path: str): - self._path = path - - def load(self) -> dict: - with Path(self._path).open(mode='r') as f: - config_dict = yaml.safe_load(f) - return config_dict diff --git a/ynabsplitbudget/models/splittransaction.py b/ynabsplitbudget/models/splittransaction.py deleted file mode 100644 index 4acf114..0000000 --- a/ynabsplitbudget/models/splittransaction.py +++ /dev/null @@ -1,29 +0,0 @@ -from dataclasses import dataclass -from datetime import date, datetime - -from ynabsplitbudget.models.category import Category - - -@dataclass(eq=True, frozen=True) -class SplitTransaction: - id: str - transaction_date: date - memo: str - payee_name: str - payee_id: str - category: Category - amount: float - split_amount: float - account_id: str - - @classmethod - def from_dict(cls, t_dict: dict, split_amount: float): - return cls(id=t_dict['id'], - transaction_date=datetime.strptime(t_dict['date'], '%Y-%m-%d').date(), - payee_name=t_dict['payee_name'], - memo=t_dict['memo'], - amount=t_dict['amount'], - payee_id=t_dict['payee_id'], - category=Category(name=t_dict['category_name'], id=t_dict['category_id']), - split_amount=split_amount, - account_id=t_dict['account_id']) diff --git a/ynabsplitbudget/models/user.py b/ynabsplitbudget/models/user.py index 958c69f..688eddc 100644 --- a/ynabsplitbudget/models/user.py +++ b/ynabsplitbudget/models/user.py @@ -1,11 +1,55 @@ from dataclasses import dataclass +from pathlib import Path +from typing import Literal +import yaml + +from ynabsplitbudget.client import Client from ynabsplitbudget.models.account import Account @dataclass(eq=True, frozen=True) class User: + """Object representing a user. + + :ivar name: The name of the user. + :ivar token: The token for access to the YNAB API. + :ivar budget_id: The ID of the YNAB budget to use. + :ivar account_id: The ID of the split account to use in the budget. + :ivar flag_color: The color of the flag with which split transactions are marked + """ name: str token: str - account: Account - flag: str + budget_id: str + account_id: str + flag_color: Literal['red', 'green', 'blue', 'orange', 'purple', 'yellow'] + + def fetch_account(self) -> Account: + """Fetches account data from the API or user + + :return: Account object with budget and account name, transfer_payee_id and currency + """ + client = Client(token=self.token, user_name=self.name, budget_id=self.budget_id, account_id=self.account_id) + return client.fetch_account(budget_id=self.budget_id, account_id=self.account_id) + + @classmethod + def from_dict(cls, data: dict) -> 'User': + """Creates user object from dict""" + return cls(name=data['name'], token=data['token'], budget_id=data['budget_id'], account_id=data['account_id'], + flag_color=data['flag_color']) + + @classmethod + def from_yaml(cls, path: str) -> 'User': + """Create instance by loading config from YAML file + + :param path: Path to the YAML file + + :returns: instance of YnabSplitBudget class + """ + with Path(path).open(mode='r') as f: + config_dict = yaml.safe_load(f) + + try: + return cls.from_dict(config_dict) + except KeyError as e: + raise ValueError(f'Could not load config from YAML file: {path}') from e diff --git a/ynabsplitbudget/splitparser.py b/ynabsplitbudget/splitparser.py new file mode 100644 index 0000000..bbfca68 --- /dev/null +++ b/ynabsplitbudget/splitparser.py @@ -0,0 +1,35 @@ +import re + +from ynabtransactionadjuster import Transaction + +from ynabsplitbudget.models.exception import SplitNotValid + + +class SplitParser: + + @staticmethod + def parse_split(transaction: Transaction) -> int: + amount = transaction.amount + + try: + r = re.search(r'@(\d+\.?\d*)(%?)', transaction.memo) + except TypeError: + r = None + + # return half the amount if no split attribution is found + if r is None: + return int(amount * 0.5) + + split_number = float(r.groups()[0]) + + # return amount percentage if % in memo + if r.groups()[1] == '%': + if split_number <= 100: + return int(amount * split_number / 100) + raise SplitNotValid(f"Split is above 100% for transaction {transaction}") + + # return absolute amount + if split_number * 1000 > abs(amount): + raise SplitNotValid(f"Split is above total amount of {amount / 1000:.2f} for transaction {transaction}") + sign = -1 if amount < 0 else 1 + return int(sign * split_number * 1000) diff --git a/ynabsplitbudget/syncrepository.py b/ynabsplitbudget/syncrepository.py index 3abcb6d..33a0b06 100644 --- a/ynabsplitbudget/syncrepository.py +++ b/ynabsplitbudget/syncrepository.py @@ -1,7 +1,7 @@ from datetime import date from typing import List, Union -from ynabsplitbudget.client import SyncClient +from ynabsplitbudget.client import Client from ynabsplitbudget.models.transaction import RootTransaction, ComplementTransaction, LookupTransaction from ynabsplitbudget.models.user import User @@ -9,8 +9,10 @@ class SyncRepository: def __init__(self, user: User, partner: User): - self._user_client = SyncClient(user) - self._partner_client = SyncClient(partner) + self._user_client = Client(token=user.token, budget_id=user.budget_id, account_id=user.account_id, + user_name=user.name) + self._partner_client = Client(token=partner.token, budget_id=partner.budget_id, account_id=partner.account_id, + user_name=partner.name) def fetch_roots_wo_complement(self, since: date) -> List[RootTransaction]: roots = self._user_client.fetch_roots(since=since) @@ -20,8 +22,8 @@ def fetch_roots_wo_complement(self, since: date) -> List[RootTransaction]: lookup_date=since) return transactions_replaced_payee - def insert_complements(self, transactions: List[RootTransaction]): - [self._partner_client.insert_complement(t) for t in transactions] + def insert_complements(self, transactions: List[RootTransaction]) -> List[ComplementTransaction]: + return [self._partner_client.insert_complement(t) for t in transactions] def replace_payee(self, transactions: List[RootTransaction], lookup_date: date) -> List[RootTransaction]: ul = self._user_client.fetch_lookup(lookup_date) diff --git a/ynabsplitbudget/transactionbuilder.py b/ynabsplitbudget/transactionbuilder.py index 783543d..0fa0c14 100644 --- a/ynabsplitbudget/transactionbuilder.py +++ b/ynabsplitbudget/transactionbuilder.py @@ -1,24 +1,20 @@ import hashlib -import logging import re from dataclasses import dataclass from datetime import datetime -from typing import Union, Optional +from typing import Union -from ynabsplitbudget.models.exception import SplitNotValid -from ynabsplitbudget.models.splittransaction import SplitTransaction from ynabsplitbudget.models.transaction import RootTransaction, ComplementTransaction, LookupTransaction -from ynabsplitbudget.models.user import User @dataclass class TransactionBuilder: - user: User + account_id: str def build(self, t_dict: dict) -> Union[RootTransaction, ComplementTransaction, LookupTransaction]: if t_dict['import_id'] and 's||' in t_dict['import_id']: return self.build_complement(t_dict) - if t_dict['account_id'] not in self.user.account.account_id: + if t_dict['account_id'] not in self.account_id: return self.build_lookup(t_dict) return self.build_root(t_dict) @@ -66,40 +62,3 @@ def build_lookup(t_dict: dict) -> LookupTransaction: account_id=t_dict['account_id']) -class SplitTransactionBuilder: - - @staticmethod - def build(t_dict: dict) -> Optional[SplitTransaction]: - try: - split_amount = SplitParser().parse_split(t_dict) - return SplitTransaction.from_dict(t_dict=t_dict, split_amount=split_amount) - except SplitNotValid as e: - logging.getLogger(__name__).error(e) - return None - - -class SplitParser: - - @staticmethod - def parse_split(t_dict: dict) -> int: - amount = t_dict['amount'] - rep = f"[{t_dict['date']} | {t_dict['payee_name']} | {amount / 1000:.2f} | {t_dict['memo']}]" - - try: - r = re.search(r'@(\d+\.?\d*)(%?)', t_dict['memo']) - except TypeError: - r = None - - # return half the amount if no split attribution is found - if r is None: - return amount * 0.5 - - split_number = float(r.groups()[0]) - if r.groups()[1] == '%': - if split_number <= 100: - return amount * split_number / 100 - raise SplitNotValid(f"Split is above 100% for transaction {rep}") - if split_number * 1000 > abs(amount): - raise SplitNotValid(f"Split is above total amount of {amount / 1000:.2f} for transaction {rep}") - sign = -1 if amount < 0 else 1 - return int(sign * split_number * 1000) diff --git a/ynabsplitbudget/userloader.py b/ynabsplitbudget/userloader.py deleted file mode 100644 index 138baf0..0000000 --- a/ynabsplitbudget/userloader.py +++ /dev/null @@ -1,32 +0,0 @@ -from ynabsplitbudget.client import BaseClient -from ynabsplitbudget.models.exception import ConfigNotValid, UserNotFound -from ynabsplitbudget.models.user import User - - -class UserLoader: - - def __init__(self, config_dict: dict): - self._config_dict = config_dict - - if len(self._config_dict) != 2: - raise ConfigNotValid('Config needs to have exactly 2 user entries') - - for u, entries in self._config_dict.items(): - for e in ['account', 'budget', 'token', 'flag']: - if e not in entries.keys(): - raise ConfigNotValid(f"'{e}' missing for user '{u}'") - - def load(self, user) -> User: - - try: - user_dict = self._config_dict[user] - c = BaseClient(token=user_dict['token'], user_name=user) - account = c.fetch_account(budget_id=user_dict['budget'], account_id=user_dict['account']) - return User(name=user, token=user_dict['token'], account=account, flag=user_dict['flag']) - - except KeyError: - raise UserNotFound(f"{user} not found in config") - - def load_partner(self, user: str) -> User: - partner = next(k for k in self._config_dict.keys() if k != user) - return self.load(user=partner) diff --git a/ynabsplitbudget/ynabsplitbudget.py b/ynabsplitbudget/ynabsplitbudget.py index 7621fef..5722bf0 100644 --- a/ynabsplitbudget/ynabsplitbudget.py +++ b/ynabsplitbudget/ynabsplitbudget.py @@ -2,64 +2,115 @@ from datetime import date, timedelta, datetime from typing import List, Optional -from ynabsplitbudget.client import SplitClient, SyncClient -from ynabsplitbudget.fileloader import FileLoader +from ynabtransactionadjuster import Credentials, Transaction + +from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster +from ynabsplitbudget.client import Client from ynabsplitbudget.models.exception import BalancesDontMatch -from ynabsplitbudget.models.transaction import ComplementTransaction +from ynabsplitbudget.models.transaction import ComplementTransaction, RootTransaction +from ynabsplitbudget.models.user import User from ynabsplitbudget.syncrepository import SyncRepository -from ynabsplitbudget.userloader import UserLoader class YnabSplitBudget: + """Interface to YNAB Split Budget. - def __init__(self, config: dict, user: str): + :ivar user: User to use for instance + :ivar partner: Partner to use for instance + :ivar logger: Logger of the instance + """ + def __init__(self, user: User, partner: User): + self.user = user + self.partner = partner self.logger = self._set_up_logger() - userloader = UserLoader(config_dict=config) - self._user = userloader.load(user) - self._partner = userloader.load_partner(user) - - @classmethod - def from_yaml(cls, path: str, user: str): - config_dict = FileLoader(path).load() - return cls(config=config_dict, user=user) + def push(self, since: date = None) -> List[ComplementTransaction]: + """Pushes transactions from user split account to partner split account. By default, considers transactions of + last 30 days. - def insert_complements(self, since: date = None) -> int: + :param since: If set to date, will push transactions from that date onwards instead of default 30 days + :return: List of inserted transactions in partner split account + """ since = self._substitute_default_since(since) - repo = SyncRepository(user=self._user, partner=self._partner) + repo = SyncRepository(user=self.user, partner=self.partner) transactions = repo.fetch_roots_wo_complement(since=since) - repo.insert_complements(transactions) - logging.getLogger(__name__).info(f'inserted {len(transactions)} complements into account of {self._partner.name}') - return len(transactions) + complement_transactions = repo.insert_complements(transactions) + logging.getLogger(__name__).info(f'inserted {len(complement_transactions)} complements into account of ' + f'{self.partner.name}') + return complement_transactions + + def split(self, clear: bool = False) -> List[Transaction]: + """Splits transactions (by default 50%) into subtransaction with original category and transfer subtransaction + to split account + + :param clear: If set to true transactions in split account will automatically be set to cleared + :return: list with split transactions + """ + creds = Credentials(token=self.user.token, budget=self.user.budget_id) + s = SplitAdjuster(creds, flag_color=self.user.flag_color, + transfer_payee_id=self.user.fetch_account().transfer_payee_id, + account_id=self.user.account_id) + mod_trans = s.apply() + updated_transactions = s.update(mod_trans) + logging.getLogger(__name__).info(f'split {len(updated_transactions)} transactions for {self.user.name}') + + if clear: + transfer_transaction_ids = [st.transfer_transaction_id for t in updated_transactions for st in + t.subtransactions if st.transfer_transaction_id] + creds = Credentials(token=self.user.token, budget=self.user.budget_id, + account=self.user.account_id) + ca = ClearAdjuster(creds, split_transaction_ids=transfer_transaction_ids) + mod_trans_clear = ca.apply() + count_clear = len(s.update(mod_trans_clear)) + logging.getLogger(__name__).info(f'cleared {count_clear} transactions for {self.user.name}') - def split_transactions(self) -> int: - c = SplitClient(self._user) - st_list = c.fetch_new_to_split() - [c.insert_split(st) for st in st_list] - logging.getLogger(__name__).info(f'split {len(st_list)} transactions for {self._user.name}') - return len(st_list) + return updated_transactions def raise_on_balances_off(self): - repo = SyncRepository(user=self._user, partner=self._partner) + """Evaluates cleared balances in both accounts + + :raises BalancesDontMatch: if cleared amounts in both accounts don't match + """ + repo = SyncRepository(user=self.user, partner=self.partner) user_balance, partner_balance = repo.fetch_balances() if user_balance + partner_balance != 0: - raise BalancesDontMatch({'user': {'name': self._user.name, + raise BalancesDontMatch({'user': {'name': self.user.name, 'balance': user_balance}, - 'partner': {'name': self._partner.name, + 'partner': {'name': self.partner.name, 'balance': partner_balance}}) - def delete_orphaned_complements(self, since: date = None) -> List[ComplementTransaction]: + def delete_orphans(self, since: date = None) -> List[ComplementTransaction]: + """Delete orphaned transactions in partner account. By default, considers transactions of last 30 days. + + :param since: if set to date will delete orphaned transactions from that date onwards instead of default 30 days + """ since = self._substitute_default_since(since) - orphaned_complements = SyncRepository(user=self._user, partner=self._partner).find_orphaned_partner_complements(since) - c = SyncClient(self._partner) + orphaned_complements = SyncRepository(user=self.user, partner=self.partner).find_orphaned_partner_complements(since) + c = Client(user_name=self.partner.name, budget_id=self.partner.budget_id, account_id=self.partner.account_id, + token=self.partner.token) [c.delete_complement(oc.id) for oc in orphaned_complements] - logging.getLogger(__name__).info(f'deleted {len(orphaned_complements)} orphaned complements in account of {self._partner.name}') + logging.getLogger(__name__).info(f'deleted {len(orphaned_complements)} orphaned complements in account of {self.partner.name}') logging.getLogger(__name__).info(orphaned_complements) return orphaned_complements + def reconcile(self) -> int: + """Reconciles cleared transactions in the current account + + :returns: count of reconciled transactions + :raises BalancesDontMatch: if cleared amounts in both accounts don't match + """ + self.raise_on_balances_off() + creds = Credentials(token=self.user.token, budget=self.user.budget_id, + account=self.user.account_id) + ra = ReconcileAdjuster(creds) + mod_trans = ra.apply() + updated_trans = ra.update(mod_trans) + logging.getLogger(__name__).info(f'reconciled {len(updated_trans)} transactions for {self.user.name}') + return len(updated_trans) + @staticmethod - def _substitute_default_since(since: Optional[date]): + def _substitute_default_since(since: Optional[date]) -> date: if since is None: return datetime.now() - timedelta(days=30) return since