├── .gitattributes ├── .github ├── .codecov.yml ├── pylintrc └── workflows │ ├── codescanner.yml │ ├── create_release.yml │ ├── markdown_check.yml │ └── test_lint.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── dkb_robo ├── __init__.py ├── __main__.py ├── authentication.py ├── cli.py ├── dkb_robo.py ├── exemptionorder.py ├── legacy.py ├── portfolio.py ├── postbox.py ├── standingorder.py ├── transaction.py └── utilities.py ├── doc ├── dkb_docdownload.py ├── dkb_example.py ├── dkb_robo.html └── unfiltered.md ├── pyproject.toml ├── sonar-project.properties └── test ├── __init__.py ├── mocks ├── accounts.json ├── brokerage.json ├── cards.json ├── dauerauftraege.html ├── details-konto.html ├── details-milesmore.html ├── details-visa.html ├── dkb_punkte.html ├── doclinks-2.html ├── doclinks-3.html ├── doclinks.html ├── document_list-2.html ├── document_list.html ├── finanzstatus-error1.html ├── finanzstatus-error2.html ├── finanzstatus-error3.html ├── finanzstatus-mbank.html ├── finanzstatus.html ├── freistellungsauftrag-indexerror.html ├── freistellungsauftrag-multiple.html ├── freistellungsauftrag-nobr.html ├── freistellungsauftrag.html ├── konto-kreditkarten-limits-exception.html ├── konto-kreditkarten-limits.html ├── login.html ├── milesmore-finanzstatus.html ├── milesmore-rechnungen-doks.html ├── pd.json ├── postbox-2.html ├── postbox.html ├── so.json ├── test_parse_account_tr.csv ├── test_parse_depot.csv ├── test_parse_dkb_cc_tr.csv ├── test_parse_no_account_tr.csv ├── test_parse_no_cc_tr.csv ├── umsaetze-abgerechnet-milesmore.html ├── umsaetze-konto.html ├── umsaetze-milesmore.html ├── umsaetze-neue-milesmore.html └── umsaetze-visa.html ├── test_authentication.py ├── test_cli.py ├── test_dkb_robo.py ├── test_exemptionorder.py ├── test_legacy.py ├── test_portfolio.py ├── test_postbox.py ├── test_standingorder.py ├── test_transaction.py └── test_utilities.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | test/* linguist-vendored 4 | -------------------------------------------------------------------------------- /.github/.codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "dkb_robo/__init__.py" 3 | - "dkb_robo/version.py" 4 | - "setup.py" 5 | - "doc" 6 | - "test" # wildcards accepted 7 | -------------------------------------------------------------------------------- /.github/pylintrc: -------------------------------------------------------------------------------- 1 | # plyintrc for dkb-robo CI pipeline 2 | 3 | [MESSAGES CONTROL] 4 | # c0301 - line to long 5 | # r0205 - useless-object-inheritance 6 | # r0801 - Similar lines in 2 files 7 | # r0902 - too-many-instance-attributes 8 | # r0903 - too-few-public methods 9 | # r0913 - too-many-arguments 10 | # r1702 - too many nested blocks 11 | # w0703 - too general exception 12 | # W1202 - logging-format-interpolation 13 | disable=C0301, R0205, R0801, R0902, R0903, R0913, R1702, W0703, W1202 14 | 15 | [DESIGN] 16 | # Maximum number of locals for function / method 17 | max-locals=20 18 | max-branches=20 19 | max-public-methods=30 20 | max-statements=100 21 | -------------------------------------------------------------------------------- /.github/workflows/codescanner.yml: -------------------------------------------------------------------------------- 1 | name: Codescanner 2 | on: 3 | push: 4 | branches: 5 | - 'master' 6 | - 'devel' 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | sonarcloud: 14 | name: SonarCloud Analysis 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout source code 18 | uses: actions/checkout@v4 19 | with: 20 | # Disabling shallow clone is recommended for improving relevancy of reporting. 21 | fetch-depth: 0 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.x' 27 | 28 | - name: Install package 29 | run: python -m pip install .[test] 30 | 31 | - name: Run tests 32 | run: python -m pytest 33 | 34 | - name: Upload coverage reports to Codecov 35 | uses: codecov/codecov-action@v4.5.0 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | flags: unittests 39 | 40 | - name: SonarCloud Scan 41 | uses: SonarSource/sonarcloud-github-action@v3 42 | env: 43 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 44 | # Needed to get PR information, if any. 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | codeql: 48 | name: CodeQL Analysis 49 | runs-on: ubuntu-latest 50 | 51 | steps: 52 | - name: Checkout repository 53 | uses: actions/checkout@v4 54 | with: 55 | # We must fetch at least the immediate parents so that if this is 56 | # a pull request then we can checkout the head. 57 | fetch-depth: 2 58 | 59 | # Initializes the CodeQL tools for scanning. 60 | - name: Initialize CodeQL 61 | uses: github/codeql-action/init@v3 62 | with: 63 | languages: python 64 | 65 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 66 | # If this step fails, then you should remove it and run the build manually (see below) 67 | - name: Autobuild 68 | uses: github/codeql-action/autobuild@v3 69 | 70 | # ℹ️ Command-line programs to run using the OS shell. 71 | # 📚 https://git.io/JvXDl 72 | 73 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 74 | # and modify them (or add more) to build your code if your project 75 | # uses a compiled language 76 | 77 | #- run: | 78 | # make bootstrap 79 | # make release 80 | 81 | - name: Perform CodeQL Analysis 82 | uses: github/codeql-action/analyze@v3 83 | 84 | ossarscan: 85 | name: OSSAR-Scan 86 | # OSSAR runs on windows-latest. 87 | # ubuntu-latest and macos-latest support coming soon 88 | runs-on: windows-latest 89 | 90 | steps: 91 | # Checkout your code repository to scan 92 | - name: Checkout repository 93 | uses: actions/checkout@v4 94 | with: 95 | # We must fetch at least the immediate parents so that if this is 96 | # a pull request then we can checkout the head. 97 | fetch-depth: 2 98 | 99 | # If this run was triggered by a pull request event, then checkout 100 | # the head of the pull request instead of the merge commit. 101 | - run: git checkout HEAD^2 102 | if: ${{ github.event_name == 'pull_request' }} 103 | 104 | # Ensure a compatible version of dotnet is installed. 105 | # The [Microsoft Security Code Analysis CLI](https://aka.ms/mscadocs) is built with dotnet v3.1.201. 106 | # A version greater than or equal to v3.1.201 of dotnet must be installed on the agent in order to run this action. 107 | # Remote agents already have a compatible version of dotnet installed and this step may be skipped. 108 | # For local agents, ensure dotnet version 3.1.201 or later is installed by including this action: 109 | # - name: Install .NET 110 | # uses: actions/setup-dotnet@v1 111 | # with: 112 | # dotnet-version: '3.1.x' 113 | 114 | # Run open source static analysis tools 115 | - name: Run OSSAR 116 | uses: github/ossar-action@v1 117 | id: ossar 118 | 119 | # Upload results to the Security tab 120 | - name: Upload OSSAR results 121 | uses: github/codeql-action/upload-sarif@v3 122 | with: 123 | sarif_file: ${{ steps.ossar.outputs.sarifFile }} 124 | -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | defaults: 13 | run: 14 | shell: bash -euo pipefail {0} 15 | 16 | jobs: 17 | build: 18 | name: Create and upload release 19 | runs-on: ubuntu-latest 20 | outputs: 21 | tag_name: ${{ env.TAG_NAME }} 22 | app_name: ${{ env.APP_NAME }} 23 | should_release: ${{ env.SHOULD_RELEASE || 'false' }} 24 | steps: 25 | 26 | - name: "[ PREPARE ] Set up Python" 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.x" 30 | 31 | - name: "[ PREPARE ] Install pypa/build" 32 | run: python3 -m pip install build --user 33 | 34 | - name: "[ PREPARE ] Get current version" 35 | uses: oprypin/find-latest-tag@v1 36 | with: 37 | repository: ${{ github.repository }} # The repository to scan. 38 | releases-only: true # We know that all relevant tags have a GitHub release for them. 39 | id: dkb_robo_ver # The step ID to refer to later. 40 | 41 | - name: "[ PREPARE ] Checkout code" 42 | uses: actions/checkout@v4 43 | 44 | - name: "[ PREPARE ] Retrieve version" 45 | run: | 46 | python3 -m pip install . 47 | echo TAG_NAME=$(python3 -c "import dkb_robo; print(dkb_robo.__version__)") >> $GITHUB_ENV 48 | echo APP_NAME=$(echo ${{ github.repository }} | awk -F / '{print $2}') >> $GITHUB_ENV 49 | 50 | - name: "[ PREPARE ] Check version difference" 51 | run: | 52 | if [ "${{ steps.dkb_robo_ver.outputs.tag }}" != "${{ env.TAG_NAME }}" ]; then 53 | echo "SHOULD_RELEASE=true" >> $GITHUB_ENV 54 | else 55 | echo "SHOULD_RELEASE=false" >> $GITHUB_ENV 56 | fi 57 | 58 | - run: | 59 | echo "Repo is at version ${{ steps.dkb_robo_ver.outputs.tag }}" 60 | echo "APP tag is ${{ env.APP_NAME }}" 61 | echo "Latest tag is ${{ env.TAG_NAME }}" 62 | echo "should_release is ${{ env.SHOULD_RELEASE }}" 63 | 64 | - name: "[ BUILD ] Create Release" 65 | id: create_release 66 | if: env.SHOULD_RELEASE == 'true' 67 | uses: actions/create-release@v1 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | tag_name: ${{ env.TAG_NAME }} 72 | release_name: ${{ env.APP_NAME }} ${{ env.TAG_NAME }} 73 | body: | 74 | [Changelog](CHANGES.md) 75 | draft: false 76 | prerelease: false 77 | 78 | - name: "[ BUILD ] Build a binary wheel and a source tarball" 79 | id: create_package 80 | if: env.SHOULD_RELEASE == 'true' 81 | run: python3 -m build 82 | 83 | - name: "[ UPLOAD ] Upload package artifacts" 84 | if: env.SHOULD_RELEASE == 'true' 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: dist 88 | path: dist 89 | 90 | upload-to-pypi: 91 | needs: build 92 | runs-on: ubuntu-latest 93 | if: needs.build.outputs.should_release == 'true' 94 | # Restricting the upload to the pypi GitHub environment enforces a security policy 95 | # requiring explicit approval of the upload by the maintainer. 96 | environment: pypi 97 | permissions: 98 | # This permission is mandatory for trusted publishing 99 | id-token: write 100 | steps: 101 | - name: "[ DOWNLOAD ] Download package artifacts" 102 | uses: actions/download-artifact@v4 103 | with: 104 | name: dist 105 | path: dist 106 | 107 | - name: "[ UPLOAD ] Publish package" 108 | uses: pypa/gh-action-pypi-publish@release/v1 109 | 110 | create-sbom: 111 | needs: [build, upload-to-pypi] 112 | runs-on: ubuntu-latest 113 | if: needs.build.outputs.should_release == 'true' 114 | steps: 115 | - name: "[ PREPARE ] Checkout code" 116 | uses: actions/checkout@v4 117 | 118 | - name: "[ PREPARE ] Retrieve SBOM repo" 119 | run: | 120 | git clone https://$GH_SBOM_USER:$GH_SBOM_TOKEN@github.com/$GH_SBOM_USER/sbom /tmp/sbom 121 | env: 122 | GH_SBOM_USER: ${{ secrets.GH_SBOM_USER }} 123 | GH_SBOM_TOKEN: ${{ secrets.GH_SBOM_TOKEN }} 124 | 125 | - name: "[ BUILD ] virtual environment" 126 | run: | 127 | python3 -m venv /tmp/dkbroboenv 128 | source /tmp/dkbroboenv/bin/activate 129 | python3 -m pip install . 130 | python3 -m pip freeze > /tmp/requirements_freeze.txt 131 | cat /tmp/requirements_freeze.txt 132 | deactivate 133 | 134 | - name: "[ BUILD ] create SBOM" 135 | run: | 136 | mkdir -p /tmp/sbom/sbom/dkb-robo 137 | cp /tmp/requirements_freeze.txt /tmp/sbom/sbom/dkb-robo/dkb-robo_sbom.txt 138 | python3 -m pip install cyclonedx-bom 139 | python3 -m cyclonedx_py environment -v --pyproject pyproject.toml --mc-type library --output-reproducible --output-format xml --outfile /tmp/sbom/sbom/dkb-robo/dkb-robo_sbom.xml /tmp/dkbroboenv 140 | python3 -m cyclonedx_py environment -v --pyproject pyproject.toml --mc-type library --output-reproducible --output-format json --outfile /tmp/sbom/sbom/dkb-robo/dkb-robo_sbom.json /tmp/dkbroboenv 141 | 142 | - name: "[ BUILD ] Upload SBOM" 143 | run: | 144 | cd /tmp/sbom 145 | git config --global user.email "grindelsack@gmail.com" 146 | git config --global user.name "SBOM Generator" 147 | git add sbom/dkb-robo/ 148 | git commit -a -m "SBOM update" 149 | git push 150 | -------------------------------------------------------------------------------- /.github/workflows/markdown_check.yml: -------------------------------------------------------------------------------- 1 | # workflow to run the acme2certifier unittest suite 2 | 3 | name: Markdown Link check 4 | 5 | on: 6 | push: 7 | pull_request: 8 | branches: [ devel ] 9 | schedule: 10 | - cron: '0 2 * * 6' 11 | 12 | jobs: 13 | markdown-link-check: 14 | # runs-on: ubuntu-latest 15 | runs-on: ubuntu-22.04 16 | steps: 17 | - uses: actions/checkout@master 18 | - uses: umbrelladocs/action-linkspector@v1 19 | - name: Lint changelog file root 20 | uses: avto-dev/markdown-lint@v1 21 | with: 22 | args: '*.md' 23 | -------------------------------------------------------------------------------- /.github/workflows/test_lint.yml: -------------------------------------------------------------------------------- 1 | name: Test and lint 2 | on: 3 | push: 4 | pull_request: 5 | branches: [ devel ] 6 | schedule: 7 | - cron: '0 2 * * 6' 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | # Keep in sync with the Python Trove classifiers in pyproject.toml. 19 | python_version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 20 | name: Test with Python (${{ matrix.python_version }}) 21 | 22 | steps: 23 | - name: Checkout source code 24 | uses: actions/checkout@v4 25 | 26 | - name: Set up Python ${{ matrix.python_version }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python_version }} 30 | allow-prereleases: true 31 | 32 | - name: Install package 33 | run: python -m pip install .[test] 34 | 35 | - name: Run tests 36 | run: python3 -m pytest 37 | 38 | - name: Upload coverage reports to Codecov 39 | uses: codecov/codecov-action@v4.5.0 40 | with: 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | flags: unittests 43 | 44 | lint: 45 | runs-on: ubuntu-latest 46 | name: Lint 47 | 48 | steps: 49 | - name: Checkout source code 50 | uses: actions/checkout@v4 51 | 52 | - name: Set up Python 53 | uses: actions/setup-python@v5 54 | with: 55 | python-version: "3.x" 56 | 57 | - name: Install linters 58 | run: python3 -m pip install pylint pylint-exit pycodestyle 59 | 60 | - name: Lint with pylint 61 | run: | 62 | pylint --rcfile=".github/pylintrc" dkb_robo/ || pylint-exit $? 63 | 64 | - name: Lint with pycodestyle 65 | run: | 66 | pycodestyle --max-line-length=380 dkb_robo/. 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | # lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | # mystuff 102 | /dkb_devel.py 103 | /dkb_scrap.py 104 | /dkb_check.py 105 | /wa_hack_cli.py 106 | /dkb_test.py 107 | /*.html 108 | /*.json 109 | # /*.txt 110 | foo*/* 111 | coverage.lcov 112 | settings.json 113 | og_weekly.py 114 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-added-large-files 10 | - id: check-case-conflict 11 | - id: check-docstring-first 12 | - id: check-json 13 | - id: check-merge-conflict 14 | - id: check-symlinks 15 | - id: check-toml 16 | - id: check-xml 17 | - id: check-yaml 18 | - id: debug-statements 19 | # - id: double-quote-string-fixer 20 | - id: mixed-line-ending 21 | 22 | - repo: https://github.com/psf/black 23 | rev: 22.10.0 24 | hooks: 25 | - id: black 26 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | 2 | # dkb-robo changelog 3 | 4 | This is a high-level summary of the most important changes. For a full list of changes, see the [git commit log](https://github.com/grindsa/dkb-robo/commits) and pick the appropriate release branch. 5 | 6 | # Changes in 0.31 7 | 8 | **Bugfixes**: 9 | 10 | - [#86](https://github.com/grindsa/dkb-robo/pull/86) - Fix unfiltered JSON output format 11 | - [#87](https://github.com/grindsa/dkb-robo/pull/87) - Add missing id field to AccountTransactionItem 12 | 13 | # Changes in 0.30.1 14 | 15 | No functional changes 16 | 17 | **Improvements**: 18 | 19 | - Introduction of [pre-commit](https://pre-commit.com/) 20 | - Cleanup release workflow/adding a trusted publisher 21 | 22 | # Changes in 0.30 23 | 24 | **Bugfixes**: 25 | 26 | - [#82](https://github.com/grindsa/dkb-robo/pull/82) - support of sha512 checksums during postbox download 27 | - [#80](https://github.com/grindsa/dkb-robo/issues/80#issuecomment-2781317228) - improved handling of checksum mismatches 28 | - [#80](https://github.com/grindsa/dkb-robo/issues/80#issuecomment-2792808878) - support for additiona keywords in data_classes 29 | 30 | # Changes in 0.29.4 31 | 32 | **Bugfixes**: 33 | 34 | - [#80](https://github.com/grindsa/dkb-robo/issues/80) - Download documents without date field in metadata 35 | 36 | # Changes in 0.29.3 37 | 38 | **Bugfixes**: 39 | 40 | - [#79](https://github.com/grindsa/dkb-robo/issues/79) - prevent mapping of **NonType in various dataclasses 41 | 42 | # Changes in 0.29.2 43 | 44 | **Bugfixes**: 45 | 46 | - [#78](https://github.com/grindsa/dkb-robo/issues/78) - Transaction does not return correct peer information 47 | 48 | # Changes in 0.29.1 49 | 50 | **Bugfixes**: 51 | 52 | - [#77](https://github.com/grindsa/dkb-robo/pull/77) - Transaction filter returns no entries 53 | - replace findAll with find_all in legacy.py 54 | 55 | # Changes in 0.29 56 | 57 | **Improvements**: 58 | 59 | - Refactor library in separate classes to improve understandability and maintainability 60 | - [unfiltered mode](doc/unfiltered.md) 61 | 62 | **Bugfixes**: 63 | 64 | - [#72](https://github.com/grindsa/dkb-robo/pull/72) - Update base path of legacy URLs 65 | - [#71](https://github.com/grindsa/dkb-robo/pull/72) - Fix prepend_date logic 66 | 67 | # Changes in 0.28.2 68 | 69 | **Bugfixes**: 70 | 71 | - [#74](https://github.com/grindsa/dkb-robo/pull/74) - fix CLI transaction command - CSV output 72 | 73 | # Changes in 0.28.1 74 | 75 | **Bugfixes**: 76 | 77 | - [#68](https://github.com/grindsa/dkb-robo/pull/68) - fix merge conflict in .gitattributes 78 | 79 | # Changes in 0.28 80 | 81 | **Improvements**: 82 | 83 | - [#66](https://github.com/grindsa/dkb-robo/pull/65) - Special handling of depot related file names 84 | - [#65](https://github.com/grindsa/dkb-robo/pull/65) - add support for additional document types 85 | - [#63](https://github.com/grindsa/dkb-robo/pull/63) - CLI - rename `PATH` env variable to `DKB_DOC_PATH` 86 | 87 | **Bugfixes**: 88 | 89 | - [#64](https://github.com/grindsa/dkb-robo/pull/64) - CLI - fix non-chip-tan default option 90 | - [#62](https://github.com/grindsa/dkb-robo/issues/62) - add missing transaction attributes for brokerage account 91 | 92 | # Changes in 0.27.1 93 | 94 | **Improvements**: 95 | 96 | - [#61](https://github.com/grindsa/dkb-robo/issues/61) filename sanitizing 97 | 98 | # Changes in 0.27 99 | 100 | **Improvements**: 101 | 102 | - [#58](https://github.com/grindsa/dkb-robo/issues/58) drop support of the old frontent (leagcy_mode) 103 | - [#56](https://github.com/grindsa/dkb-robo/issues/56) add support for [chipTAN manuell and chipTAN QR] 104 | - [#48](https://github.com/grindsa/dkb-robo/issues/48) fetch transactions older than one year 105 | - scan-postbox option in dkb-robo cli 106 | 107 | # Changes in 0.26 108 | 109 | **Improvements**: 110 | 111 | - sort mfa devices by enrollment date with "main" device listed first 112 | - allow preselection of mfa device by using the `mfa_device` parameter in context-handler 113 | 114 | # Changes in 0.25 115 | 116 | **Improvements**: 117 | 118 | - [#50](https://github.com/grindsa/dkb-robo/issues/50) refactoring for better code readability 119 | 120 | # Changes in 0.24.1 121 | 122 | **Bugfixes**: 123 | 124 | - [#49](https://github.com/grindsa/dkb-robo/issues/49) - adding Mandatsreferenz to transcation dictionary 125 | 126 | # Changes in 0.24 127 | 128 | **Bugfixes**: 129 | 130 | - #47 - refactor `DKBRobo._build_account_dic()` to reflect changes in rest-responses 131 | - do not show transactoin link for debitcards 132 | - several fixes to keep ordering as shown in UI 133 | - [fix](https://github.com/grindsa/dkb-robo/issues/47#issuecomment-1751807028) to allow future transaction dates in new API 134 | 135 | **Improvements**: 136 | 137 | - show accounts which are not assigned to a group 138 | - show debitcards in DKBRobo.account_dic 139 | - extend credit/debitcard information by status (blocked/active) and expiry date 140 | 141 | # Changes in 0.23.2 142 | 143 | **Bugfixes**: 144 | 145 | - #46 - full fix for `tan_insert` option 146 | 147 | # Changes in 0.23.1 148 | 149 | **Improvements**: 150 | 151 | - #45 - CLI support of the new API 152 | - CLI option `-l` to use the old frontend 153 | - support date input in `%Y-%m-%d` format 154 | - all dates will be returned in `%Y-%m-%d` format when using the new API 155 | 156 | **Bufixes**: 157 | 158 | - #46 - `tan_insert` option enforces the useage of old frontend 159 | - CLI option `-l` enforces the useage of old frontend 160 | 161 | # Changes in 0.23 162 | 163 | **Improvements**: 164 | 165 | - get_transactions() and get_overview() methods are using the new API 166 | 167 | # Changes in 0.22 168 | 169 | **Bugfixes**: 170 | 171 | - [41] - link changes at DKB portal 172 | 173 | # Changes in 0.21 - beta 174 | 175 | **Improvements**: 176 | 177 | - suppport for the new DKB frontend (experimental). To use the new frontent create dkb context manager as shown below. 178 | 179 | ```python 180 | with DKBRobo(DKB_USER, DKB_PASSWORD, TAN, legacy_login=False) as dkb: 181 | ... 182 | ``` 183 | 184 | # Chagens in 0.21 185 | 186 | **Features**: 187 | 188 | - [[#39](https://github.com/grindsa/dkb-robo/issues/39) support new DKB frontend] 189 | 190 | # Changes in 0.20.1 191 | 192 | **Bugfixes**: 193 | 194 | - #38 `long_description` field in PyPI 195 | 196 | ## Changes in 0.20 197 | 198 | **Features**: 199 | 200 | - [#36](https://github.com/grindsa/dkb-robo/pull/36) dkb_robo CLI tool 201 | 202 | ## Changes in 0.19.1 203 | 204 | **Bugfixes**: 205 | 206 | - addressing code smells reported by [sonarcloud.io](https://sonarcloud.io/summary/overall?id=grindsa_dkb-robo) 207 | 208 | ## Changes in 0.19 209 | 210 | **Features**: 211 | 212 | - [#34](https://github.com/grindsa/dkb-robo/pull/34) Add date into filename when scanning postbox 213 | 214 | ## Changes in 0.18.2 215 | 216 | **Bugfixes**: 217 | 218 | - addressing code smells reported by [sonarcloud.io](https://sonarcloud.io/summary/overall?id=grindsa_dkb-robo) 219 | 220 | ## Changes in 0.18.1 221 | 222 | **Bugfixes**: 223 | 224 | - [sonarcloud.io](https://sonarcloud.io/summary/overall?id=grindsa_dkb-robo) badges in Readme.md 225 | - security issues reported by sonarcube 226 | 227 | ## Changes in 0.18 228 | 229 | **Bugfixes**: 230 | 231 | - [#32](https://github.com/grindsa/dkb-robo/issues/32) German Umlaut in filename 232 | 233 | ## Changes in 0.17 234 | 235 | **Bugfixes**: 236 | 237 | - #30 - handle attribute errors in case of empty documentlist 238 | - #31 - avoid overrides in case of duplicate document names 239 | 240 | ## Changes in 0.16 241 | 242 | **Features**: 243 | 244 | - #29 - add method to retrieve depot status 245 | 246 | **Improvements**: 247 | 248 | - convert numbers to float wherever possible 249 | 250 | ## Changes in 0.15 251 | 252 | - obsolete version.py to address [#28](https://github.com/grindsa/dkb-robo/pull/28) 253 | 254 | ## Changes in 0.14 255 | 256 | **Features**: 257 | 258 | - [reserved transactions ("vorgemerke Buchungen") support](https://github.com/grindsa/dkb-robo/pull/26/files) 259 | 260 | ## Changes in 0.13.1 261 | 262 | **Improvements**: 263 | 264 | - [date_from/date_to validation against minimal date](https://github.com/grindsa/dkb-robo/issues/25) 265 | 266 | ## Changes in 0.13 267 | 268 | **Features**: 269 | 270 | - add amount `amount_original` ("ursprünglicher Betrag") field to creditcard transaction list 271 | 272 | **Improvements**: 273 | 274 | - adding code-review badge from [lgtm.com](https://lgtm.com/projects/g/grindsa/dkb-robo/?mode=list) 275 | - some smaller fixes to address [lgtm code-review comments](https://lgtm.com/projects/g/grindsa/dkb-robo/alerts/?mode=list) 276 | 277 | ## Changes in 0.12 278 | 279 | **Features**: 280 | 281 | - support of the new [DKB-app](https://play.google.com/store/apps/details?id=com.dkbcodefactory.banking) 282 | 283 | **Improvements**: 284 | 285 | - underscore-prefixed Class-local references 286 | 287 | ## Changes in 0.11 288 | 289 | **Improvements**: 290 | 291 | - scan_postbox() is able to scan and download the Archiv-Folder 292 | - removed python2 support 293 | - better error handling (raise Exceptions instead of sys.exit) 294 | - use of `logging` module for debug messages 295 | - [date_from/date_to validation](https://github.com/grindsa/dkb-robo/issues/22) 296 | - additional unittests 297 | - pep8 conformance validation 298 | - unittest coverage measurement via [codecov.io](https://app.codecov.io/gh/grindsa/dkb-robo) 299 | 300 | **Bugfixes**: 301 | 302 | - ExemptionOrder Link corrected 303 | 304 | ## Changes in 0.10.7 305 | 306 | **Improvements**: 307 | 308 | - output of scan_postbox() contains path and filename of the downloaded file 309 | 310 | ## Changes in 0.10.5 311 | 312 | **Improvements**: 313 | 314 | - fix release summing-up smaller improvements from last few months 315 | 316 | - harmonized workflows 317 | - code-scanning via CodeQL and OSSAR 318 | - modifications due to pylint error messages 319 | 320 | ## Changes in 0.10.4 321 | 322 | **Improvements**: 323 | 324 | - pypi packaging added to create_release workflow 325 | 326 | ## Changes in 0.10.3 327 | 328 | **Bugfixes***: 329 | 330 | - daf35c9 improved checking account detection (DKB removed the "Cash im Shop" link) 331 | - d40200e0 fix for faulty TAN handling reported in #18 332 | 333 | ## Changes in 0.10.2 334 | 335 | **Bugfixes***: 336 | 337 | - 14ba22c7 fix for "Kontoauszugsdownload" 338 | 339 | ## Changes in 0.10 340 | 341 | **Features**: 342 | 343 | - Ability do download documents from DKB Postbox 344 | - Tan2go support 345 | 346 | ## Changes in 0.9 347 | 348 | **Features**: 349 | 350 | - MFA support limited to confirmation via DKB-app and TANs generated by ChipTan method 351 | - Debug mode 352 | 353 | ## Changes in 0.8.3 354 | 355 | **Bugfixes**: 356 | 357 | - Some smaller bug-fixes in dkb_robo and unittests 358 | 359 | ## Changes in 0.8.2 360 | 361 | **Bugfixes**: 362 | 363 | - fix typos - thanks to tbm 364 | 365 | ## Changes in 0.8.1 366 | 367 | **Features**: 368 | 369 | - Transactions for checking accounts and credit cares are based on CSV file downloadable from website 370 | - Support of non dkb-accounts in account overview and transaction list 371 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing 3 | 4 | When contributing to this repository, please first discuss the change you wish to make via issue, 5 | email, or any other method with the owners of this repository before making a change. 6 | 7 | Please note we have a code of conduct, please follow it in all your interactions with the project. 8 | 9 | ## Pull Request Process 10 | 11 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 12 | build. 13 | 2. Update the README.md with details of changes to the interface, this includes new environment 14 | variables, exposed ports, useful file locations and container parameters. 15 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 16 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 17 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 18 | do not have permission to do that, you may request the second reviewer to merge it for you. 19 | 20 | ## Code of Conduct 21 | 22 | ### Our Pledge 23 | 24 | In the interest of fostering an open and welcoming environment, we as 25 | contributors and maintainers pledge to making participation in our project and 26 | our community a harassment-free experience for everyone, regardless of age, body 27 | size, disability, ethnicity, gender identity and expression, level of experience, 28 | nationality, personal appearance, race, religion, or sexual identity and 29 | orientation. 30 | 31 | ### Our Standards 32 | 33 | Examples of behavior that contributes to creating a positive environment 34 | include: 35 | 36 | - Using welcoming and inclusive language 37 | - Being respectful of differing viewpoints and experiences 38 | - Gracefully accepting constructive criticism 39 | - Focusing on what is best for the community 40 | - Showing empathy towards other community members 41 | 42 | Examples of unacceptable behavior by participants include: 43 | 44 | - The use of sexualized language or imagery and unwelcome sexual attention or 45 | advances 46 | - Trolling, insulting/derogatory comments, and personal or political attacks 47 | - Public or private harassment 48 | - Publishing others' private information, such as a physical or electronic 49 | address, without explicit permission 50 | - Other conduct which could reasonably be considered inappropriate in a 51 | professional setting 52 | 53 | ### Our Responsibilities 54 | 55 | Project maintainers are responsible for clarifying the standards of acceptable 56 | behavior and are expected to take appropriate and fair corrective action in 57 | response to any instances of unacceptable behavior. 58 | 59 | Project maintainers have the right and responsibility to remove, edit, or 60 | reject comments, commits, code, wiki edits, issues, and other contributions 61 | that are not aligned to this Code of Conduct, or to ban temporarily or 62 | permanently any contributor for other behaviors that they deem inappropriate, 63 | threatening, offensive, or harmful. 64 | 65 | ### Scope 66 | 67 | This Code of Conduct applies both within project spaces and in public spaces 68 | when an individual is representing the project or its community. Examples of 69 | representing a project or community include using an official project e-mail 70 | address, posting via an official social media account, or acting as an appointed 71 | representative at an online or offline event. Representation of a project may be 72 | further defined and clarified by project maintainers. 73 | 74 | ### Enforcement 75 | 76 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 77 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 78 | complaints will be reviewed and investigated and will result in a response that 79 | is deemed necessary and appropriate to the circumstances. The project team is 80 | obligated to maintain confidentiality with regard to the reporter of an incident. 81 | Further details of specific enforcement policies may be posted separately. 82 | 83 | Project maintainers who do not follow or enforce the Code of Conduct in good 84 | faith may face temporary or permanent repercussions as determined by other 85 | members of the project's leadership. 86 | 87 | ### Attribution 88 | 89 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 90 | available at [http://contributor-covenant.org/version/1/4][version] 91 | 92 | [homepage]: http://contributor-covenant.org 93 | [version]: http://contributor-covenant.org/version/1/4/ 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # dkb-robo 3 | 4 | ![GitHub release](https://img.shields.io/github/release/grindsa/dkb-robo.svg) 5 | ![GitHub last commit (branch)](https://img.shields.io/github/last-commit/grindsa/dkb-robo/master.svg?label=last%20commit%20into%20master) 6 | ![GitHub last commit (branch)](https://img.shields.io/github/last-commit/grindsa/dkb-robo/devel.svg?label=last%20commit%20into%20devel) 7 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/dkb-robo) 8 | 9 | [![Codecov main](https://img.shields.io/codecov/c/gh/grindsa/dkb-robo/branch/master?label=test%20coverage%20master)](https://app.codecov.io/gh/grindsa/dkb-robo/branch/master) 10 | [![Codecov devel](https://img.shields.io/codecov/c/gh/grindsa/dkb-robo/branch/devel?label=test%20coverage%20devel)](https://app.codecov.io/gh/grindsa/dkb-robo/branch/devel) 11 | 12 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=grindsa_dkb-robo&metric=security_rating)](https://sonarcloud.io/summary/overall?id=grindsa_dkb-robo) 13 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=grindsa_dkb-robo&metric=sqale_rating)](https://sonarcloud.io/summary/overall?id=grindsa_dkb-robo) 14 | [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=grindsa_dkb-robo&metric=reliability_rating)](https://sonarcloud.io/summary/overall?id=grindsa_dkb-robo) 15 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=grindsa_dkb-robo&metric=alert_status)](https://sonarcloud.io/summary/overall?id=grindsa_dkb-robo) 16 | 17 | dkb-robo is a python library to access the internet banking area of ["Deutsche Kreditbank"](https://banking.dkb.de) to fetch 18 | 19 | - account information and current balances 20 | - transactions from creditcards and checking accounts (Girokonten) 21 | - query the content of "DKB Postbox" 22 | - get standing orders (Dauerauftrag) 23 | - get information about credit limits and exemption orders (Freistellungsauftrag) 24 | 25 | Starting from version 0.9 dkb-robo can handle the 2nd factor DKB introduced to fulfill the [PSD2 obligations](https://en.wikipedia.org/wiki/Payment_Services_Directive). Starting from September 2019 logins must be confirmed by either 26 | 27 | - the blue [DKB-Banking app](https://play.google.com/store/apps/details?id=com.dkbcodefactory.banking) 28 | - Insertion of a TAN created by the [DKB TAN2Go app](https://play.google.com/store/apps/details?id=com.starfinanz.mobile.android.dkbpushtan) 29 | 30 | The introduction of a 2nd factor does limit the usage of dkb-robo for automation purposes. DKB is unfortunately ~~not willing/ not able~~ not allowed to open their PSD2-API for non-Fintechs. I discussed this with them for weeks at some point they stopped responding to my emails so I gave up. 31 | 32 | DKB introduced a new web-frontend in July 2023 which is using a REST-API as backend. The migration to the new REST endpoints started with v0.22 and has been completed with v0.27. 33 | 34 | ## Getting Started 35 | 36 | These instructions will get you a copy of the project up and running on your local machine. 37 | 38 | ### Prerequisites 39 | 40 | To run dkb-robo on your system you need 41 | 42 | - [Python](https://www.python.org) 43 | - [mechanicalsoup](https://github.com/MechanicalSoup/MechanicalSoup) - Stateful programmatic web browsing library 44 | - [cookielib](https://docs.python.org/2/library/cookielib.html) - library for Cookie handling for HTTP clients 45 | - [Beautiful Soup](https://www.crummy.com/software/BeautifulSoup/) - a Python library for pulling data out of HTML and XML files. 46 | 47 | Please make sure python and all the above modules had been installed successfully before you start any kind of testing. 48 | 49 | ### Installing 50 | 51 | #### via Pypi 52 | 53 | ```bash 54 | > pip install dkb_robo 55 | ``` 56 | 57 | #### manually for all users 58 | 59 | 1. download the archive and unpack it 60 | 2. enter the directory and run the setup script 61 | 62 | ```bash 63 | > python setup.py install 64 | ``` 65 | 66 | #### manually for a single user 67 | 68 | 1. download the archive and unpack it 69 | 2. move the "dkb_robo" subfolder into the directory your script is located 70 | 71 | #### SBOM 72 | 73 | [A bill of material](https://www.linuxfoundation.org/blog/blog/what-is-an-sbom) of the packages coming along wiht `dkb-robo` will be automatically created during build process and stored in [my SBOM respository](https://github.com/grindsa/sbom/tree/main/sbom/dkb-robo) 74 | 75 | ### Usage 76 | 77 | you need to import dkb-robo into your script 78 | 79 | ```python 80 | > from dkb_robo import DKBRobo 81 | ``` 82 | 83 | create a new DKBRobo context handler and login to DKB portal 84 | 85 | ```python 86 | > with DKBRobo(dkb_user=, dkb_password=, chip_tan=True|False|qr, mfa_device=, debug=True|False, unfiltered=True|False) as dkb: 87 | ``` 88 | 89 | - dbk_user: username to access the dkb portal 90 | - dkb_password: corresponding login password 91 | - chip_tan: (True/**False**/qr) TAN usage - when not "False" dbk-robo will ask for a TAN during login. So far this library only supports ["chipTAN manuell" and "chipTAN QR](https://www.dkb.de/fragen-antworten/was-ist-das-chiptan-verfahren). "qr" foces the usage of "chipTAN QR" all other values will trigger the usage of "chipTAN Manuell" 92 | - mfa_device: ('m'/Integer) optional - preselect MFA device to be used for 2nd factor - 'm' - main device, otherwise number from device-list 93 | - debug: (True/**False**) Debug mode 94 | - unfiltered: (True/**False**) [Unfiltered mode](doc/unfiltered.md) 95 | 96 | After login you can return a dictionary containing a list of your accounts, the actual balance and a link to fetch the transactions 97 | 98 | ```python 99 | from pprint import pprint 100 | pprint(dkb.account_dic) 101 | {0: { 102 | 'amount': '1458.00', 103 | 'currencycode': 'EUR', 104 | 'date': '22.01.2023', 105 | 'holdername': 'Firstname Lastname', 106 | 'iban': 'DEXXXXXXXXXXXXXXXXXXXXX', 107 | 'id': 'xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', 108 | 'limit': '2500.00', 109 | 'name': 'Girokonto', 110 | 'productgroup': 'Meine Konten', 111 | 'transactions': 'https://banking.dkb.de/api/accounts/accounts/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/transactions', 112 | 'type': 'account'}, 113 | 1: { 114 | 'amount': -1000.23, 115 | 'currencycode': 'EUR', 116 | 'date': '22.01.2023', 117 | 'holdername': 'Firstname Lastname', 118 | 'id': 'xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', 119 | 'limit': '2000.00', 120 | 'maskedpan': '1234XXXXXXXX5678', 121 | 'name': 'Visa CC', 122 | 'productgroup': 'Meine Konten', 123 | 'transactions': 'https://banking.dkb.de/api/credit-card/cards/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/transactions', 124 | 'type': 'creditcard'}, 125 | 2: { 126 | 'amount': 100000.23, 127 | 'currencycode': 'EUR', 128 | 'date': '22.01.2023', 129 | 'holdername': 'Firstname lastname', 130 | 'id': 'xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', 131 | 'limit': '0.00', 132 | 'maskedpan': '5678XXXXXXXX1234', 133 | 'name': 'Another Visa', 134 | 'productgroup': 'Meine Konten', 135 | 'transactions': 'https://banking.dkb.de/api/credit-card/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/transactions', 136 | 'type': 'creditcard'}, 137 | 3: { 138 | 'amount': '123456,79', 139 | 'currencycode': 'EUR', 140 | 'holdername': 'Firstname Lastname', 141 | 'id': 'xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', 142 | 'name': 'Mein Depot', 143 | 'productgroup': 'Meine Konten', 144 | 'transactions': 'https://banking.dkb.de/api/broker/brokerage-accounts/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx(positions?include=instrument%2Cquote', 145 | 'type': 'depot'}} 146 | ``` 147 | 148 | to get the list of transactions for a certain checking account or a credit card use the following method 149 | 150 | ```python 151 | tlist = dkb.get_transactions(link, type, date_from, date_to) 152 | ``` 153 | 154 | - link - link to get transactions for a specific account - see former step if you do not know how to get it 155 | - type - account type (either "account" or "creditcard") - see former step if you do not know how to get it 156 | - date_from - start date in European notation (DD.MM.YYYY) 157 | - date_to - end date in European notation (DD.MM.YYYY) 158 | - transaction_type - optional: "booked" (default if not specified) or "pending" ("vorgemerkt") 159 | 160 | this method returns a list of transactions. 161 | 162 | A list of transactions for a regular checking account follows the below format. 163 | 164 | ```python 165 | from pprint import pprint 166 | pprint(tlist) 167 | [{'amount': -44.98, 168 | 'bdate': '2023-01-22', 169 | 'currencycode': 'EUR', 170 | 'customerreferenz': 'XXXXXX', 171 | 'peer': 'PayPal Europe S.a.r.l. et Cie S.C.A', 172 | 'peeraccount': 'XXXXXXXXX', 173 | 'peerbic': 'XXXXXXXXX', 174 | 'peerid': 'XXXXXXXXXXX', 175 | 'postingtext': 'FOLGELASTSCHRIFT', 176 | 'reasonforpayment': 'XXXXXX PP.XXXXX.PP . Foo-bar AG, Ihr Einkauf bei ' 177 | 'Foo-bar AG', 178 | 'vdate': '2023-01-22'}, 179 | {'amount': -70.05, 180 | 'bdate': '2023-01-22', 181 | 'currencycode': 'EUR', 182 | 'customerreferenz': '68251782022947180823144926', 183 | 'peer': 'FEFASE GmbH', 184 | 'peeraccount': 'XXXXXXXXX', 185 | 'peerbic': 'XXXXXXXXX', 186 | 'peerid': 'XXXXXXXXX', 187 | 'postingtext': 'SEPA-ELV-LASTSCHRIFT', 188 | 'reasonforpayment': 'ELV68251782 18.08 14.49 MEFAS ', 189 | 'vdate': '2023-01-22'}, 190 | {'amount': -7.49, 191 | 'bdate': '2023-01-22', 192 | 'currencycode': 'EUR', 193 | 'customerreferenz': '3REFeSERENC', 194 | 'peer': 'PEER', 195 | 'peeraccount': 'XXXXXXXXX', 196 | 'peerbic': 'XXXXXXXXX', 197 | 'peerid': 'XXXXXXXXX', 198 | 'postingtext': 'FOLGELASTSCHRIFT', 199 | 'reasonforpayment': 'VIELEN DANK VON BAR-FOO GMBH', 200 | 'vdate': '2023-01-22'}] 201 | ``` 202 | 203 | The list of transactions from a creditcard will look as below: 204 | 205 | ```python 206 | [{'amount': 500.0, 207 | 'bdate': '2023-08-18', 208 | 'currencycode': 'EUR', 209 | 'text': 'Berliner Sparkasse', 210 | 'vdate': '2023-08-18'}, 211 | {'amount': 125.95, 212 | 'bdate': '2023-08-14', 213 | 'currencycode': 'EUR', 214 | 'text': 'Zara Deutschland 3742', 215 | 'vdate': '2023-08-14'}, 216 | {'amount': 500.0, 217 | 'bdate': '2023-08-14', 218 | 'currencycode': 'EUR', 219 | 'text': 'Commerzbank Berlin', 220 | 'vdate': '2023-08-14'}] 221 | ``` 222 | 223 | A brokerage account (depot) will not show the list of transactions but rather a list of positions: 224 | 225 | ```python 226 | [{'currencycode': 'EUR', 227 | 'isin_wkn': 'DE0005140008', 228 | 'lastorderdate': '2017-01-01', 229 | 'market': 'Frankfurt', 230 | 'price': 9.872, 231 | 'price_euro': '39488.00', 232 | 'quantity': 4000.0, 233 | 'shares_unit': 'pieces', 234 | 'text': 'DEUTSCHE BANK AG NA O.N.'}, 235 | {'currencycode': 'EUR', 236 | 'isin_wkn': 'DE0005557508', 237 | 'lastorderdate': '2017-10-01', 238 | 'market': 'Frankfurt', 239 | 'price': 19.108, 240 | 'price_euro': '28.662.00', 241 | 'quantity': 1500.0, 242 | 'shares_unit': 'pieces', 243 | 'text': 'DT.TELEKOM AG NA'}] 244 | ``` 245 | 246 | to get the credit limits per account or credit-card the method get_credit_limits() must be used 247 | 248 | ```python 249 | > c_list = dkb.get_credit_limits() 250 | ``` 251 | 252 | This method returns a dictionary of all identified accounts including the credit limit per account 253 | 254 | ```python 255 | {u'XXXX********XXXX': 100.00, 256 | u'4748********XXXX': 10000.00, 257 | u'XXXX********XXXX': 10000.00, 258 | u'DEXX XXXX XXXX XXXX XXXX XX': 200.00, 259 | u'DEXX XXXX XXXX XXXX XXXX XX': 2000.00} 260 | ``` 261 | 262 | A list of standing orders (Daueraufträge) can be obtained by calling get_standing_orders() method 263 | 264 | ```python 265 | > so = dkb.get_standing_orders(account_id) 266 | ``` 267 | 268 | - account_id - 'id' field from account dictionary (`dkb.account_dic[x]['id']`) 269 | 270 | A list of standing orders will be returned containing a dictionary per standing order 271 | 272 | ```python 273 | > pprint(so) 274 | [{'amount': 30.0, 275 | 'creditoraccount': {'bic': 'BIC-1', 'iban': 'IBAN-1'}, 276 | 'currencycode': 'EUR', 277 | 'interval': {'frequency': 'monthly', 278 | 'from': '2019-01-05', 279 | 'holidayExecutionStrategy': 'following', 280 | 'nextExecutionAt': '2023-10-01', 281 | 'until': '2025-12-01'}, 282 | 'purpose': 'Purpose-1', 283 | 'recpipient': 'Recipient-1'}, 284 | {'amount': 58.0, 285 | 'creditoraccount': {'bic': 'BIC-2', 'iban': 'IBAN-2'}, 286 | 'currencycode': 'EUR', 287 | 'interval': {'frequency': 'monthly', 288 | 'from': '2022-12-30', 289 | 'holidayExecutionStrategy': 'following', 290 | 'nextExecutionAt': '2023-12-01'}, 291 | 'purpose': 'Purpose-2', 292 | 'recpipient': 'Recipient-2'},] 293 | ``` 294 | 295 | The method get_exemption_order() can be used to get the exemption orders (Freistellungsaufträge) 296 | 297 | ```python 298 | > exo = dkb.get_exemption_order() 299 | ``` 300 | 301 | A dictionary similar to the one below will be returned 302 | 303 | ```python 304 | > pprint(exo) 305 | [{'amount': 2000.0, 306 | 'currencycode': 'EUR', 307 | 'partner': {'dateofbirth': '1980-01-01', 308 | 'firstname': 'Jane', 309 | 'lastname': 'Doe', 310 | 'salutation': 'Frau', 311 | 'taxid': '1234567890'}, 312 | 'receivedat': '2017-01-01', 313 | 'type': 'joint', 314 | 'used': 567.89, 315 | 'validfrom': '2020-01-01', 316 | 'validto': '9999-12-31'}] 317 | ``` 318 | 319 | To get the amount of dkb points the below method can be used 320 | 321 | ```python 322 | > points_dic = dkb.get_points() 323 | ``` 324 | 325 | A dictionary similar to the below will be returned 326 | 327 | ```python 328 | > pprint(points_dic) 329 | {u'DKB-Punkte': 99999, 330 | u'davon verfallen zum 31.12.2018': 999} 331 | ``` 332 | 333 | To scan the DKB postbox for documents the below method can be used 334 | 335 | ```python 336 | > document_dic = dkb.scan_postbox(path, download_all, archive, prepend_date) 337 | ``` 338 | 339 | - path - optional argument. If specified, documents will be downloaded and stored 340 | - dowload_all (True/**False**) - optional argument. By default only unread documents from DKB postbox will get downloaded and marked as "read". By setting this parameter all documents will be downloaded 341 | - archive (True/**False**) - optional argument. When set to `True` the "Archiv" folder in the Postbox will be scanned and documents will be downloaded if a `path` variable is specificed. *Handle this parameter with care as the amount of documents to be downloaded can be huge*. 342 | - prepend_date (True/**False**) - optional argument. Prepend document date in `YYYY-MM-DD_` format to each document to allow easy sorting of downloaded files 343 | 344 | The method will return a dictionary containing the different postbox folders and links to download the corresponding documents 345 | 346 | Check the scripts [dkb_example.py](doc/dkb_example.py) and [dkb_docdownload.py](doc/dkb_docdownload.py) for further examples. 347 | 348 | ## dkb_robo command line interface (CLI) 349 | 350 | Starting with v0.20 dkb_robo comes with a CLI tool 351 | 352 | ```bash 353 | $ dkb --help 354 | Usage: dkb [OPTIONS] COMMAND [ARGS]... 355 | 356 | Options: 357 | -d, --debug Show additional debugging 358 | -t, --chip-tan TEXT use [ChipTan](https://www.dkb.de/fragen-antworten/was-ist-das-chiptan-verfahren) for login ("qr" for chipTan-QR "manual" for chipTan-manuell) 359 | -u, --username TEXT username to access the dkb portal 360 | [required] 361 | -p, --password TEXT corresponding login password 362 | --format [pprint|table|csv|json] 363 | output format to use 364 | --help Show this message and exit. 365 | 366 | Commands: 367 | accounts 368 | credit-limits 369 | download 370 | scan-postbox 371 | last-login 372 | standing-orders 373 | transactions 374 | ``` 375 | 376 | ### Example command to fetch account list 377 | 378 | ```bash 379 | py dkb -u -p accounts 380 | ``` 381 | 382 | ### Example commands to fetch transactions via CLI tool 383 | 384 | ```bash 385 | py dkb -u -p transactions --name Girokonto 386 | py dkb -u -p transactions --account "DE75xxxxxxxxxxxxxxxxxxx" 387 | py dkb -u -p transactions --account "DE75xxxxxxxxxxxxxxxxxxx" --date-from 2023-08-01 --date-to 2023-08-15" 388 | ``` 389 | 390 | ## Further documentation 391 | 392 | please check the [doc](https://github.com/grindsa/dkb-robo/tree/master/doc) folder of the project. You will find further documentation and an example scripts of all dkb-robo methods there. 393 | 394 | ## Contributing 395 | 396 | Please read [CONTRIBUTING.md](https://github.com/grindsa/dkb-robo/blob/master/CONTRIBUTING.md) for details on my code of conduct, and the process for submitting pull requests. 397 | Please note that I have a life besides programming. Thus, expect a delay in answering. 398 | 399 | ## Versioning 400 | 401 | I use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/grindsa/dkb-robo/tags). 402 | 403 | ## License 404 | 405 | This project is licensed under the GPLv3 - see the [LICENSE.md](https://github.com/grindsa/dkb-robo/blob/master/LICENSE) file for details 406 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Security Policy 4 | 5 | ## Supported Versions 6 | 7 | Only the last version is under support. Please keep your environement updated. 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Please report security vulnerabilities directly to and provide the following information: 12 | 13 | - A summary of the problem 14 | - Used software version and deployment mode 15 | - The actual behavior 16 | - The expected behavior 17 | - Steps to replicate the problem (if they there are any) 18 | - Debug logs from dkb-robo 19 | 20 | Preferred-Languages: en, de 21 | -------------------------------------------------------------------------------- /dkb_robo/__init__.py: -------------------------------------------------------------------------------- 1 | """init""" 2 | 3 | from importlib.metadata import version 4 | 5 | from .dkb_robo import DKBRobo, DKBRoboError 6 | 7 | __author__ = "GrindSa" 8 | __version__ = version("dkb_robo") 9 | -------------------------------------------------------------------------------- /dkb_robo/__main__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c0114, e0401 2 | import cli # pragma: no cover 3 | 4 | cli.main() # pragma: no cover 5 | -------------------------------------------------------------------------------- /dkb_robo/cli.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c3001, e1101, r0913, w0108, w0622 2 | """ dkb_robo cli """ 3 | from datetime import date 4 | from pathlib import Path 5 | import pathlib 6 | from pprint import pprint 7 | import sys 8 | import csv 9 | import json 10 | import tabulate 11 | import click 12 | import dkb_robo 13 | from dkb_robo.utilities import object2dictionary 14 | 15 | sys.path.append("..") 16 | 17 | DATE_FORMAT = "%d.%m.%Y" 18 | DATE_FORMAT_ALTERNATE = "%Y-%m-%d" 19 | 20 | 21 | def _account_lookup(ctx, name, account, account_dic, unfiltered): 22 | """lookup account""" 23 | 24 | mapping_matrix = { 25 | "account": "iban", 26 | "creditCard": "maskedPan", 27 | "debitCard": "maskedPan", 28 | "depot": "depositAccountId", 29 | "brokerageAccount": "depositAccountId", 30 | } 31 | if name is not None and account is None: 32 | if unfiltered: 33 | 34 | def account_filter(acct): 35 | product = getattr(acct, "product", None) 36 | return getattr(product, "displayName", None) == name 37 | 38 | else: 39 | 40 | def account_filter(acct): 41 | return acct["name"] == name 42 | 43 | elif account is not None and name is None: 44 | if unfiltered: 45 | 46 | def account_filter(acct): 47 | return getattr(acct, mapping_matrix[acct.type]) == account 48 | 49 | else: 50 | 51 | def account_filter(acct): 52 | return acct["account"] == account 53 | 54 | else: 55 | raise click.UsageError("One of --name or --account must be provided.", ctx) 56 | 57 | filtered_accounts = [acct for acct in account_dic.values() if account_filter(acct)] 58 | if len(filtered_accounts) == 0: 59 | click.echo(f"No account found matching '{name or account}'", err=True) 60 | raise click.Abort() 61 | 62 | return filtered_accounts[0] 63 | 64 | 65 | def _id_lookup(ctx, name, account, account_dic, unfiltered): 66 | """lookup id""" 67 | the_account = _account_lookup(ctx, name, account, account_dic, unfiltered) 68 | if unfiltered: 69 | uid = getattr(the_account, "id", None) 70 | else: 71 | uid = the_account["id"] 72 | return uid 73 | 74 | 75 | def _transactionlink_lookup(ctx, name, account, account_dic, unfiltered): 76 | """lookup id""" 77 | the_account = _account_lookup(ctx, name, account, account_dic, unfiltered) 78 | 79 | if unfiltered: 80 | output_dic = { 81 | "id": getattr(the_account, "id", None), 82 | "type": getattr(the_account, "type", None), 83 | "transactions": getattr(the_account, "transactions", None), 84 | } 85 | else: 86 | output_dic = { 87 | "id": the_account.get("id", None), 88 | "type": the_account.get("type", None), 89 | "transactions": the_account.get("transactions", None), 90 | } 91 | return output_dic 92 | 93 | 94 | @click.group() 95 | @click.option( 96 | "--debug", 97 | "-d", 98 | default=False, 99 | help="Show additional debugging", 100 | is_flag=True, 101 | envvar="DKB_DEBUG", 102 | ) 103 | @click.option( 104 | "--unfiltered", 105 | default=False, 106 | is_flag=True, 107 | envvar="DKB_UNFILTERED", 108 | help="Do not filter output from DKB API", 109 | ) 110 | @click.option( 111 | "--mfa-device", 112 | "-m", 113 | default=None, 114 | help='MFA device used for login ("1", "2" ...)', 115 | type=int, 116 | envvar="MFA_DEVICE", 117 | ) 118 | @click.option( 119 | "--use-tan", 120 | default=False, 121 | is_flag=True, 122 | envvar="DKB_USE_TAN", 123 | hidden=True, 124 | ) 125 | @click.option( 126 | "--chip-tan", 127 | "-t", 128 | default=None, 129 | help='use ChipTAN for login (either "qr" or "manual")', 130 | type=str, 131 | envvar="DKB_CHIP_TAN", 132 | ) 133 | @click.option( 134 | "--username", 135 | "-u", 136 | required=True, 137 | type=str, 138 | help="username to access the dkb portal", 139 | envvar="DKB_USERNAME", 140 | ) 141 | @click.option( 142 | "--password", 143 | "-p", 144 | prompt=True, 145 | hide_input=True, 146 | type=str, 147 | help="corresponding login password", 148 | envvar="DKB_PASSWORD", 149 | ) 150 | @click.option( 151 | "--format", 152 | default="pprint", 153 | type=click.Choice(["pprint", "table", "csv", "json"]), 154 | help="output format to use", 155 | envvar="DKB_FORMAT", 156 | ) 157 | @click.pass_context 158 | def main( 159 | ctx, debug, unfiltered, mfa_device, use_tan, chip_tan, username, password, format 160 | ): # pragma: no cover 161 | """main fuunction""" 162 | 163 | if use_tan: 164 | click.echo( 165 | "The --use-tan option is deprecated and will be removed in a future release. Please use the --chip-tan option", 166 | err=True, 167 | ) 168 | chip_tan = True 169 | ctx.ensure_object(dict) 170 | ctx.obj["DEBUG"] = debug 171 | ctx.obj["UNFILTERED"] = unfiltered 172 | ctx.obj["CHIP_TAN"] = chip_tan 173 | ctx.obj["MFA_DEVICE"] = mfa_device 174 | ctx.obj["USERNAME"] = username 175 | ctx.obj["PASSWORD"] = password 176 | ctx.obj["FORMAT"] = _load_format(format) 177 | 178 | 179 | @main.command() 180 | @click.pass_context 181 | def accounts(ctx): 182 | """get list of account""" 183 | try: 184 | with _login(ctx) as dkb: 185 | accounts_dict = dkb.account_dic 186 | for id, value in accounts_dict.items(): 187 | if ctx.obj["UNFILTERED"]: 188 | value = object2dictionary(value) 189 | accounts_dict[id] = value 190 | if "details" in value: 191 | del value["details"] 192 | if "transactions" in value: 193 | del value["transactions"] 194 | ctx.obj["FORMAT"](list(accounts_dict.values())) 195 | except dkb_robo.DKBRoboError as _err: 196 | click.echo(_err.args[0], err=True) 197 | 198 | 199 | @main.command() 200 | @click.pass_context 201 | @click.option( 202 | "--name", 203 | "-n", 204 | type=str, 205 | help="Name of the account to fetch transactions for", 206 | envvar="DKB_TRANSACTIONS_ACCOUNT_NAME", 207 | ) 208 | @click.option( 209 | "--account", 210 | "-a", 211 | type=str, 212 | help="Account to fetch transactions for", 213 | envvar="DKB_TRANSACTIONS_ACCOUNT", 214 | ) 215 | @click.option( 216 | "--transaction-type", 217 | "-t", 218 | default="booked", 219 | type=click.Choice(["booked", "reserved"]), 220 | help="The type of transactions to fetch", 221 | envvar="DKB_TRANSACTIONS_TYPE", 222 | ) 223 | @click.option( 224 | "--date-from", 225 | type=click.DateTime(formats=[DATE_FORMAT, DATE_FORMAT_ALTERNATE]), 226 | default=date.today().strftime(DATE_FORMAT), 227 | ) 228 | @click.option( 229 | "--date-to", 230 | type=click.DateTime(formats=[DATE_FORMAT, DATE_FORMAT_ALTERNATE]), 231 | default=date.today().strftime(DATE_FORMAT), 232 | ) 233 | def transactions( 234 | ctx, name, account, transaction_type, date_from, date_to 235 | ): # pragma: no cover 236 | """get list of transactions""" 237 | 238 | try: 239 | with _login(ctx) as dkb: 240 | the_account = _transactionlink_lookup( 241 | ctx, name, account, dkb.account_dic, ctx.obj["UNFILTERED"] 242 | ) 243 | transactions_list = dkb.get_transactions( 244 | the_account["transactions"], 245 | the_account["type"], 246 | date_from.strftime(DATE_FORMAT), 247 | date_to.strftime(DATE_FORMAT), 248 | transaction_type=transaction_type, 249 | ) 250 | ctx.obj["FORMAT"](transactions_list) 251 | 252 | except dkb_robo.DKBRoboError as _err: 253 | click.echo(_err.args[0], err=True) 254 | 255 | 256 | @main.command() 257 | @click.pass_context 258 | def last_login(ctx): 259 | """get last login""" 260 | try: 261 | with _login(ctx) as dkb: 262 | ctx.obj["FORMAT"]([{"last_login": dkb.last_login}]) 263 | except dkb_robo.DKBRoboError as _err: 264 | click.echo(_err.args[0], err=True) 265 | 266 | 267 | @main.command() 268 | @click.pass_context 269 | def credit_limits(ctx): 270 | """get limits""" 271 | try: 272 | with _login(ctx) as dkb: 273 | limits = dkb.get_credit_limits() 274 | limits = [{"account": k, "limit": v} for k, v in limits.items()] 275 | ctx.obj["FORMAT"](limits) 276 | except dkb_robo.DKBRoboError as _err: 277 | click.echo(_err.args[0], err=True) 278 | 279 | 280 | @main.command() 281 | @click.pass_context 282 | @click.option( 283 | "--name", 284 | "-n", 285 | type=str, 286 | help="Name of the account to fetch transactions for", 287 | envvar="DKB_TRANSACTIONS_ACCOUNT_NAME", 288 | ) 289 | @click.option( 290 | "--account", 291 | "-a", 292 | type=str, 293 | help="Account to fetch transactions for", 294 | envvar="DKB_TRANSACTIONS_ACCOUNT", 295 | ) 296 | def standing_orders(ctx, name, account): # pragma: no cover 297 | """get standing orders""" 298 | try: 299 | with _login(ctx) as dkb: 300 | uid = _id_lookup(ctx, name, account, dkb.account_dic, ctx.obj["UNFILTERED"]) 301 | so_list = dkb.get_standing_orders(uid) 302 | standing_orders_list = [] 303 | for so in so_list: 304 | if ctx.obj["UNFILTERED"]: 305 | standing_orders_list.append(object2dictionary(so)) 306 | else: 307 | standing_orders_list.append(so) 308 | ctx.obj["FORMAT"](standing_orders_list) 309 | except dkb_robo.DKBRoboError as _err: 310 | click.echo(_err.args[0], err=True) 311 | 312 | 313 | @main.command() 314 | @click.pass_context 315 | @click.option( 316 | "--path", 317 | "-p", 318 | type=str, 319 | help="Path to save the documents to", 320 | envvar="DKB_DOC_PATH", 321 | ) 322 | @click.option( 323 | "--download_all", 324 | is_flag=True, 325 | show_default=True, 326 | default=False, 327 | help="Download all documents", 328 | envvar="DKB_DOWNLOAD_ALL", 329 | ) 330 | @click.option( 331 | "--archive", 332 | is_flag=True, 333 | show_default=True, 334 | default=False, 335 | help="Download archive", 336 | envvar="DKB_ARCHIVE", 337 | ) 338 | @click.option( 339 | "--prepend_date", 340 | is_flag=True, 341 | show_default=True, 342 | default=False, 343 | help="Prepend date to filename", 344 | envvar="DKB_PREPEND_DATE", 345 | ) 346 | def scan_postbox(ctx, path, download_all, archive, prepend_date): 347 | """scan postbox""" 348 | if not path: 349 | path = "documents" 350 | try: 351 | with _login(ctx) as dkb: 352 | doc_list = dkb.scan_postbox( 353 | path=path, download_all=download_all, prepend_date=prepend_date 354 | ) 355 | if ctx.obj["UNFILTERED"]: 356 | documents_list = [] 357 | for doc in doc_list.values(): 358 | documents_list.append(object2dictionary(doc)) 359 | else: 360 | documents_list = doc_list 361 | ctx.obj["FORMAT"](documents_list) 362 | except dkb_robo.DKBRoboError as _err: 363 | click.echo(_err.args[0], err=True) 364 | 365 | 366 | @main.command() 367 | @click.pass_context 368 | @click.option( 369 | "--path", 370 | "-p", 371 | type=click.Path(writable=True, path_type=pathlib.Path), 372 | help="Path to save the documents to", 373 | envvar="DKB_DOC_PATH", 374 | ) 375 | @click.option( 376 | "--all", 377 | "-A", 378 | is_flag=True, 379 | show_default=True, 380 | default=False, 381 | help="Download all documents", 382 | envvar="DKB_DOWNLOAD_ALL", 383 | ) 384 | @click.option( 385 | "--prepend-date", 386 | is_flag=True, 387 | show_default=True, 388 | default=False, 389 | help="Prepend date to filename", 390 | envvar="DKB_PREPEND_DATE", 391 | ) 392 | @click.option( 393 | "--mark-read", 394 | is_flag=True, 395 | show_default=True, 396 | default=True, 397 | help="Mark downloaded files read", 398 | envvar="DKB_MARK_READ", 399 | ) 400 | @click.option( 401 | "--use-account-folders", 402 | is_flag=True, 403 | show_default=True, 404 | default=False, 405 | help="Store files in separate folders per account/depot", 406 | envvar="DKB_ACCOUNT_FOLDERS", 407 | ) 408 | @click.option( 409 | "--list-only", 410 | is_flag=True, 411 | show_default=True, 412 | default=False, 413 | help="Only list documents, do not download", 414 | envvar="DKB_LIST_ONLY", 415 | ) 416 | def download( 417 | ctx, 418 | path: Path, 419 | all: bool, 420 | prepend_date: bool, 421 | mark_read: bool, 422 | use_account_folders: bool, 423 | list_only: bool, 424 | ): 425 | """download document""" 426 | if path is None: 427 | list_only = True 428 | try: 429 | with _login(ctx) as dkb: 430 | ctx.obj["FORMAT"]( 431 | dkb.download( 432 | path=path, 433 | download_all=all, 434 | prepend_date=prepend_date, 435 | mark_read=mark_read, 436 | use_account_folders=use_account_folders, 437 | list_only=list_only, 438 | ) 439 | ) 440 | except dkb_robo.DKBRoboError as _err: 441 | click.echo(_err.args[0], err=True) 442 | 443 | 444 | class DataclassJSONEncoder(json.JSONEncoder): 445 | def default(self, obj): 446 | if dataclasses.is_dataclass(obj): 447 | return dataclasses.asdict(obj) 448 | return super().default(obj) 449 | 450 | 451 | def _load_format(output_format): 452 | """select output format based on cli option""" 453 | if output_format == "pprint": 454 | return lambda data: pprint(data) 455 | 456 | if output_format == "table": 457 | return lambda data: click.echo( 458 | tabulate.tabulate(data, headers="keys", tablefmt="grid") 459 | ) 460 | 461 | if output_format == "csv": 462 | 463 | def formatter(data): # pragma: no cover 464 | if len(data) == 0: 465 | return 466 | writer = csv.DictWriter(sys.stdout, fieldnames=max(data, key=len).keys()) 467 | writer.writeheader() 468 | writer.writerows(data) 469 | 470 | return formatter 471 | 472 | if output_format == "json": 473 | 474 | return lambda data: click.echo( 475 | json.dumps(data, indent=2, cls=DataclassJSONEncoder) 476 | ) 477 | 478 | raise ValueError(f"Unknown format: {output_format}") 479 | 480 | 481 | def _login(ctx): 482 | return dkb_robo.DKBRobo( 483 | dkb_user=ctx.obj["USERNAME"], 484 | dkb_password=ctx.obj["PASSWORD"], 485 | chip_tan=ctx.obj["CHIP_TAN"], 486 | debug=ctx.obj["DEBUG"], 487 | unfiltered=ctx.obj["UNFILTERED"], 488 | mfa_device=ctx.obj["MFA_DEVICE"], 489 | ) 490 | -------------------------------------------------------------------------------- /dkb_robo/dkb_robo.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=c0415, r0913 2 | """ dkb internet banking automation library """ 3 | # -*- coding: utf-8 -*- 4 | from pathlib import Path 5 | import time 6 | from dkb_robo.postbox import PostBox 7 | from dkb_robo.authentication import Authentication 8 | from dkb_robo.exemptionorder import ExemptionOrders 9 | from dkb_robo.standingorder import StandingOrders 10 | from dkb_robo.transaction import Transactions 11 | from dkb_robo.utilities import logger_setup, validate_dates, get_dateformat 12 | 13 | 14 | LEGACY_DATE_FORMAT, API_DATE_FORMAT = get_dateformat() 15 | 16 | 17 | class DKBRoboError(Exception): 18 | """dkb-robo exception class""" 19 | 20 | 21 | class DKBRobo(object): 22 | """dkb_robo class""" 23 | 24 | # pylint: disable=R0904 25 | legacy_login = False 26 | dkb_user = None 27 | dkb_password = None 28 | proxies = {} 29 | last_login = None 30 | mfa_device = 0 31 | account_dic = {} 32 | tan_insert = False 33 | chip_tan = False 34 | logger = None 35 | wrapper = None 36 | unfiltered = False 37 | 38 | def __init__( 39 | self, 40 | dkb_user=None, 41 | dkb_password=None, 42 | tan_insert=False, 43 | legacy_login=False, 44 | debug=False, 45 | mfa_device=None, 46 | chip_tan=False, 47 | unfiltered=False, 48 | ): 49 | self.dkb_user = dkb_user 50 | self.dkb_password = dkb_password 51 | self.chip_tan = chip_tan 52 | self.tan_insert = tan_insert 53 | self.legacy_login = legacy_login 54 | self.logger = logger_setup(debug) 55 | self.mfa_device = mfa_device 56 | self.unfiltered = unfiltered 57 | 58 | def __enter__(self): 59 | """Makes DKBRobo a Context Manager""" 60 | # tan usage requires legacy login 61 | if self.tan_insert: 62 | self.logger.info( 63 | "tan_insert is a legacy login option and will be disabled soon. Please use chip_tan instead" 64 | ) 65 | self.chip_tan = True 66 | 67 | if self.legacy_login: 68 | raise DKBRoboError( 69 | "Legacy Login got deprecated. Please do not use this option anymore" 70 | ) 71 | 72 | if self.mfa_device == "m": 73 | self.mfa_device = 1 74 | 75 | self.wrapper = Authentication( 76 | dkb_user=self.dkb_user, 77 | dkb_password=self.dkb_password, 78 | proxies=self.proxies, 79 | chip_tan=self.chip_tan, 80 | mfa_device=self.mfa_device, 81 | unfiltered=self.unfiltered, 82 | ) 83 | 84 | # login and get the account overview 85 | (self.account_dic, self.last_login) = self.wrapper.login() 86 | 87 | return self 88 | 89 | def __exit__(self, *args): 90 | """Close the connection at the end of the context""" 91 | self.wrapper.logout() 92 | 93 | def _accounts_by_id(self): 94 | self.logger.debug("DKBRobo._accounts_by_id()\n") 95 | 96 | if self.unfiltered: 97 | accounts_by_id = {} 98 | for acc in self.wrapper.account_dic.values(): 99 | uid = getattr(acc, "id", None) 100 | if getattr(acc, "iban", None): 101 | accounts_by_id[uid] = acc.iban 102 | elif getattr(acc, "maskedPan", None): 103 | accounts_by_id[uid] = acc.maskedPan 104 | elif getattr(acc, "depositAccountId", None): 105 | accounts_by_id[uid] = acc.depositAccountId 106 | else: 107 | accounts_by_id = { 108 | acc["id"]: acc["account"] for acc in self.wrapper.account_dic.values() 109 | } 110 | 111 | self.logger.debug( 112 | "DKBRobo._accounts_by_id(): returned %s elements\n", 113 | len(accounts_by_id.keys()), 114 | ) 115 | return accounts_by_id 116 | 117 | def get_credit_limits(self): 118 | """create a dictionary of credit limits of the different accounts""" 119 | self.logger.debug("DKBRobo.get_credit_limits()\n") 120 | 121 | limit_dic = {} 122 | for _aid, account_data in self.account_dic.items(): 123 | if "limit" in account_data: 124 | if "iban" in account_data: 125 | limit_dic[account_data["iban"]] = account_data["limit"] 126 | elif "maskedpan" in account_data: 127 | limit_dic[account_data["maskedpan"]] = account_data["limit"] 128 | 129 | return limit_dic 130 | 131 | def get_exemption_order(self): 132 | """get get_exemption_order""" 133 | self.logger.debug("DKBRobo.get_exemption_order()\n") 134 | exemptionorder = ExemptionOrders( 135 | client=self.wrapper.client, unfiltered=self.unfiltered 136 | ) 137 | return exemptionorder.fetch() 138 | 139 | def get_points(self): 140 | """returns the DKB points""" 141 | self.logger.debug("DKBRobo.get_points()\n") 142 | raise DKBRoboError("Method not supported...") 143 | 144 | def get_standing_orders(self, uid=None): 145 | """get standing orders""" 146 | self.logger.debug("DKBRobo.get_standing_orders()\n") 147 | standingorder = StandingOrders( 148 | client=self.wrapper.client, unfiltered=self.unfiltered 149 | ) 150 | return standingorder.fetch(uid) 151 | 152 | def get_transactions( 153 | self, transaction_url, atype, date_from, date_to, transaction_type="booked" 154 | ): 155 | """exported method to get transactions""" 156 | self.logger.debug( 157 | "DKBRobo.get_transactions(%s/%s: %s/%s)\n", 158 | transaction_url, 159 | atype, 160 | date_from, 161 | date_to, 162 | ) 163 | 164 | (date_from, date_to) = validate_dates(date_from, date_to) 165 | transaction = Transactions( 166 | client=self.wrapper.client, unfiltered=self.unfiltered 167 | ) 168 | transaction_list = transaction.get( 169 | transaction_url, atype, date_from, date_to, transaction_type 170 | ) 171 | 172 | self.logger.debug( 173 | "DKBRobo.get_transactions(): %s transactions returned\n", 174 | len(transaction_list), 175 | ) 176 | return transaction_list 177 | 178 | def scan_postbox( 179 | self, path=None, download_all=False, _archive=False, prepend_date=False 180 | ): 181 | """scan posbox and return document dictionary""" 182 | self.logger.debug("DKBRobo.scan_postbox()\n") 183 | return self.download( 184 | Path(path) if path is not None else None, download_all, prepend_date 185 | ) 186 | 187 | def download_doc( 188 | self, 189 | path: Path, 190 | doc, 191 | prepend_date: bool = False, 192 | mark_read: bool = True, 193 | use_account_folders: bool = False, 194 | list_only: bool = False, 195 | accounts_by_id: dict = None, 196 | ): 197 | """download a single document""" 198 | target = path / doc.category() 199 | 200 | if use_account_folders: 201 | target = target / doc.account(card_lookup=accounts_by_id) 202 | 203 | filename = f"{doc.date()}_{doc.filename()}" if prepend_date else doc.filename() 204 | 205 | if not list_only: 206 | self.logger.info('Downloading "%s" to %s...', doc.subject(), target) 207 | 208 | download_rcode = doc.download(self.wrapper.client, target / filename) 209 | if download_rcode: 210 | if mark_read: 211 | doc.mark_read(self.wrapper.client, True) 212 | time.sleep(0.5) 213 | doc.rcode = download_rcode 214 | else: 215 | self.logger.info("File already exists. Skipping %s.", filename) 216 | doc.rcode = "skipped" 217 | 218 | def format_doc( 219 | self, 220 | path, 221 | documents, 222 | use_account_folders: bool = False, 223 | prepend_date: bool = False, 224 | accounts_by_id: dict = None, 225 | ): 226 | """format documents""" 227 | 228 | document_dic = {} 229 | for doc in documents.values(): 230 | category = doc.category() 231 | 232 | if category not in document_dic: 233 | document_dic[category] = {"documents": {}, "count": 0} 234 | 235 | target = path / category 236 | if use_account_folders: 237 | target = target / doc.account(card_lookup=accounts_by_id) 238 | filename = ( 239 | f"{doc.date()}_{doc.filename()}" if prepend_date else doc.filename() 240 | ) 241 | 242 | document_dic[category]["documents"][doc.message.subject] = { 243 | "id": doc.id, 244 | "date": doc.document.creationDate, 245 | "link": doc.document.link, 246 | "fname": str(target / filename), 247 | "rcode": doc.rcode, 248 | } 249 | document_dic[category]["count"] += 1 250 | return document_dic 251 | 252 | def download( 253 | self, 254 | path: Path, 255 | download_all: bool, 256 | prepend_date: bool = False, 257 | mark_read: bool = True, 258 | use_account_folders: bool = False, 259 | list_only: bool = False, 260 | ): 261 | """download postbox documents""" 262 | if path is None: 263 | list_only = True 264 | postbox = PostBox(client=self.wrapper.client) 265 | documents = postbox.fetch_items() 266 | if not download_all: 267 | # only unread documents 268 | documents = { 269 | id: item 270 | for id, item in documents.items() 271 | if item.message and item.message.read is False 272 | } 273 | 274 | # create dictionary to map accounts to their respective iban/maskedpan 275 | accounts_by_id = self._accounts_by_id() 276 | if not list_only: 277 | # download the documents if required 278 | for doc in documents.values(): 279 | self.download_doc( 280 | path=path, 281 | doc=doc, 282 | prepend_date=prepend_date, 283 | mark_read=mark_read, 284 | use_account_folders=use_account_folders, 285 | list_only=list_only, 286 | accounts_by_id=accounts_by_id, 287 | ) 288 | 289 | # format the documents 290 | if not self.unfiltered: 291 | documents = self.format_doc( 292 | path=path, 293 | documents=documents, 294 | use_account_folders=use_account_folders, 295 | prepend_date=prepend_date, 296 | accounts_by_id=accounts_by_id, 297 | ) 298 | 299 | return documents 300 | -------------------------------------------------------------------------------- /dkb_robo/exemptionorder.py: -------------------------------------------------------------------------------- 1 | """ Module for handling dkb standing orders """ 2 | from typing import Dict, List, Optional 3 | from dataclasses import dataclass 4 | import logging 5 | import requests 6 | from dkb_robo.utilities import ( 7 | Amount, 8 | DKBRoboError, 9 | Person, 10 | filter_unexpected_fields, 11 | object2dictionary, 12 | ulal, 13 | ) 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | @filter_unexpected_fields 20 | @dataclass 21 | class ExemptionOrderItem: 22 | """class for a single exemption order""" 23 | 24 | # pylint: disable=C0103 25 | exemptionAmount: Optional[str] = None 26 | exemptionOrderType: Optional[str] = None 27 | partner: Optional[str] = None 28 | receivedAt: Optional[str] = None 29 | utilizedAmount: Optional[str] = None 30 | remainingAmount: Optional[str] = None 31 | validFrom: Optional[str] = None 32 | validUntil: Optional[str] = None 33 | 34 | def __post_init__(self): 35 | self.exemptionAmount = ulal(Amount, self.exemptionAmount) 36 | self.remainingAmount = ulal(Amount, self.remainingAmount) 37 | self.utilizedAmount = ulal(Amount, self.utilizedAmount) 38 | self.partner = ulal(Person, self.partner) 39 | 40 | 41 | class ExemptionOrders: 42 | """exemption order class""" 43 | 44 | def __init__( 45 | self, 46 | client: requests.Session, 47 | unfiltered: bool = False, 48 | base_url: str = "https://banking.dkb.de/api", 49 | ): 50 | self.client = client 51 | self.base_url = base_url 52 | self.unfiltered = unfiltered 53 | 54 | def _filter(self, full_list: Dict[str, str]) -> List[Dict[str, str]]: 55 | """filter standing orders""" 56 | logger.debug("ExemptionOrders._filter()\n") 57 | 58 | unfiltered_exo_list = ( 59 | full_list.get("data", {}).get("attributes", {}).get("exemptionOrders", []) 60 | ) 61 | exo_list = [] 62 | for exo in unfiltered_exo_list: 63 | 64 | exemptionorder_obj = ExemptionOrderItem(**exo) 65 | if self.unfiltered: 66 | exo_list.append(exemptionorder_obj) 67 | else: 68 | exo_list.append( 69 | { 70 | "amount": exemptionorder_obj.exemptionAmount.value, 71 | "used": exemptionorder_obj.utilizedAmount.value, 72 | "currencycode": exemptionorder_obj.exemptionAmount.currencyCode, 73 | "validfrom": exemptionorder_obj.validFrom, 74 | "validto": exemptionorder_obj.validUntil, 75 | "receivedat": exemptionorder_obj.receivedAt, 76 | "type": exemptionorder_obj.exemptionOrderType, 77 | "partner": object2dictionary( 78 | exemptionorder_obj.partner, key_lc=True, skip_list=["title"] 79 | ), 80 | } 81 | ) 82 | 83 | logger.debug("ExemptionOrders._filter() ended with: %s entries.", len(exo_list)) 84 | return exo_list 85 | 86 | def fetch(self) -> Dict: 87 | """fetcg exemption orders from api""" 88 | logger.debug("ExemptionOrders.fetch()\n") 89 | 90 | exo_list = [] 91 | 92 | response = self.client.get(self.base_url + "/customers/me/tax-exemptions") 93 | if response.status_code == 200: 94 | _exo_list = response.json() 95 | exo_list = self._filter(_exo_list) 96 | else: 97 | raise DKBRoboError( 98 | f"fetch exemption orders: http status code is not 200 but {response.status_code}" 99 | ) 100 | 101 | logger.debug("ExemptionOrders.fetch() ended\n") 102 | return exo_list 103 | -------------------------------------------------------------------------------- /dkb_robo/postbox.py: -------------------------------------------------------------------------------- 1 | """ Module for handling the DKB postbox. """ 2 | import datetime 3 | import hashlib 4 | import logging 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | from typing import Dict, Optional, Union 8 | import requests 9 | from dkb_robo.utilities import ( 10 | get_valid_filename, 11 | filter_unexpected_fields, 12 | DKBRoboError, 13 | JSON_CONTENT_TYPE, 14 | ) 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | @filter_unexpected_fields 20 | @dataclass 21 | class Document: 22 | """Document data class, roughly based on the JSON API response.""" 23 | 24 | # pylint: disable=c0103 25 | creationDate: Optional[str] = None 26 | expirationDate: Optional[str] = None 27 | retentionPeriod: Optional[str] = None 28 | contentType: Optional[str] = None 29 | checksum: Optional[str] = None 30 | fileName: Optional[str] = None 31 | metadata: Optional[Union[Dict, str]] = None 32 | owner: Optional[str] = None 33 | link: Optional[str] = None 34 | rcode: Optional[str] = None 35 | documentTypeId: Optional[str] = None 36 | 37 | 38 | @filter_unexpected_fields 39 | @dataclass 40 | class Message: 41 | """Message data class, roughly based on the JSON API response.""" 42 | 43 | # pylint: disable=c0103 44 | archived: bool = False 45 | read: bool = False 46 | subject: Optional[str] = None 47 | documentId: Optional[str] = None 48 | documentType: Optional[str] = None 49 | creationDate: Optional[str] = None 50 | link: Optional[str] = None 51 | 52 | 53 | @filter_unexpected_fields 54 | @dataclass 55 | class PostboxItem: 56 | """Postbox item data class, merging document and message data and providing download functionality.""" 57 | 58 | DOCTYPE_MAPPING = { 59 | "bankAccountStatement": "Kontoauszüge", 60 | "creditCardStatement": "Kreditkartenabrechnungen", 61 | "dwpRevenueStatement": "Wertpapierdokumente", 62 | "dwpOrderStatement": "Wertpapierdokumente", 63 | "dwpDepotStatement": "Wertpapierdokumente", 64 | "exAnteCostInformation": "Wertpapierdokumente", 65 | "dwpCorporateActionNotice": "Wertpapierdokumente", 66 | } 67 | 68 | id: str 69 | document: Document 70 | message: Message 71 | 72 | def mark_read(self, client: requests.Session, read: bool): 73 | """Marks the document as read or unread.""" 74 | logger.debug("PostboxItem.mark_read(): set document %s to %s", self.id, read) 75 | resp = client.patch( 76 | self.message.link, 77 | json={"data": {"attributes": {"read": read}, "type": "message"}}, 78 | headers={"Accept": JSON_CONTENT_TYPE, "Content-type": JSON_CONTENT_TYPE}, 79 | ) 80 | resp.raise_for_status() 81 | 82 | def check_checsum(self, target_file: Path): 83 | logger.debug("PostboxItem.check_checsum(): %s", self.id) 84 | with target_file.open("rb") as file: 85 | if len(self.document.checksum) == 32: 86 | computed_checksum = hashlib.md5(file.read()).hexdigest() 87 | elif len(self.document.checksum) == 128: 88 | computed_checksum = hashlib.sha512(file.read()).hexdigest() 89 | else: 90 | raise DKBRoboError( 91 | f"Unsupported checksum length: {len(self.document.checksum)}, {self.document.checksum}" 92 | ) 93 | if computed_checksum != self.document.checksum: 94 | logger.warning( 95 | "Checksum mismatch for %s: %s != %s. Renaming file.", 96 | target_file, 97 | computed_checksum, 98 | self.document.checksum, 99 | ) 100 | # rename file to indicate checksum mismatch 101 | suffix = ".checksum_mismatch" 102 | if not target_file.with_name(target_file.name + suffix).exists(): 103 | # rename file to indicate checksum mismatch 104 | target_file.rename(target_file.with_name(target_file.name + suffix)) 105 | else: 106 | logger.warning( 107 | "File %s%s already exists. Not renaming.", target_file, suffix 108 | ) 109 | 110 | def download( 111 | self, client: requests.Session, target_file: Path, overwrite: bool = False 112 | ): 113 | """ 114 | Downloads the document from the provided link and saves it to the target file. 115 | 116 | :param client: The requests session to use for downloading the document. 117 | :param target_file: The path where the document should be saved. 118 | :param overwrite: Whether to overwrite the file if it already exists. 119 | :return: True if the file was downloaded and saved, False if the file already exists and overwrite is False. 120 | """ 121 | logger.debug("PostboxItem.download(): %s to %s", self.id, target_file) 122 | if not target_file.exists() or overwrite: 123 | resp = client.get( 124 | self.document.link, headers={"Accept": self.document.contentType} 125 | ) 126 | resp.raise_for_status() 127 | 128 | # create directories if necessary 129 | target_file.parent.mkdir(parents=True, exist_ok=True) 130 | 131 | with target_file.open("wb") as file: 132 | file.write(resp.content) 133 | 134 | if self.document.checksum: 135 | # compare checksums of file with checksum from document metadata 136 | self.check_checsum(target_file) 137 | 138 | return resp.status_code 139 | return False 140 | 141 | def filename(self) -> str: 142 | """Returns a sanitized filename based on the document metadata.""" 143 | logger.debug( 144 | "PostboxItem.filename(): Generating filename for document %s", self.id 145 | ) 146 | 147 | filename = self.document.fileName 148 | # Depot related files don't have meaningful filenames but only contain the document id. Hence, we use subject 149 | # instead and rely on the filename sanitization. 150 | if ( 151 | "dwpDocumentId" in self.document.metadata 152 | and "subject" in self.document.metadata 153 | ): 154 | filename = self.subject() or self.document.fileName 155 | 156 | if self.document.contentType == "application/pdf" and not filename.endswith( 157 | "pdf" 158 | ): 159 | filename = f"{filename}.pdf" 160 | 161 | fname = get_valid_filename(filename) 162 | logger.debug("PostboxItem.filename() for %s ended with %s", self.id, fname) 163 | return fname 164 | 165 | def subject(self) -> str: 166 | """Returns the subject of the message.""" 167 | return self.document.metadata.get("subject", self.message.subject) 168 | 169 | def category(self) -> str: 170 | """Returns the category of the document based on the document type.""" 171 | return PostboxItem.DOCTYPE_MAPPING.get( 172 | self.message.documentType, self.message.documentType 173 | ) 174 | 175 | def account(self, card_lookup: Dict[str, str] = None) -> str: 176 | """Returns the account number or IBAN based on the document metadata.""" 177 | logger.debug("PostboxItem.account() fom document %s", self.id) 178 | if card_lookup is None: 179 | card_lookup = {} 180 | account = None 181 | if "depotNumber" in self.document.metadata: 182 | account = self.document.metadata["depotNumber"] 183 | elif "cardId" in self.document.metadata: 184 | account = card_lookup.get( 185 | self.document.metadata["cardId"], self.document.metadata["cardId"] 186 | ) 187 | elif "iban" in self.document.metadata: 188 | account = self.document.metadata["iban"] 189 | 190 | logger.debug( 191 | "PostboxItem.account() for document %s ended with %s", self.id, account 192 | ) 193 | return account 194 | 195 | def date(self) -> str: 196 | """Returns the date of the document based on the metadata.""" 197 | logger.debug("PostboxItem.date() for document %s", self.id) 198 | date = None 199 | if "statementDate" in self.document.metadata: 200 | date = datetime.date.fromisoformat(self.document.metadata["statementDate"]) 201 | elif "statementDateTime" in self.document.metadata: 202 | date = datetime.datetime.fromisoformat( 203 | self.document.metadata["statementDateTime"] 204 | ) 205 | elif "creationDate" in self.document.metadata: 206 | date = datetime.date.fromisoformat(self.document.metadata["creationDate"]) 207 | 208 | if date is None: 209 | if "subject" in self.document.metadata: 210 | logger.error( 211 | '"%s" is missing a valid date field found in metadata. Using today\'s date as fallback.', 212 | self.document.metadata["subject"], 213 | ) 214 | else: 215 | logger.error( 216 | "No valid date field found in document metadata. Using today's date as fallback." 217 | ) 218 | date = datetime.date.today() 219 | 220 | logger.debug("PostboxItem.date() for document %s ended with %s", self.id, date) 221 | return date.strftime("%Y-%m-%d") 222 | 223 | 224 | class PostBox: 225 | """Class for handling the DKB postbox.""" 226 | 227 | BASE_URL = "https://banking.dkb.de/api/documentstorage/" 228 | 229 | # pylint: disable=w0621 230 | def __init__(self, client: requests.Session): 231 | self.client = client 232 | 233 | def fetch_items(self) -> Dict[str, PostboxItem]: 234 | """Fetches all items from the postbox and merges document and message data.""" 235 | logger.debug("PostBox.fetch_items(): Fetching messages") 236 | 237 | def __fix_link_url(url: str) -> str: 238 | # print(f'old: {url}') 239 | return url.replace( 240 | "https://api.developer.dkb.de/documentstorage/", PostBox.BASE_URL 241 | ) 242 | 243 | response = self.client.get(PostBox.BASE_URL + "/messages") 244 | response.raise_for_status() 245 | messages = response.json() 246 | 247 | logger.debug("PostBox.fetch_items(): Fetching documents") 248 | response = self.client.get(PostBox.BASE_URL + "/documents?page%5Blimit%5D=1000") 249 | response.raise_for_status() 250 | documents = response.json() 251 | 252 | if messages and documents: 253 | # Merge raw messages and documents from JSON API (left join with documents as base). 254 | items = { 255 | doc["id"]: PostboxItem( 256 | id=doc["id"], 257 | document=Document( 258 | **doc.get("attributes", {}), 259 | link=__fix_link_url(doc["links"]["self"]), 260 | ), 261 | message=None, 262 | ) 263 | for doc in documents.get("data", []) 264 | } 265 | 266 | # Add matching message data 267 | for msg in messages.get("data", []): 268 | msg_id = msg["id"] 269 | if msg_id in items: 270 | items[msg_id].message = Message( 271 | **msg.get("attributes", {}), 272 | link=__fix_link_url(msg["links"]["self"]), 273 | ) 274 | 275 | return items 276 | raise DKBRoboError("Could not fetch messages/documents.") 277 | -------------------------------------------------------------------------------- /dkb_robo/standingorder.py: -------------------------------------------------------------------------------- 1 | """ Module for handling dkb standing orders """ 2 | from typing import Dict, List, Optional, Union 3 | from dataclasses import dataclass, field 4 | import logging 5 | import requests 6 | from dkb_robo.utilities import ( 7 | DKBRoboError, 8 | Account, 9 | Amount, 10 | filter_unexpected_fields, 11 | object2dictionary, 12 | ulal, 13 | ) 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | @filter_unexpected_fields 20 | @dataclass 21 | class StandingOrderItem: 22 | """class for a single standing order""" 23 | 24 | amount: Optional[Dict] = None 25 | creditor: Optional[Union[Dict, str]] = None 26 | debtor: Optional[Union[Dict, str]] = None 27 | description: Optional[str] = None 28 | messages: List[str] = field(default_factory=list) 29 | recurrence: Optional[Union[Dict, str]] = None 30 | status: Optional[str] = None 31 | 32 | def __post_init__(self): 33 | self.amount = ulal(Amount, self.amount) 34 | self.creditor["creditorAccount"]["name"] = self.creditor.get("name", {}) 35 | self.creditor = ulal(Account, self.creditor["creditorAccount"]) 36 | if self.debtor and "debtorAccount" in self.debtor: 37 | self.debtor = ulal(Account, self.debtor["debtorAccount"]) 38 | # rewrite from - field to frm 39 | self.recurrence["frm"] = self.recurrence.get("from", None) 40 | self.recurrence = ulal(self.Recurrence, self.recurrence) 41 | 42 | @filter_unexpected_fields 43 | @dataclass 44 | class Recurrence: 45 | """class for frequency account""" 46 | 47 | # pylint: disable=C0103 48 | frm: Optional[str] = None 49 | frequency: Optional[str] = None 50 | holidayExecutionStrategy: Optional[str] = None 51 | nextExecutionAt: Optional[str] = None 52 | until: Optional[str] = None 53 | 54 | 55 | class StandingOrders: 56 | """StandingOrders class""" 57 | 58 | def __init__( 59 | self, 60 | client: requests.Session, 61 | unfiltered: bool = False, 62 | base_url: str = "https://banking.dkb.de/api", 63 | ): 64 | self.client = client 65 | self.base_url = base_url 66 | self.unfiltered = unfiltered 67 | self.uid = None 68 | 69 | def _filter(self, full_list: Dict[str, str]) -> List[Dict[str, str]]: 70 | """filter standing orders""" 71 | logger.debug("StandingOrders._filter()\n") 72 | 73 | so_list = [] 74 | if "data" in full_list: 75 | for ele in full_list["data"]: 76 | 77 | standingorder_obj = StandingOrderItem(**ele["attributes"]) 78 | 79 | if self.unfiltered: 80 | so_list.append(standingorder_obj) 81 | else: 82 | so_list.append( 83 | { 84 | "amount": standingorder_obj.amount.value, 85 | "currencycode": standingorder_obj.amount.currencyCode, 86 | "purpose": standingorder_obj.description, 87 | "recipient": standingorder_obj.creditor.name, 88 | "creditoraccount": object2dictionary( 89 | standingorder_obj.creditor, 90 | skip_list=[ 91 | "name", 92 | "accountId", 93 | "accountNr", 94 | "id", 95 | "intermediaryName", 96 | "blz", 97 | ], 98 | ), 99 | # from got rewritten in dataclase - we need to rewrite it back 100 | "interval": { 101 | **object2dictionary( 102 | standingorder_obj.recurrence, skip_list=["frm"] 103 | ), 104 | "from": standingorder_obj.recurrence.frm, 105 | }, 106 | } 107 | ) 108 | 109 | logger.debug("StandingOrders._filter() ended with: %s entries.", len(so_list)) 110 | return so_list 111 | 112 | def fetch(self, uid) -> Dict: 113 | """fetch standing orders""" 114 | logger.debug("StandingOrders.fetch()\n") 115 | 116 | so_list = [] 117 | if uid: 118 | response = self.client.get( 119 | self.base_url 120 | + "/accounts/payments/recurring-credit-transfers" 121 | + "?accountId=" 122 | + uid 123 | ) 124 | if response.status_code == 200: 125 | _so_list = response.json() 126 | so_list = self._filter(_so_list) 127 | else: 128 | raise DKBRoboError("account-id is required to fetch standing orders") 129 | 130 | logger.debug("StandingOrders.fetch() ended\n") 131 | return so_list 132 | -------------------------------------------------------------------------------- /dkb_robo/utilities.py: -------------------------------------------------------------------------------- 1 | """ miscellaneous functions """ 2 | # -*- coding: utf-8 -*- 3 | import logging 4 | from pathlib import Path 5 | import random 6 | from string import digits, ascii_letters 7 | from typing import List, Tuple, Optional 8 | from datetime import datetime, timezone 9 | from dataclasses import dataclass, fields, asdict, is_dataclass 10 | import time 11 | import re 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def get_dateformat(): 18 | """get date format""" 19 | return "%d.%m.%Y", "%Y-%m-%d" 20 | 21 | 22 | LEGACY_DATE_FORMAT, API_DATE_FORMAT = get_dateformat() 23 | JSON_CONTENT_TYPE = "application/vnd.api+json" 24 | BASE_URL = "https://banking.dkb.de/api" 25 | 26 | 27 | def filter_unexpected_fields(cls): 28 | """filter undefined fields (not defined as class variable) before import to dataclass""" 29 | original_init = cls.__init__ 30 | 31 | def new_init(self, *args, **kwargs): 32 | expected_fields = {field.name for field in fields(cls)} 33 | cleaned_kwargs = { 34 | key: value for key, value in kwargs.items() if key in expected_fields 35 | } 36 | original_init(self, *args, **cleaned_kwargs) 37 | 38 | cls.__init__ = new_init 39 | return cls 40 | 41 | 42 | @filter_unexpected_fields 43 | @dataclass 44 | class Account: 45 | """dataclass to build peer account structure""" 46 | 47 | # pylint: disable=c0103 48 | accountNr: Optional[str] = None 49 | accountId: Optional[str] = None 50 | bic: Optional[str] = None 51 | blz: Optional[str] = None 52 | iban: Optional[str] = None 53 | id: Optional[str] = None 54 | intermediaryName: Optional[str] = None 55 | name: Optional[str] = None 56 | 57 | 58 | @filter_unexpected_fields 59 | @dataclass 60 | class Amount: 61 | """Amount data class, roughly based on the JSON API response.""" 62 | 63 | # pylint: disable=c0103 64 | value: Optional[float] = None 65 | currencyCode: Optional[str] = None 66 | conversionRate: Optional[float] = None 67 | date: Optional[str] = None 68 | unit: Optional[str] = None 69 | 70 | def __post_init__(self): 71 | # convert value to float 72 | try: 73 | self.value = float(self.value) 74 | except Exception as err: 75 | logger.error("Account.__post_init: value conversion error: %s", str(err)) 76 | self.value = None 77 | if self.conversionRate: 78 | try: 79 | self.conversionRate = float(self.conversionRate) 80 | except Exception as err: 81 | logger.error( 82 | "Account.__post_init: converstionRate conversion error: %s", 83 | str(err), 84 | ) 85 | self.conversionRate = None 86 | 87 | 88 | @filter_unexpected_fields 89 | @dataclass 90 | class PerformanceValue: 91 | """PerformanceValue data class, roughly based on the JSON API response.""" 92 | 93 | # pylint: disable=c0103 94 | currencyCode: Optional[str] = None 95 | value: Optional[float] = None 96 | unit: Optional[str] = None 97 | 98 | def __post_init__(self): 99 | # convert value to float 100 | try: 101 | self.value = float(self.value) 102 | except Exception as err: 103 | logger.error( 104 | "PerformanceValue.__post_init: conversion error: %s", str(err) 105 | ) 106 | self.value = None 107 | 108 | 109 | @filter_unexpected_fields 110 | @dataclass 111 | class Person: 112 | """Person class""" 113 | 114 | # pylint: disable=c0103 115 | firstName: Optional[str] = None 116 | lastName: Optional[str] = None 117 | title: Optional[str] = None 118 | salutation: Optional[str] = None 119 | dateOfBirth: Optional[str] = None 120 | taxId: Optional[str] = None 121 | 122 | 123 | class DKBRoboError(Exception): 124 | """dkb-robo exception class""" 125 | 126 | 127 | def _convert_date_format( 128 | input_date: str, input_format_list: List[str], output_format: str 129 | ) -> str: 130 | """convert date to a specified output format""" 131 | logger.debug("_convert_date_format(%s)", input_date) 132 | 133 | output_date = None 134 | for input_format in input_format_list: 135 | try: 136 | parsed_date = datetime.strptime(input_date, input_format) 137 | # convert date 138 | output_date = parsed_date.strftime(output_format) 139 | break 140 | except Exception: 141 | logger.debug("_convert_date_format(): cannot convert date: %s", input_date) 142 | # something went wrong. we return the date we got as input 143 | continue 144 | 145 | if not output_date: 146 | output_date = input_date 147 | 148 | logger.debug("_convert_date_format() ended with: %s", output_date) 149 | return output_date 150 | 151 | 152 | def generate_random_string(length: int) -> str: 153 | """generate random string to be used as name""" 154 | char_set = digits + ascii_letters 155 | return "".join(random.choice(char_set) for _ in range(length)) 156 | 157 | 158 | def get_valid_filename(name): 159 | """sanitize filenames""" 160 | s = re.sub(r"(?u)[^-\w.]", " ", str(name)) 161 | p = Path(s.strip()) 162 | s = "_".join(p.stem.split()) 163 | 164 | if s in {"", ".", ".."}: 165 | s = f"{generate_random_string(8)}.pdf" 166 | return s + p.suffix 167 | 168 | 169 | def object2dictionary(obj, key_lc=False, skip_list=None): 170 | """convert object to dict""" 171 | 172 | output_dict = {} 173 | for k, v in asdict(obj).items(): 174 | if isinstance(skip_list, list) and k in skip_list: 175 | continue 176 | if is_dataclass(v): 177 | output_dict[k] = object2dictionary(v, key_lc=key_lc) 178 | else: 179 | if key_lc: 180 | output_dict[k.lower()] = v 181 | else: 182 | output_dict[k] = v 183 | return output_dict 184 | 185 | 186 | def string2float(value: str) -> float: 187 | """convert string to float value""" 188 | try: 189 | result = float(value.replace(".", "").replace(",", ".")) 190 | except Exception: 191 | result = value 192 | 193 | return result 194 | 195 | 196 | def logger_setup(debug: bool) -> logging.Logger: 197 | """setup logger""" 198 | if debug: 199 | log_mode = logging.DEBUG 200 | else: 201 | log_mode = logging.INFO 202 | 203 | # define standard log format 204 | log_format = None 205 | if debug: 206 | log_format = "%(module)s: %(message)s" 207 | else: 208 | log_format = "%(message)s" 209 | 210 | logging.basicConfig(format=log_format, datefmt="%Y-%m-%d %H:%M:%S", level=log_mode) 211 | mylogger = logging.getLogger("dkb_robo") 212 | return mylogger 213 | 214 | 215 | def validate_dates(date_from: str, date_to: str) -> Tuple[str, str]: 216 | """correct dates if needed""" 217 | logger.debug("validate_dates()") 218 | 219 | try: 220 | date_from_uts = int( 221 | time.mktime(datetime.strptime(date_from, "%d.%m.%Y").timetuple()) 222 | ) 223 | except ValueError: 224 | date_from_uts = int( 225 | time.mktime(datetime.strptime(date_from, API_DATE_FORMAT).timetuple()) 226 | ) 227 | try: 228 | date_to_uts = int( 229 | time.mktime(datetime.strptime(date_to, "%d.%m.%Y").timetuple()) 230 | ) 231 | except ValueError: 232 | date_to_uts = int( 233 | time.mktime(datetime.strptime(date_to, API_DATE_FORMAT).timetuple()) 234 | ) 235 | 236 | now_uts = int(time.time()) 237 | 238 | # ajust valid_from to valid_to 239 | if date_to_uts <= date_from_uts: 240 | logger.info("validate_dates(): adjust date_from to date_to") 241 | date_from = date_to 242 | 243 | # minimal date uts (01.01.2022) 244 | minimal_date_uts = 1640995200 245 | 246 | if date_from_uts < minimal_date_uts: 247 | logger.info( 248 | "validate_dates(): adjust date_from to %s", 249 | datetime.fromtimestamp(minimal_date_uts, timezone.utc).strftime( 250 | API_DATE_FORMAT 251 | ), 252 | ) 253 | date_from = datetime.fromtimestamp(minimal_date_uts, timezone.utc).strftime( 254 | "%d.%m.%Y" 255 | ) 256 | if date_to_uts < minimal_date_uts: 257 | logger.info( 258 | "validate_dates(): adjust date_to to %s", 259 | datetime.fromtimestamp(minimal_date_uts, timezone.utc).strftime( 260 | API_DATE_FORMAT 261 | ), 262 | ) 263 | date_to = datetime.fromtimestamp(minimal_date_uts, timezone.utc).strftime( 264 | "%d.%m.%Y" 265 | ) 266 | 267 | if date_from_uts > now_uts: 268 | logger.info( 269 | "validate_dates(): adjust date_from to %s", 270 | datetime.fromtimestamp(now_uts, timezone.utc).strftime(API_DATE_FORMAT), 271 | ) 272 | date_from = datetime.fromtimestamp(now_uts).strftime("%d.%m.%Y") 273 | if date_to_uts > now_uts: 274 | logger.info( 275 | "validate_dates(): adjust date_to to %s", 276 | datetime.fromtimestamp(now_uts, timezone.utc).strftime(API_DATE_FORMAT), 277 | ) 278 | date_to = datetime.fromtimestamp(now_uts, timezone.utc).strftime("%d.%m.%Y") 279 | 280 | # this is the new api we need to ensure %Y-%m-%d 281 | date_from = _convert_date_format( 282 | date_from, [API_DATE_FORMAT, LEGACY_DATE_FORMAT], API_DATE_FORMAT 283 | ) 284 | date_to = _convert_date_format( 285 | date_to, [API_DATE_FORMAT, LEGACY_DATE_FORMAT], API_DATE_FORMAT 286 | ) 287 | 288 | logger.debug("validate_dates() returned: %s, %s", date_from, date_to) 289 | return date_from, date_to 290 | 291 | 292 | def ulal(mapclass, parameter): 293 | """map parameter""" 294 | if parameter: 295 | return mapclass(**parameter) 296 | return None 297 | -------------------------------------------------------------------------------- /doc/dkb_docdownload.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ example script for dkb-robo """ 4 | from __future__ import print_function 5 | import sys 6 | from dkb_robo import DKBRobo 7 | 8 | if sys.version_info > (3, 0): 9 | import http.cookiejar as cookielib 10 | import importlib 11 | 12 | importlib.reload(sys) 13 | else: 14 | import cookielib 15 | 16 | reload(sys) 17 | sys.setdefaultencoding("utf8") 18 | 19 | 20 | if __name__ == "__main__": 21 | 22 | DKB_USER = "xxx" 23 | DKB_PASSWORD = "xxx" 24 | try: 25 | PATH = sys.argv[1] 26 | except BaseException: 27 | print("No path given") 28 | sys.exit(1) 29 | 30 | DKB = DKBRobo() 31 | 32 | # Using a Contexthandler (with) makes sure that the connection is closed after use 33 | with DKBRobo(DKB_USER, DKB_PASSWORD, False, True) as dkb: 34 | print(dkb.last_login) 35 | 36 | print(f"Writing documents to {PATH}") 37 | POSTBOX_DIC = dkb.scan_postbox(PATH) 38 | -------------------------------------------------------------------------------- /doc/dkb_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ example script for dkb-robo """ 4 | from __future__ import print_function 5 | import sys 6 | from pprint import pprint 7 | from dkb_robo import DKBRobo 8 | 9 | if sys.version_info > (3, 0): 10 | import http.cookiejar as cookielib 11 | import importlib 12 | 13 | importlib.reload(sys) 14 | else: 15 | import cookielib 16 | 17 | reload(sys) 18 | sys.setdefaultencoding("utf8") 19 | 20 | 21 | if __name__ == "__main__": 22 | 23 | DKB_USER = "xxxxxxx" 24 | DKB_PASSWORD = "*****" 25 | 26 | DKB = DKBRobo() 27 | 28 | # Using a Contexthandler (with) makes sure that the connection is closed after use 29 | with DKBRobo(dkb_user=DKB_USER, dkb_password=DKB_PASSWORD) as dkb: 30 | print(dkb.last_login) 31 | pprint(dkb.account_dic) 32 | 33 | # get transaction 34 | LINK = dkb.account_dic[0]["transactions"] 35 | TYPE = dkb.account_dic[0]["type"] 36 | DATE_FROM = "01.03.2020" 37 | DATE_TO = "31.03.2020" 38 | 39 | TRANSACTION_LIST = dkb.get_transactions(LINK, TYPE, DATE_FROM, DATE_TO) 40 | pprint(TRANSACTION_LIST) 41 | 42 | # get dkb postbox 43 | POSTBOX_DIC = dkb.scan_postbox() 44 | pprint(POSTBOX_DIC) 45 | 46 | # get credit limits 47 | CLI = dkb.get_credit_limits() 48 | pprint(CLI) 49 | 50 | # get standing orders (daueraufträge) 51 | STO = dkb.get_standing_orders() 52 | pprint(STO) 53 | 54 | # get freitstellungsaufträge 55 | EXO = dkb.get_exemption_order() 56 | pprint(EXO) 57 | 58 | POINTS_DIC = dkb.get_points() 59 | pprint(POINTS_DIC) 60 | -------------------------------------------------------------------------------- /doc/dkb_robo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Python: module dkb_robo 4 | 5 | 6 | 7 | 8 | 9 |
 
10 |  
dkb_robo
index
13 |

dkb internet banking automation library

14 |

15 | 16 | 17 | 19 | 20 | 21 |
 
18 | Modules
       
cookielib
22 |
mechanize
23 |
re
24 |
sys
25 |

26 | 27 | 28 | 30 | 31 | 32 |
 
29 | Classes
       
33 |
__builtin__.object 34 |
35 |
36 |
DKBRobo 37 |
38 |
39 |
40 |

41 | 42 | 43 | 45 | 46 | 47 | 48 | 49 |
 
44 | class DKBRobo(__builtin__.object)
   dkb_robo class
 
 Methods defined here:
50 |
get_account_transactions(self, dkb_br, transaction_url, date_from, date_to)
get transactions from an regular account for a certain amount of time
51 | args:
52 |     dkb_br          - browser object
53 |     transaction_url - link to collect the transactions
54 |     date_from       - transactions starting form
55 |     date_to         - end date
56 | 57 |
get_credit_limits(self, dkb_br)
create a dictionary of credit limits of the different accounts
58 |  
59 | args:
60 |     dkb_br - browser object
61 |  
62 | returns:
63 |     dictionary of the accounts and limits
64 | 65 |
get_creditcard_transactions(self, dkb_br, transaction_url, date_from, date_to)
get transactions from an regular account for a certain amount of time
66 | args:
67 |     dkb_br          - browser object
68 |     transaction_url - link to collect the transactions
69 |     date_from       - transactions starting form
70 |     date_to         - end date
71 | 72 |
get_document_links(self, dkb_br, url)
create a dictionary of the documents stored in a pbost folder
73 |  
74 | args:
75 |     dkb_br - browser object
76 |     url - folder url
77 |  
78 | returns:
79 |     dictionary of the documents
80 | 81 |
get_exemption_order(self, dkb_br)
returns a dictionary of the stored exemption orders
82 |  
83 | args:
84 |     dkb_br - browser object
85 |     url - folder url
86 |  
87 | returns:
88 |     dictionary of exemption orders
89 | 90 |
get_transactions(self, dkb_br, transaction_url, atype, date_from, date_to)
get transactions for a certain amount of time
91 | args:
92 |     dkb_br          - browser object
93 |     transaction_url - link to collect the transactions
94 |     atype           - account type (cash, creditcard, depot)
95 |     date_from       - transactions starting form
96 |     date_to         - end date
97 |  
98 | returns:
99 |     list of transactions; each transaction gets represented as a dictionary containing the following information
100 |     - date   - booking date
101 |     - amount - amount
102 |     - text   - test
103 | 104 |
login(self, dkb_user, dkb_password)
login into DKB banking area
105 |  
106 | args:
107 |     dkb_user = dkb username
108 |     dkb_password  = dkb_password
109 |  
110 | returns:
111 |     dkb_br - handle to browser object for further processing
112 |     last_login - last login date (German date format)
113 |     account_dic - dictionary containing account information
114 |     - name
115 |     - account number
116 |     - type (account, creditcard, depot)
117 |     - account balance
118 |     - date of balance
119 |     - link to details
120 |     - link to transactions
121 | 122 |
logout(self, dkb_br)
logout from DKB banking area
123 |  
124 | args:
125 |     dkb_br = browser object
126 |  
127 | returns:
128 |     None
129 | 130 |
new_instance(self)
creates a new browser instance
131 |  
132 | args:
133 |    None
134 |  
135 | returns:
136 |    dkb_br - instance
137 | 138 |
parse_account_transactions(self, transactions)
parses html code and creates a list of transactions included
139 |  
140 | args:
141 |     transactions - html page including transactions
142 |  
143 | returns:
144 |     list of transactions captured. Each transaction gets represented by a hash containing the following values
145 |     - date - booking date
146 |     - amount - amount
147 |     - text - text
148 | 149 |
parse_cc_transactions(self, transactions)
parses html code and creates a list of transactions included
150 |  
151 | args:
152 |     transactions - html page including transactions
153 |  
154 | returns:
155 |     list of transactions captured. Each transaction gets represented by a hash containing the following values
156 |     - bdate - booking date
157 |     - vdate - valuta date
158 |     - amount - amount
159 |     - text - text
160 | 161 |
parse_overview(self, soup)
creates a dictionary including account information
162 |  
163 | args:
164 |     soup - BautifulSoup object
165 |  
166 | returns:
167 |     overview_dic - dictionary containing following account information
168 |     - name
169 |     - account number
170 |     - type (account, creditcard, depot)
171 |     - account balance
172 |     - date of balance
173 |     - link to details
174 |     - link to transactions
175 | 176 |
scan_postbox(self, dkb_br)
scans the DKB postbox and creates a dictionary out of the
177 |     different documents
178 |  
179 | args:
180 |     dkb_br = browser object
181 |  
182 | returns:
183 |    dictionary in the following format
184 |  
185 |    - folder name in postbox
186 |         - details -> link to document overview
187 |         - documents
188 |             - name of document -> document link
189 | 190 |
191 | Data descriptors defined here:
192 |
__dict__
193 |
dictionary for instance variables (if defined)
194 |
195 |
__weakref__
196 |
list of weak references to the object (if defined)
197 |
198 |
199 | Data and other attributes defined here:
200 |
base_url = 'https://www.dkb.de'
201 | 202 |

203 | 204 | 205 | 207 | 208 | 209 |
 
206 | Data
       print_function = _Feature((2, 6, 0, 'alpha', 2), (3, 0, 0, 'alpha', 0), 65536)
210 | 211 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | authors = [{ name = "grindsa", email = "grindelsack@gmail.com" }] 3 | name = "dkb_robo" 4 | description = "Download transactions from the website of Deutsche Kreditbak AG" 5 | keywords = ["Deutsche Kreditbank", "DKB"] 6 | version = "0.31" 7 | readme = "README.md" 8 | classifiers = [ 9 | # Keep in sync with the Python version matrix in test_lint.yaml GitHub workflow. 10 | "Programming Language :: Python :: 3", 11 | "Programming Language :: Python :: 3.8", 12 | "Programming Language :: Python :: 3.9", 13 | "Programming Language :: Python :: 3.10", 14 | "Programming Language :: Python :: 3.11", 15 | "Programming Language :: Python :: 3.12", 16 | "Programming Language :: Python :: 3.13", 17 | "Development Status :: 4 - Beta", 18 | "Natural Language :: German", 19 | "Intended Audience :: Developers", 20 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 21 | "Operating System :: OS Independent", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | ] 24 | requires-python = ">= 3.8" 25 | dependencies = [ 26 | "bs4", 27 | "mechanicalsoup", 28 | "requests", 29 | "pillow", 30 | "tabulate", 31 | "click", 32 | ] 33 | optional-dependencies.test = ["pytest", "pytest-cov", "html5lib"] 34 | optional-dependencies.dev = ["build", "dkb_robo[test]", "pre-commit"] 35 | scripts = { dkb = "dkb_robo.cli:main" } 36 | 37 | [project.urls] 38 | Repository = "https://github.com/grindsa/dkb-robo" 39 | Issues = "https://github.com/grindsa/dkb-robo/issues" 40 | 41 | [build-system] 42 | requires = ["hatchling"] 43 | build-backend = "hatchling.build" 44 | 45 | [tool.hatch] 46 | build.targets.sdist.only-packages = true 47 | 48 | [tool.pytest.ini_options] 49 | addopts = [ 50 | "-ra", 51 | "--cov=dkb_robo", 52 | "--import-mode=importlib", 53 | "--strict-config", 54 | "--strict-markers", 55 | ] 56 | filterwarnings = ["error"] 57 | log_cli_level = "INFO" 58 | minversion = "7" 59 | xfail_strict = true 60 | testpaths = ["test"] 61 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=grindsa_dkb-robo 2 | sonar.organization=grindsa 3 | 4 | sonar.python.coverage.reportPaths=coverage.xml 5 | # This is the name and version displayed in the SonarCloud UI. 6 | #sonar.projectName=dkb-robo 7 | #sonar.projectVersion=1.0 8 | 9 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 10 | #sonar.sources=. 11 | 12 | # Encoding of the source code. Default is default system encoding 13 | #sonar.sourceEncoding=UTF-8 14 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grindsa/dkb-robo/871320f33a7d7288baef25a6f39b1345d7d11c97/test/__init__.py -------------------------------------------------------------------------------- /test/mocks/accounts.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "account", 5 | "id": "accountid1", 6 | "attributes": { 7 | "holderName": "Account HolderName 1", 8 | "iban": "AccountIBAN1", 9 | "permissions": [ 10 | "foo", 11 | "bar", 12 | "foo-bar" 13 | ], 14 | "currencyCode": "EUR", 15 | "balance": { 16 | "currencyCode": "EUR", 17 | "value": "12345.67" 18 | }, 19 | "availableBalance": { 20 | "currencyCode": "EUR", 21 | "value": "12345.67" 22 | }, 23 | "nearTimeBalance": { 24 | "currencyCode": "EUR", 25 | "value": "12345.67" 26 | }, 27 | "product": { 28 | "id": "Account productid1", 29 | "type": "checking-account-type1", 30 | "displayName": "Account DisplayName 1" 31 | }, 32 | "state": "active", 33 | "updatedAt": "2020-01-01", 34 | "openingDate": "2020-01-02", 35 | "overdraftLimit": "1000.00", 36 | "interestRate": "1.000", 37 | "unauthorizedOverdraftInterestRate": "11.000", 38 | "lastAccountStatementDate": "2020-01-03", 39 | "interests": [ 40 | { 41 | "type": "overdraft", 42 | "method": "individual-rate", 43 | "details": [ 44 | { 45 | "interestRate": "12.150", 46 | "condition": { 47 | "currency": "EUR", 48 | "minimumAmount": "0.00" 49 | } 50 | } 51 | ] 52 | }, 53 | { 54 | "type": "credit", 55 | "method": "individual-rate", 56 | "details": [ 57 | { 58 | "interestRate": "2.000", 59 | "condition": { 60 | "currency": "EUR", 61 | "minimumAmount": "0.00" 62 | } 63 | } 64 | ] 65 | } 66 | ] 67 | } 68 | }, 69 | { 70 | "type": "account", 71 | "id": "accountid2", 72 | "attributes": { 73 | "holderName": "Account HolderName 2", 74 | "iban": "AccountIBAN2", 75 | "permissions": [ 76 | "foo", 77 | "bar", 78 | "foo-bar" 79 | ], 80 | "currencyCode": "EUR", 81 | "balance": { 82 | "currencyCode": "EUR", 83 | "value": "1284.56" 84 | }, 85 | "availableBalance": { 86 | "currencyCode": "EUR", 87 | "value": "123.45" 88 | }, 89 | "nearTimeBalance": { 90 | "currencyCode": "EUR", 91 | "value": "123.45" 92 | }, 93 | "product": { 94 | "id": "Account productid2", 95 | "type": "checking-account-type2", 96 | "displayName": "Account DisplayName 2" 97 | }, 98 | "state": "active", 99 | "updatedAt": "2020-02-01", 100 | "openingDate": "2020-02-02", 101 | "overdraftLimit": "0.00", 102 | "interestRate": "2.000", 103 | "unauthorizedOverdraftInterestRate": "12.000", 104 | "lastAccountStatementDate": "2023-02-04", 105 | "interests": [ 106 | { 107 | "type": "overdraft", 108 | "method": "individual-rate", 109 | "details": [ 110 | { 111 | "interestRate": "12.150", 112 | "condition": { 113 | "currency": "EUR", 114 | "minimumAmount": "0.00" 115 | } 116 | } 117 | ] 118 | }, 119 | { 120 | "type": "credit", 121 | "method": "individual-rate", 122 | "details": [ 123 | { 124 | "interestRate": "2.000", 125 | "condition": { 126 | "currency": "EUR", 127 | "minimumAmount": "0.00" 128 | } 129 | } 130 | ] 131 | } 132 | ] 133 | } 134 | }, 135 | { 136 | "type": "account", 137 | "id": "accountid3", 138 | "attributes": { 139 | "holderName": "Account HolderName 3", 140 | "iban": "AccountIBAN3", 141 | "permissions": [ 142 | "foo", 143 | "bar", 144 | "foo-bar" 145 | ], 146 | "currencyCode": "EUR", 147 | "balance": { 148 | "currencyCode": "EUR", 149 | "value": "-1000.22" 150 | }, 151 | "availableBalance": { 152 | "currencyCode": "EUR", 153 | "value": "1200.78" 154 | }, 155 | "nearTimeBalance": { 156 | "currencyCode": "EUR", 157 | "value": "-690.00" 158 | }, 159 | "product": { 160 | "id": "Account productid3", 161 | "type": "checking-account-type3", 162 | "displayName": "Account DisplayName 3" 163 | }, 164 | "state": "active", 165 | "updatedAt": "2020-03-01", 166 | "openingDate": "2020-03-02", 167 | "overdraftLimit": "2500.00", 168 | "overdraftInterestRate": "1.1234", 169 | "interestRate": "1.000", 170 | "unauthorizedOverdraftInterestRate": "1.234", 171 | "lastAccountStatementDate": "2020-03-04", 172 | "interests": [ 173 | { 174 | "type": "overdraft", 175 | "method": "individual-rate", 176 | "details": [ 177 | { 178 | "interestRate": "12.150", 179 | "condition": { 180 | "currency": "EUR", 181 | "minimumAmount": "0.00" 182 | } 183 | } 184 | ] 185 | }, 186 | { 187 | "type": "credit", 188 | "method": "individual-rate", 189 | "details": [ 190 | { 191 | "interestRate": "2.000", 192 | "condition": { 193 | "currency": "EUR", 194 | "minimumAmount": "0.00" 195 | } 196 | } 197 | ] 198 | } 199 | ] 200 | } 201 | } 202 | ] 203 | } 204 | -------------------------------------------------------------------------------- /test/mocks/brokerage.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "id": "baccountid1", 5 | "type": "brokerageAccount", 6 | "attributes": { 7 | "brokerageAccountPerformance": { 8 | "currentValue": { 9 | "value": "1234.56", 10 | "unit": "currency", 11 | "currencyCode": "EUR" 12 | }, 13 | "averagePrice": { 14 | "value": "0", 15 | "unit": "currency", 16 | "currencyCode": "EUR" 17 | }, 18 | "overallAbsolute": { 19 | "value": "0", 20 | "unit": "currency", 21 | "currencyCode": "EUR" 22 | }, 23 | "overallRelative": "0", 24 | "isOutdated": true 25 | }, 26 | "depositAccountId": "987654321", 27 | "holder": { 28 | "firstName": "firstname1", 29 | "lastName": "lastname1" 30 | }, 31 | "holderName": "HolderName1", 32 | "referenceAccounts": [ 33 | { 34 | "internalReferenceAccounts": true, 35 | "accountType": "accounting", 36 | "accountNumber": "refaccountNumber1", 37 | "bankCode": "refbankCode1", 38 | "holderName": "refholderName1" 39 | }, 40 | { 41 | "internalReferenceAccounts": true, 42 | "accountType": "returns", 43 | "accountNumber": "refaccountNumber2", 44 | "bankCode": "refbankCode2", 45 | "holderName": "refholderName2" 46 | } 47 | ], 48 | "tradingEnabled": true 49 | } 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /test/mocks/cards.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "creditCard", 5 | "id": "cardid1", 6 | "attributes": { 7 | "maskedPan": "maskedPan1", 8 | "network": "visa", 9 | "engravedLine1": "engravedLine2", 10 | "engravedLine2": "", 11 | "activationDate": "2020-01-01", 12 | "expiryDate": "2020-01-02", 13 | "balance": { 14 | "date": "2020-01-03", 15 | "currencyCode": "EUR", 16 | "value": "1234.56" 17 | }, 18 | "state": "active", 19 | "owner": { 20 | "firstName": "ownerfirstName1", 21 | "lastName": "ownerlastName1", 22 | "title": "", 23 | "salutation": "salutation" 24 | }, 25 | "holder": { 26 | "person": { 27 | "firstName": "holderfirstName", 28 | "lastName": "holderlastName" 29 | } 30 | }, 31 | "product": { 32 | "superProductId": "superProductId1", 33 | "displayName": "displayName1", 34 | "institute": "DKB", 35 | "productType": "CASH-BLACK", 36 | "ownerType": "PRIVATE", 37 | "id": "productid1", 38 | "type": "CASH-BLACK" 39 | }, 40 | "limit": { 41 | "currencyCode": "EUR", 42 | "value": "1000.00" 43 | }, 44 | "availableLimit": { 45 | "currencyCode": "EUR", 46 | "value": "1000.00" 47 | }, 48 | "authorizedAmount": { 49 | "currencyCode": "EUR", 50 | "value": "0.00" 51 | }, 52 | "referenceAccount": { 53 | "iban": "referenceAccountiban1", 54 | "bic": "referenceAccountbic1" 55 | }, 56 | "status": { 57 | "category": "active", 58 | "limitationsFor": [] 59 | }, 60 | "billingDetails": { 61 | "days": [ 62 | 22 63 | ], 64 | "calendarType": "calendar", 65 | "cycle": "monthly" 66 | } 67 | }, 68 | "relationships": { 69 | "legitimates": { 70 | "data": [ 71 | { 72 | "type": "user", 73 | "id": "relationshipid1" 74 | } 75 | ] 76 | } 77 | } 78 | }, 79 | { 80 | "type": "creditCard", 81 | "id": "cardid2", 82 | "attributes": { 83 | "maskedPan": "maskedPan2", 84 | "network": "visa", 85 | "engravedLine1": "engravedLine12", 86 | "engravedLine2": "", 87 | "activationDate": "2020-02-01", 88 | "expiryDate": "2020-02-02", 89 | "balance": { 90 | "date": "2020-02-07", 91 | "currencyCode": "EUR", 92 | "value": "-12345.67" 93 | }, 94 | "state": "active", 95 | "owner": { 96 | "firstName": "firstName2", 97 | "lastName": "lastName2", 98 | "title": "", 99 | "salutation": "salutation2" 100 | }, 101 | "holder": { 102 | "person": { 103 | "firstName": "holderfirstName2", 104 | "lastName": "holderlastName2" 105 | } 106 | }, 107 | "product": { 108 | "superProductId": "superProductId2", 109 | "displayName": "displayName2", 110 | "institute": "DKB", 111 | "productType": "productType2", 112 | "ownerType": "PRIVATE", 113 | "id": "produchtid2", 114 | "type": "CASH-BLACK" 115 | }, 116 | "limit": { 117 | "currencyCode": "EUR", 118 | "value": "0.00" 119 | }, 120 | "availableLimit": { 121 | "currencyCode": "EUR", 122 | "value": "12345.67" 123 | }, 124 | "authorizedAmount": { 125 | "currencyCode": "EUR", 126 | "value": "0.00" 127 | }, 128 | "referenceAccount": { 129 | "iban": "referenceAccountiban2", 130 | "bic": "referenceAccountbic2" 131 | }, 132 | "status": { 133 | "category": "active", 134 | "limitationsFor": [] 135 | }, 136 | "billingDetails": { 137 | "days": [ 138 | 22 139 | ], 140 | "calendarType": "calendar", 141 | "cycle": "monthly" 142 | } 143 | }, 144 | "relationships": { 145 | "owner": { 146 | "data": { 147 | "type": "user", 148 | "id": "b013a178-0a73-4ee2-a3e0-ce73085672f1" 149 | } 150 | } 151 | } 152 | }, 153 | { 154 | "type": "debitCard", 155 | "id": "cardid3", 156 | "attributes": { 157 | "maskedPan": "maskedPan3", 158 | "network": "visa", 159 | "engravedLine1": "engravedLine13", 160 | "blockedSince": "2020-03-01", 161 | "activationDate": "2020-03-02", 162 | "creationDate": "2020-03-03", 163 | "expiryDate": "2020-04-04", 164 | "state": "blocked", 165 | "failedPinAttempts": 0, 166 | "limit": { 167 | "identifier": "lidentifier1", 168 | "categories": [ 169 | { 170 | "name": "categories1", 171 | "amount": { 172 | "currencyCode": "EUR", 173 | "value": "1.99" 174 | } 175 | }, 176 | { 177 | "name": "categories2", 178 | "amount": { 179 | "currencyCode": "EUR", 180 | "value": "2.00" 181 | } 182 | }, 183 | { 184 | "name": "categories3", 185 | "amount": { 186 | "currencyCode": "EUR", 187 | "value": "3.99" 188 | } 189 | }, 190 | { 191 | "name": "categories4", 192 | "amount": { 193 | "currencyCode": "EUR", 194 | "value": "4.00" 195 | } 196 | }, 197 | { 198 | "name": "categories5", 199 | "amount": { 200 | "currencyCode": "EUR", 201 | "value": "6.00" 202 | } 203 | } 204 | ] 205 | }, 206 | "holder": { 207 | "person": { 208 | "firstName": "holderfirstName3", 209 | "lastName": "holderlastName3" 210 | } 211 | }, 212 | "product": { 213 | "superProductId": "Debit", 214 | "displayName": "Visa Debitkarte", 215 | "institute": "DKB", 216 | "productType": "producttype3", 217 | "ownerType": "private", 218 | "id": "Debit", 219 | "type": "DebitPhysical" 220 | }, 221 | "referenceAccount": { 222 | "iban": "referenceAccount3", 223 | "bic": "referenceAccountbic3" 224 | }, 225 | "status": { 226 | "category": "blocked", 227 | "since": "2020-03-01", 228 | "reason": "cancellation-of-product-by-customer", 229 | "final": true 230 | } 231 | }, 232 | "relationships": { 233 | "owner": { 234 | "data": { 235 | "type": "user", 236 | "id": "relationshipsid3" 237 | } 238 | } 239 | } 240 | }, 241 | { 242 | "type": "debitCard", 243 | "id": "cardid4", 244 | "attributes": { 245 | "maskedPan": "maskedPan4", 246 | "network": "visa", 247 | "engravedLine1": "engravedLine4", 248 | "activationDate": "2020-04-01", 249 | "creationDate": "2020-04-02", 250 | "expiryDate": "2020-04-03", 251 | "state": "active", 252 | "failedPinAttempts": 0, 253 | "limit": { 254 | "identifier": "lidentifier2", 255 | "categories": [ 256 | { 257 | "name": "categories21", 258 | "amount": { 259 | "currencyCode": "EUR", 260 | "value": "1.99" 261 | } 262 | }, 263 | { 264 | "name": "categories22", 265 | "amount": { 266 | "currencyCode": "EUR", 267 | "value": "2.00" 268 | } 269 | }, 270 | { 271 | "name": "categories23", 272 | "amount": { 273 | "currencyCode": "EUR", 274 | "value": "3.99" 275 | } 276 | }, 277 | { 278 | "name": "categories24", 279 | "amount": { 280 | "currencyCode": "EUR", 281 | "value": "5.00" 282 | } 283 | }, 284 | { 285 | "name": "categories25", 286 | "amount": { 287 | "currencyCode": "EUR", 288 | "value": "6.00" 289 | } 290 | } 291 | ] 292 | }, 293 | "holder": { 294 | "person": { 295 | "firstName": "holderfirstName4", 296 | "lastName": "holderlastName4" 297 | } 298 | }, 299 | "product": { 300 | "superProductId": "superProductId4", 301 | "displayName": "Visa Debitkarte", 302 | "institute": "DKB", 303 | "productType": "DebitPhysical", 304 | "ownerType": "private", 305 | "id": "Debit", 306 | "type": "DebitPhysical" 307 | }, 308 | "referenceAccount": { 309 | "iban": "referenceAccountiban4", 310 | "bic": "referenceAccountbic4" 311 | }, 312 | "status": { 313 | "category": "active" 314 | } 315 | }, 316 | "relationships": { 317 | "owner": { 318 | "data": { 319 | "type": "user", 320 | "id": "relationships4" 321 | } 322 | } 323 | } 324 | } 325 | ], 326 | "meta": { 327 | "messages": [] 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /test/mocks/dauerauftraege.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 11 | 17 | 20 | 21 | 22 | 25 | 29 | 35 | 38 | 39 | 40 |
5 | RECPIPIENT-1  6 | 8 | 100,00 9 |  EUR 10 | 12 | 1.
13 | monatlich 14 |
15 | 01.03.2017 16 |
18 | KV 1234567890  19 |
23 | RECPIPIENT-2  24 | 26 | 200,00 27 |  EUR 28 | 30 | 1.
31 | monatlich 32 |
33 | geloescht 34 |
36 | KV 0987654321  37 |
41 | -------------------------------------------------------------------------------- /test/mocks/doclinks-2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
02.03.201702.03.2017Kontoauszug Nr. 003_2017 zu Konto 12345678

Löschung zum 02.03.2017

02.03.201702.03.2017Kontoauszug Nr. 003_2017 zu Konto 87654321

Löschung zum 02.03.2017

15 | -------------------------------------------------------------------------------- /test/mocks/doclinks-3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
02.03.201702.03.2017Kontoauszug Nr. 003_2017 zu Konto 23456789

Löschung zum 02.03.2017

02.03.201702.03.2017Kontoauszug Nr. 003_2017 zu Konto 98765432

Löschung zum 02.03.2017

15 | foo 16 | -------------------------------------------------------------------------------- /test/mocks/doclinks.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
02.03.201702.03.2017Kontoauszug Nr. 003_2017 zu Konto 12345678

Löschung zum 02.03.2017

02.03.201702.03.2017Kontoauszug Nr. 003_2017 zu Konto 87654321

Löschung zum 02.03.2017

15 | -------------------------------------------------------------------------------- /test/mocks/document_list-2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 11 | 12 | 13 | 14 |
6 | 04.01.2022
7 | Name 04.01.2022
9 | 10 |
15 | -------------------------------------------------------------------------------- /test/mocks/document_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 12 | 13 | 14 | 15 |
04.01.2022
7 | 04.01.2022
8 | Name 04.01.2022
10 | 11 |
16 | -------------------------------------------------------------------------------- /test/mocks/finanzstatus-mbank.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 70 | 71 | 72 |
credit-card-1
1111********1111
1111********1111
01.03.20171.000,00 10 |

Umsätze

11 | 12 |
credit-card-2
1111********1112
1111********1112
02.03.20172.000,00 21 |

Umsätze

22 | 23 |
checking-account-1
DE11 1111 1111 1111 1111 11
DE11 1111 1111 1111 1111 11
03.03.20171.000,00 33 |

Umsätze

34 | 35 |
checking-account-2
DE11 1111 1111 1111 1111 12
DE11 1111 1111 1111 1111 12
04.03.20172.000,00 44 |

Umsätze

45 | 46 |
Depot-1
1111111
1111111
06.03.20175.000,00 56 |

Depotstatus

57 | 58 |
Depot-2
1111112
1111112
06.03.20176.000,00 67 |

Depotstatus

68 | 69 |
73 | -------------------------------------------------------------------------------- /test/mocks/freistellungsauftrag-indexerror.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

6 | 7 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
8 | Freistellungsauftrag 9 |
 ArtGültigkeitGemeldetVerbrauchtVerfügbarFunktionen
foo
37 |
38 | 39 | -------------------------------------------------------------------------------- /test/mocks/konto-kreditkarten-limits-exception.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Konto- & Kreditkartenlimite

6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
foo
foo
32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/mocks/konto-kreditkarten-limits.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Konto- & Kreditkartenlimite

6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
CC-1
1111********1111
CC-1100,00
CC-2
1111********1112
CC-22.000,00
36 |
37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /test/mocks/milesmore-finanzstatus.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Lufthansa Miles & More Credit Card 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 | 65 | 66 | 67 |
68 |
69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 150 | 151 | 152 | 153 |
154 | 155 |
156 |
157 |
158 |
159 | 160 | 161 | 162 | 163 | 164 |
165 |
166 | 167 | 168 | 169 | 170 |
171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 |
193 | Meine persönlichen Daten 194 | NAME8 NAME0 195 | Letztes Login: 196 | 18.04.2018 12:58:18 MESZ 197 | 198 | 199 | Benutzername und E-Mail Adresse 200 | 201 | 202 | 203 | 204 | Handynummer 205 | 206 | 207 | 208 | 209 | Passwort 210 | 211 | 212 | 213 | 214 | Passwortfrage 215 | 216 | 217 | 218 | 219 | Adresse und Bankverbindung 220 | 221 | 222 | 223 | 224 | Karte sperren/ ersetzen 225 | 226 | 227 | 228 | 229 | Posteingang (27) 230 | 231 | 232 |
 
233 |
234 | Abmelden 235 |
236 | 237 |
238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 292 | 293 | 294 | 301 | 302 | 303 | 310 | 311 |
286 | 291 |
295 | 300 |
304 | 309 |
312 | 313 | 314 | 315 | 316 | 317 |
 
318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 |
328 | 329 |
330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 |
369 |
370 |
371 | Herzlich Willkommen im
Online-Kartenkonto
372 |
373 |
374 |
375 | 376 | 377 |

Finanzstatus

378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 414 | 417 | 418 | 419 | 427 | 428 | 429 | 430 | 431 | 432 | 433 |
KartennummerBezeichnungAktuell verfügbar Letzte BuchungNeue Umsätze

999999 xxxxxx 1234 Miles & More Credit Card Blue 400 | 401 | 402 | 403 | 404 | 405 | 406 | 6.132,93 407 | EUR 408 | 409 | 410 | 411 | 412 | 413 | 415 | 416 | 25.04.2018 420 | 421 | 422 | - 423 | 260,42 424 | EUR 425 | 426 |
mehr...
434 | 435 | 436 | 437 | 438 | 439 |
440 | 441 | 442 |
443 | 444 | 445 | 446 | 447 |
Aktuelle Nachricht: 03.04.2018   Machen Sie Ihren Lieben eine Freude:
448 |
449 | 450 | 451 |
452 | 453 | 454 |
455 | 456 | 457 | 458 |
459 |
460 |
461 | Kartenumsätze 462 |
463 |
464 |
465 | 466 |
467 |
Aktuelle Umsätze und frühere Rechnungen
468 | mehr... 469 |
470 |
471 |
472 | 473 | 474 | 475 | 476 | 477 |
478 |
479 |
480 | Überweisungsservice 481 |
482 |
483 |
484 | 485 |
486 |
Überweisungen auf deutsche Bankkonten
487 | mehr... 488 |
489 |
490 |
491 | 492 | 493 | 494 | 495 | 496 |
497 |
498 |
499 | Guthabenauszahlung 500 |
501 |
502 |
503 | 504 |
505 |
Auszahlung von Guthaben
506 | mehr... 507 |
508 |
509 |
510 | 511 | 512 | 513 |
514 |
515 |
516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 |
525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 |
539 |
 
540 | 541 | 542 |
543 | 544 |
545 | 546 |
547 | 548 | 549 | 550 |
551 |
552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 |
574 | 616 |
617 | 618 | 619 | 620 | 621 | -------------------------------------------------------------------------------- /test/mocks/pd.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "productDisplaySettings", 5 | "id": "productDisplaySettingskid", 6 | "attributes": { 7 | "portfolio": "dkb", 8 | "productSettings": { 9 | "accounts": { 10 | "accountid2": { 11 | "name": "pdsettings accoutname accountid2" 12 | }, 13 | "accountid3": { 14 | "name": "pdsettings accoutname accountid3" 15 | } 16 | }, 17 | "creditCards": { 18 | "cardid1": { 19 | "name": "pdsettings cardname cardid1" 20 | }, 21 | "creditCards2": { 22 | "name": "pdsettings cardname cardid2" 23 | } 24 | }, 25 | "brokerageAccounts": { 26 | "baccountid1": { 27 | "name": "pdsettings brokeraage baccountid1" 28 | } 29 | } 30 | }, 31 | "productGroups": { 32 | "productGroup1": { 33 | "name": "productGroup name 1", 34 | "index": 0, 35 | "products": { 36 | "accounts": { 37 | "accountid3": { 38 | "index": 1 39 | } 40 | }, 41 | "creditCards": { 42 | "cardid1": { 43 | "index": 2 44 | }, 45 | "cardid2": { 46 | "index": 3 47 | } 48 | }, 49 | "brokerageAccounts": { 50 | "baccountid1": { 51 | "index": 0 52 | } 53 | } 54 | } 55 | }, 56 | "productGroup2": { 57 | "name": "productGroup name 2", 58 | "index": 1, 59 | "products": { 60 | "accounts": { 61 | "accountid2": { 62 | "index": 0 63 | } 64 | } 65 | } 66 | } 67 | }, 68 | "clientTypeSpecificSettings": {} 69 | }, 70 | "links": { 71 | "self": "https://api.developer.dkb.de/config/users/me/product-display-settings/2e5556c2-4165-4ac6-8192-674890871f1c" 72 | } 73 | } 74 | ], 75 | "included": [] 76 | } 77 | -------------------------------------------------------------------------------- /test/mocks/postbox-2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
Mitteilungen
Vertragsinformationen
Kreditkartenabrechnungen
22 | -------------------------------------------------------------------------------- /test/mocks/postbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
Mitteilungen
Vertragsinformationen
Kreditkartenabrechnungen
22 | -------------------------------------------------------------------------------- /test/mocks/so.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "recurringCreditTransferResponse", 5 | "id": "id1", 6 | "attributes": { 7 | "status": "accepted", 8 | "description": "description1", 9 | "amount": { 10 | "currencyCode": "EUR", 11 | "value": "100.00" 12 | }, 13 | "creditor": { 14 | "name": "name1", 15 | "creditorAccount": { 16 | "iban": "iban1", 17 | "bic": "bic1" 18 | } 19 | }, 20 | "debtor": { 21 | "debtorAccount": { 22 | "iban": "debiban1" 23 | } 24 | }, 25 | "messages": [], 26 | "recurrence": { 27 | "from": "2022-01-01", 28 | "until": "2025-12-01", 29 | "frequency": "monthly", 30 | "holidayExecutionStrategy": "following", 31 | "nextExecutionAt": "2022-11-01" 32 | } 33 | } 34 | }, 35 | { 36 | "type": "recurringCreditTransferResponse", 37 | "id": "id2", 38 | "attributes": { 39 | "status": "accepted", 40 | "description": "description2", 41 | "amount": { 42 | "currencyCode": "EUR", 43 | "value": "200.00" 44 | }, 45 | "creditor": { 46 | "name": "name2", 47 | "creditorAccount": { 48 | "iban": "iban2", 49 | "bic": "bic2" 50 | } 51 | }, 52 | "debtor": { 53 | "debtorAccount": { 54 | "iban": "debiban2" 55 | } 56 | }, 57 | "messages": [], 58 | "recurrence": { 59 | "from": "2022-02-01", 60 | "until": "2025-12-02", 61 | "frequency": "monthly", 62 | "holidayExecutionStrategy": "following", 63 | "nextExecutionAt": "2022-11-02" 64 | } 65 | } 66 | }, 67 | { 68 | "type": "recurringCreditTransferResponse", 69 | "id": "id3", 70 | "attributes": { 71 | "status": "accepted", 72 | "description": "description3", 73 | "amount": { 74 | "currencyCode": "EUR", 75 | "value": "300.00" 76 | }, 77 | "creditor": { 78 | "name": "name3", 79 | "creditorAccount": { 80 | "iban": "iban3", 81 | "bic": "bic3" 82 | } 83 | }, 84 | "debtor": { 85 | "debtorAccount": { 86 | "iban": "debiban3" 87 | } 88 | }, 89 | "messages": [], 90 | "recurrence": { 91 | "from": "2022-03-01", 92 | "until": "2025-03-01", 93 | "frequency": "monthly", 94 | "holidayExecutionStrategy": "following", 95 | "nextExecutionAt": "2022-03-01" 96 | } 97 | } 98 | } 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /test/mocks/test_parse_account_tr.csv: -------------------------------------------------------------------------------- 1 | "Kontonummer:";"xxxxxxxxx / Girokonto"; 2 | 3 | "Von:";"01.03.2017"; 4 | "Bis:";"03.03.2017"; 5 | "Kontostand vom 03.03.2017:";"9.999,99 EUR"; 6 | 7 | "Buchungstag";"Wertstellung";"Buchungstext";"Auftraggeber / Begünstigter";"Verwendungszweck";"Kontonummer";"BLZ";"Betrag (EUR)";"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"; 8 | "01.03.2017";"01.03.2017";"Buchungstext1";"Auftraggeber1";"Verwendungszweck1";"Kontonummer1";"BLZ1";"100,00";"GID1";"Mandatsreferenz1";"Kundenreferenz1";"AA" 9 | "02.03.2017";"02.03.2017";"Buchungstext2";"Auftraggeber2";"Verwendungszweck2";"Kontonummer2";"BLZ2";"-200,00";"GID2";"Mandatsreferenz2";"Kundenreferenz2";"BB" 10 | "03.03.2017";"03.03.2017";"Buchungstext3";"Auftraggeber3";"Verwendungszweck3";"Kontonummer3";"BLZ3";"3.000,00";"GID3";"Mandatsreferenz3";"Kundenreferenz3";"CC" 11 | "04.03.2017";"04.03.2017";"Buchungstext4";"Auftraggeber4";"Verwendungszweck4";"Kontonummer4";"BLZ4";"-4.000,00";"GID4";"Mandatsreferenz4";"Kundenreferenz4";"CC" 12 | -------------------------------------------------------------------------------- /test/mocks/test_parse_depot.csv: -------------------------------------------------------------------------------- 1 | "Depot:";"101010101 / Depot"; 2 | 3 | "Depotgesamtwert in Euro:";"4.444,44"; 4 | "Depotgesamtwert per:";"01.08.2022 08:08"; 5 | 6 | "Bestand";"";"ISIN / WKN";"Bezeichnung";"Kurs";"Gewinn / Verlust";"";"Einstandswert";"";"Dev. Kurs";"Kurswert in Euro";"Verf�gbarkeit"; 7 | "10,00";"cnt1";"WKN1";"Bezeichnung1";"11,00";"";"";"";"";"";"1.110,10";"Frei"; 8 | "20,00";"cnt2";"WKN2";"Bezeichnung2";"12,00";"";"";"";"";"";"2.220,20";"Frei"; 9 | -------------------------------------------------------------------------------- /test/mocks/test_parse_dkb_cc_tr.csv: -------------------------------------------------------------------------------- 1 | "Kreditkarte:";"xxxx********xxxx"; 2 | 3 | "Zeitraum:";"seit der letzten Abrechnung"; 4 | "Saldo:";"-999.01 EUR"; 5 | "Datum:";"11.03.2017"; 6 | 7 | "Umsatz abgerechnet und nicht im Saldo enthalten";"Wertstellung";"Belegdatum";"Beschreibung";"Betrag (EUR)";"Ursprünglicher Betrag"; 8 | "Nein";"01.03.2017";"01.03.2017";"AAA";"-100,00";"-110"; 9 | "Nein";"02.03.2017";"02.03.2017";"BBB";"-200,00";"-210"; 10 | "Nein";"03.03.2017";"03.03.2017";"CCC";"-300,00";"-310"; 11 | -------------------------------------------------------------------------------- /test/mocks/test_parse_no_account_tr.csv: -------------------------------------------------------------------------------- 1 | "Kontonummer:";"xxxxxxxxx / Girokonto"; 2 | 3 | "Von:";"01.03.2017"; 4 | "Bis:";"03.03.2017"; 5 | "Kontostand vom 03.03.2017:";"9.999,99 EUR"; 6 | 7 | "Buchungstag";"Wertstellung";"Buchungstext";"Auftraggeber / Begünstigter";"Verwendungszweck";"Kontonummer";"BLZ";"Betrag (EUR)";"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"; 8 | -------------------------------------------------------------------------------- /test/mocks/test_parse_no_cc_tr.csv: -------------------------------------------------------------------------------- 1 | "Kreditkarte:";"xxx********xxx"; 2 | 3 | "Von:";"01.03.2017"; 4 | "Bis:";"02.03.2017"; 5 | "Saldo:";"0 EUR"; 6 | "Datum:";"31.04.2017"; 7 | 8 | "Umsatz abgerechnet und nicht im Saldo enthalten";"Wertstellung";"Belegdatum";"Beschreibung";"Betrag (EUR)";"Ursprünglicher Betrag"; 9 | -------------------------------------------------------------------------------- /test/test_exemptionorder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=r0904, c0415, c0413, r0913, w0212 3 | """ unittests for dkb_robo """ 4 | import sys 5 | import os 6 | from datetime import date 7 | import unittest 8 | import logging 9 | import json 10 | from unittest.mock import patch, Mock, MagicMock, mock_open 11 | from bs4 import BeautifulSoup 12 | from mechanicalsoup import LinkNotFoundError 13 | import io 14 | 15 | sys.path.insert(0, ".") 16 | sys.path.insert(0, "..") 17 | from dkb_robo.exemptionorder import ExemptionOrders 18 | 19 | 20 | def json_load(fname): 21 | """simple json load""" 22 | 23 | with open(fname, "r", encoding="utf8") as myfile: 24 | data_dic = json.load(myfile) 25 | return data_dic 26 | 27 | 28 | class TestExemptionOrders(unittest.TestCase): 29 | """test class""" 30 | 31 | @patch("requests.Session") 32 | def setUp(self, mock_session): 33 | self.dir_path = os.path.dirname(os.path.realpath(__file__)) 34 | self.exo = ExemptionOrders(client=mock_session) 35 | self.maxDiff = None 36 | 37 | @patch("dkb_robo.exemptionorder.ExemptionOrders._filter") 38 | def test_001_fetch(self, mock_filter): 39 | """test ExemptionOrders.fetch() with uid but http error""" 40 | self.exo.client = Mock() 41 | self.exo.client.get.return_value.status_code = 400 42 | self.exo.client.get.return_value.json.return_value = {"foo": "bar"} 43 | with self.assertRaises(Exception) as err: 44 | self.assertFalse(self.exo.fetch()) 45 | self.assertEqual( 46 | "fetch exemption orders: http status code is not 200 but 400", 47 | str(err.exception), 48 | ) 49 | self.assertFalse(mock_filter.called) 50 | 51 | @patch("dkb_robo.exemptionorder.ExemptionOrders._filter") 52 | def test_002_fetch(self, mock_filter): 53 | """test ExemptionOrders.fetch() with uid no error""" 54 | self.exo.client = Mock() 55 | self.exo.client.get.return_value.status_code = 200 56 | self.exo.client.get.return_value.json.return_value = {"foo": "bar"} 57 | mock_filter.return_value = "mock_filter" 58 | self.assertEqual("mock_filter", self.exo.fetch()) 59 | self.assertTrue(mock_filter.called) 60 | 61 | def test_003__filter(self): 62 | """test ExemptionOrders._filter() with empty list""" 63 | full_list = {} 64 | self.assertFalse(self.exo._filter(full_list)) 65 | 66 | def test_004__filter(self): 67 | """test StandingOrder._filter() with list""" 68 | full_list = { 69 | "data": { 70 | "attributes": { 71 | "exemptionCertificates": [], 72 | "exemptionOrders": [ 73 | { 74 | "exemptionAmount": { 75 | "currencyCode": "EUR", 76 | "value": "2000.00", 77 | }, 78 | "exemptionOrderType": "joint", 79 | "partner": { 80 | "dateOfBirth": "1970-01-01", 81 | "firstName": "Jane", 82 | "lastName": "Doe", 83 | "salutation": "Frau", 84 | "taxId": "1234567890", 85 | }, 86 | "receivedAt": "2020-01-01", 87 | "remainingAmount": { 88 | "currencyCode": "EUR", 89 | "value": "1699.55", 90 | }, 91 | "utilizedAmount": { 92 | "currencyCode": "EUR", 93 | "value": "300.50", 94 | }, 95 | "validFrom": "2020-01-01", 96 | "validUntil": "9999-12-31", 97 | } 98 | ], 99 | }, 100 | "id": "xxxx", 101 | "type": "customerTaxExemptions", 102 | } 103 | } 104 | 105 | result = [ 106 | { 107 | "amount": 2000.0, 108 | "used": 300.5, 109 | "currencycode": "EUR", 110 | "validfrom": "2020-01-01", 111 | "validto": "9999-12-31", 112 | "receivedat": "2020-01-01", 113 | "type": "joint", 114 | "partner": { 115 | "dateofbirth": "1970-01-01", 116 | "firstname": "Jane", 117 | "lastname": "Doe", 118 | "salutation": "Frau", 119 | "taxid": "1234567890", 120 | }, 121 | } 122 | ] 123 | self.assertEqual(result, self.exo._filter(full_list)) 124 | 125 | def test_005__filter(self): 126 | """test StandingOrder._filter() with incomplete list""" 127 | full_list = { 128 | "data": { 129 | "attributes": { 130 | "exemptionCertificates": [], 131 | "exemptionOrders": [ 132 | { 133 | "exemptionAmount": {"currencyCode": "EUR", "value": "aa"}, 134 | "exemptionOrderType": "joint", 135 | "partner": { 136 | "dateOfBirth": "1970-01-01", 137 | "firstName": "Jane", 138 | "lastName": "Doe", 139 | "salutation": "Frau", 140 | "taxId": "1234567890", 141 | }, 142 | "receivedAt": "2020-01-01", 143 | "remainingAmount": {"currencyCode": "EUR", "value": "aa"}, 144 | "utilizedAmount": {"currencyCode": "EUR", "value": "aa"}, 145 | "validFrom": "2020-01-01", 146 | "validUntil": "9999-12-31", 147 | } 148 | ], 149 | }, 150 | "id": "xxxx", 151 | "type": "customerTaxExemptions", 152 | } 153 | } 154 | 155 | result = [ 156 | { 157 | "amount": None, 158 | "used": None, 159 | "currencycode": "EUR", 160 | "validfrom": "2020-01-01", 161 | "validto": "9999-12-31", 162 | "receivedat": "2020-01-01", 163 | "type": "joint", 164 | "partner": { 165 | "dateofbirth": "1970-01-01", 166 | "firstname": "Jane", 167 | "lastname": "Doe", 168 | "salutation": "Frau", 169 | "taxid": "1234567890", 170 | }, 171 | } 172 | ] 173 | with self.assertLogs("dkb_robo", level="INFO") as lcm: 174 | self.assertEqual(result, self.exo._filter(full_list)) 175 | self.assertIn( 176 | "ERROR:dkb_robo.utilities:Account.__post_init: value conversion error: could not convert string to float: 'aa'", 177 | lcm.output, 178 | ) 179 | 180 | def test_006__filter(self): 181 | """test StandingOrder._filter() with list""" 182 | full_list = { 183 | "data": { 184 | "attributes": { 185 | "exemptionCertificates": [], 186 | "exemptionOrders": [ 187 | { 188 | "exemptionAmount": { 189 | "currencyCode": "EUR", 190 | "value": "2000.00", 191 | }, 192 | "exemptionOrderType": "joint", 193 | "partner": { 194 | "dateOfBirth": "1970-01-01", 195 | "firstName": "Jane", 196 | "lastName": "Doe", 197 | "salutation": "Frau", 198 | "taxId": "1234567890", 199 | }, 200 | "receivedAt": "2020-01-01", 201 | "remainingAmount": { 202 | "currencyCode": "EUR", 203 | "value": "1699.55", 204 | }, 205 | "utilizedAmount": { 206 | "currencyCode": "EUR", 207 | "value": "300.50", 208 | }, 209 | "validFrom": "2020-01-01", 210 | "validUntil": "9999-12-31", 211 | } 212 | ], 213 | }, 214 | "id": "xxxx", 215 | "type": "customerTaxExemptions", 216 | } 217 | } 218 | 219 | self.exo.unfiltered = True 220 | result = self.exo._filter(full_list) 221 | self.assertEqual("2020-01-01", result[0].receivedAt) 222 | self.assertEqual("EUR", result[0].exemptionAmount.currencyCode) 223 | self.assertEqual(2000, result[0].exemptionAmount.value) 224 | self.assertEqual("EUR", result[0].remainingAmount.currencyCode) 225 | self.assertEqual(1699.55, result[0].remainingAmount.value) 226 | self.assertEqual("EUR", result[0].utilizedAmount.currencyCode) 227 | self.assertEqual(300.5, result[0].utilizedAmount.value) 228 | self.assertEqual("joint", result[0].exemptionOrderType) 229 | self.assertEqual("2020-01-01", result[0].validFrom) 230 | self.assertEqual("9999-12-31", result[0].validUntil) 231 | self.assertEqual("1970-01-01", result[0].partner.dateOfBirth) 232 | self.assertEqual("Jane", result[0].partner.firstName) 233 | self.assertEqual("Doe", result[0].partner.lastName) 234 | self.assertEqual("Frau", result[0].partner.salutation) 235 | self.assertEqual("1234567890", result[0].partner.taxId) 236 | 237 | 238 | if __name__ == "__main__": 239 | 240 | unittest.main() 241 | -------------------------------------------------------------------------------- /test/test_standingorder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=r0904, c0415, c0413, r0913, w0212 3 | """ unittests for dkb_robo """ 4 | import sys 5 | import os 6 | from datetime import date 7 | import unittest 8 | import logging 9 | import json 10 | from unittest.mock import patch, Mock, MagicMock, mock_open 11 | from bs4 import BeautifulSoup 12 | from mechanicalsoup import LinkNotFoundError 13 | import io 14 | 15 | sys.path.insert(0, ".") 16 | sys.path.insert(0, "..") 17 | from dkb_robo.standingorder import StandingOrders 18 | 19 | 20 | def json_load(fname): 21 | """simple json load""" 22 | 23 | with open(fname, "r", encoding="utf8") as myfile: 24 | data_dic = json.load(myfile) 25 | 26 | return data_dic 27 | 28 | 29 | class TestDKBRobo(unittest.TestCase): 30 | """test class""" 31 | 32 | @patch("requests.Session") 33 | def setUp(self, mock_session): 34 | self.dir_path = os.path.dirname(os.path.realpath(__file__)) 35 | self.logger = logging.getLogger("dkb_robo") 36 | self.dkb = StandingOrders(client=mock_session) 37 | self.maxDiff = None 38 | 39 | @patch("dkb_robo.standingorder.StandingOrders._filter") 40 | def test_001_fetch(self, mock_filter): 41 | """test StandingOrders.fetch() without uid""" 42 | self.dkb.client = Mock() 43 | self.dkb.client.get.return_value.status_code = 200 44 | self.dkb.client.get.return_value.json.return_value = {"foo": "bar"} 45 | 46 | with self.assertRaises(Exception) as err: 47 | self.assertFalse(self.dkb.fetch(None)) 48 | self.assertEqual( 49 | "account-id is required to fetch standing orders", str(err.exception) 50 | ) 51 | self.assertFalse(mock_filter.called) 52 | 53 | @patch("dkb_robo.standingorder.StandingOrders._filter") 54 | def test_002_fetch(self, mock_filter): 55 | """test StandingOrders.fetch() with uid but http error""" 56 | self.dkb.client = Mock() 57 | self.dkb.client.get.return_value.status_code = 400 58 | self.dkb.client.get.return_value.json.return_value = {"foo": "bar"} 59 | self.assertFalse(self.dkb.fetch(uid="uid")) 60 | self.assertFalse(mock_filter.called) 61 | 62 | @patch("dkb_robo.standingorder.StandingOrders._filter") 63 | def test_003_fetch(self, mock_filter): 64 | """test StandingOrders.fetch() with uid no error""" 65 | self.dkb.client = Mock() 66 | self.dkb.client.get.return_value.status_code = 200 67 | self.dkb.client.get.return_value.json.return_value = {"foo": "bar"} 68 | mock_filter.return_value = "mock_filter" 69 | self.assertEqual("mock_filter", self.dkb.fetch(uid="uid")) 70 | self.assertTrue(mock_filter.called) 71 | 72 | def test_004__filter(self): 73 | """test StandingOrders._filter() with empty list""" 74 | full_list = {} 75 | self.assertFalse(self.dkb._filter(full_list)) 76 | 77 | def test_005__filter(self): 78 | """test StandingOrders._filter() with list""" 79 | full_list = { 80 | "data": [ 81 | { 82 | "attributes": { 83 | "description": "description", 84 | "amount": {"currencyCode": "EUR", "value": "100.00"}, 85 | "creditor": { 86 | "name": "cardname", 87 | "creditorAccount": {"iban": "crediban", "bic": "credbic"}, 88 | }, 89 | "recurrence": { 90 | "from": "2020-01-01", 91 | "until": "2025-12-01", 92 | "frequency": "monthly", 93 | "nextExecutionAt": "2020-02-01", 94 | }, 95 | } 96 | } 97 | ] 98 | } 99 | result = [ 100 | { 101 | "amount": 100.0, 102 | "currencycode": "EUR", 103 | "purpose": "description", 104 | "recipient": "cardname", 105 | "creditoraccount": {"iban": "crediban", "bic": "credbic"}, 106 | "interval": { 107 | "from": "2020-01-01", 108 | "until": "2025-12-01", 109 | "frequency": "monthly", 110 | "nextExecutionAt": "2020-02-01", 111 | "holidayExecutionStrategy": None, 112 | }, 113 | } 114 | ] 115 | self.assertEqual(result, self.dkb._filter(full_list)) 116 | 117 | def test_006__filter(self): 118 | """test StandingOrders._filter() with list from file""" 119 | so_list = json_load(self.dir_path + "/mocks/so.json") 120 | self.dkb.unfiltered = False 121 | result = [ 122 | { 123 | "amount": 100.0, 124 | "currencycode": "EUR", 125 | "purpose": "description1", 126 | "recipient": "name1", 127 | "creditoraccount": {"iban": "iban1", "bic": "bic1"}, 128 | "interval": { 129 | "from": "2022-01-01", 130 | "until": "2025-12-01", 131 | "frequency": "monthly", 132 | "holidayExecutionStrategy": "following", 133 | "nextExecutionAt": "2022-11-01", 134 | }, 135 | }, 136 | { 137 | "amount": 200.0, 138 | "currencycode": "EUR", 139 | "purpose": "description2", 140 | "recipient": "name2", 141 | "creditoraccount": {"iban": "iban2", "bic": "bic2"}, 142 | "interval": { 143 | "from": "2022-02-01", 144 | "until": "2025-12-02", 145 | "frequency": "monthly", 146 | "holidayExecutionStrategy": "following", 147 | "nextExecutionAt": "2022-11-02", 148 | }, 149 | }, 150 | { 151 | "amount": 300.0, 152 | "currencycode": "EUR", 153 | "purpose": "description3", 154 | "recipient": "name3", 155 | "creditoraccount": {"iban": "iban3", "bic": "bic3"}, 156 | "interval": { 157 | "from": "2022-03-01", 158 | "until": "2025-03-01", 159 | "frequency": "monthly", 160 | "holidayExecutionStrategy": "following", 161 | "nextExecutionAt": "2022-03-01", 162 | }, 163 | }, 164 | ] 165 | self.assertEqual(result, self.dkb._filter(so_list)) 166 | 167 | def test_007__filter(self): 168 | """test StandingOrders._filter() with incomplete list/conversion error""" 169 | full_list = { 170 | "data": [ 171 | { 172 | "attributes": { 173 | "description": "description", 174 | "amount": {"value": "aa"}, 175 | "creditor": { 176 | "name": "cardname", 177 | "creditorAccount": {"iban": "crediban", "bic": "credbic"}, 178 | }, 179 | "recurrence": { 180 | "from": "2020-01-01", 181 | "until": "2025-12-01", 182 | "frequency": "monthly", 183 | "nextExecutionAt": "2020-02-01", 184 | }, 185 | } 186 | } 187 | ] 188 | } 189 | result = [ 190 | { 191 | "amount": None, 192 | "currencycode": None, 193 | "purpose": "description", 194 | "recipient": "cardname", 195 | "creditoraccount": {"iban": "crediban", "bic": "credbic"}, 196 | "interval": { 197 | "from": "2020-01-01", 198 | "until": "2025-12-01", 199 | "frequency": "monthly", 200 | "nextExecutionAt": "2020-02-01", 201 | "holidayExecutionStrategy": None, 202 | }, 203 | } 204 | ] 205 | with self.assertLogs("dkb_robo", level="INFO") as lcm: 206 | self.assertEqual(result, self.dkb._filter(full_list)) 207 | self.assertIn( 208 | "ERROR:dkb_robo.utilities:Account.__post_init: value conversion error: could not convert string to float: 'aa'", 209 | lcm.output, 210 | ) 211 | 212 | def test_008__filter(self): 213 | """test StandingOrders._filter() with incomplete list/conversion error""" 214 | full_list = { 215 | "data": [ 216 | { 217 | "attributes": { 218 | "description": "description", 219 | "amount": {"value": "100"}, 220 | "creditor": { 221 | "name": "cardname", 222 | "creditorAccount": {"iban": "crediban", "bic": "credbic"}, 223 | }, 224 | "recurrence": { 225 | "from": "2020-01-01", 226 | "until": "2025-12-01", 227 | "frequency": "monthly", 228 | "nextExecutionAt": "2020-02-01", 229 | }, 230 | } 231 | } 232 | ] 233 | } 234 | self.dkb.unfiltered = True 235 | result = self.dkb._filter(full_list) 236 | self.assertEqual(100, result[0].amount.value) 237 | self.assertEqual("description", result[0].description) 238 | self.assertEqual("cardname", result[0].creditor.name) 239 | self.assertEqual("crediban", result[0].creditor.iban) 240 | self.assertEqual("credbic", result[0].creditor.bic) 241 | self.assertEqual("2020-01-01", result[0].recurrence.frm) 242 | self.assertEqual("monthly", result[0].recurrence.frequency) 243 | self.assertEqual("2025-12-01", result[0].recurrence.until) 244 | self.assertEqual("2020-02-01", result[0].recurrence.nextExecutionAt) 245 | self.assertIsNone(result[0].recurrence.holidayExecutionStrategy) 246 | 247 | 248 | if __name__ == "__main__": 249 | 250 | unittest.main() 251 | -------------------------------------------------------------------------------- /test/test_utilities.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=r0904, c0415 3 | """ unittests for dkb_robo """ 4 | import sys 5 | import os 6 | import unittest 7 | import logging 8 | from unittest.mock import patch, Mock, MagicMock 9 | from dataclasses import dataclass, asdict 10 | 11 | sys.path.insert(0, ".") 12 | sys.path.insert(0, "..") 13 | 14 | 15 | @dataclass 16 | class DataclassObject: 17 | Attr1: str 18 | attr2: int 19 | attr3: dict 20 | attr4: list 21 | 22 | 23 | class TestDKBRobo(unittest.TestCase): 24 | """test class""" 25 | 26 | maxDiff = None 27 | 28 | def setUp(self): 29 | self.dir_path = os.path.dirname(os.path.realpath(__file__)) 30 | from dkb_robo.utilities import ( 31 | validate_dates, 32 | generate_random_string, 33 | logger_setup, 34 | string2float, 35 | _convert_date_format, 36 | get_dateformat, 37 | get_valid_filename, 38 | object2dictionary, 39 | logger_setup, 40 | ulal, 41 | ) 42 | 43 | self.validate_dates = validate_dates 44 | self.string2float = string2float 45 | self.generate_random_string = generate_random_string 46 | self.logger_setup = logger_setup 47 | self._convert_date_format = _convert_date_format 48 | self.get_dateformat = get_dateformat 49 | self.get_valid_filename = get_valid_filename 50 | self.object2dictionary = object2dictionary 51 | self.logger = logging.getLogger("dkb_robo") 52 | self.logger_setup = logger_setup 53 | self.ulal = ulal 54 | 55 | @patch("time.time") 56 | def test_001_validate_dates(self, mock_time): 57 | """test validate dates with correct data""" 58 | date_from = "01.01.2024" 59 | date_to = "10.01.2024" 60 | mock_time.return_value = 1726309859 61 | self.assertEqual( 62 | ("2024-01-01", "2024-01-10"), self.validate_dates(date_from, date_to) 63 | ) 64 | 65 | @patch("time.time") 66 | def test_002_validate_dates(self, mock_time): 67 | """test validate dates dates to be corrected""" 68 | date_from = "12.12.2021" 69 | date_to = "11.12.2021" 70 | mock_time.return_value = 1726309859 71 | with self.assertLogs("dkb_robo", level="INFO") as lcm: 72 | self.assertEqual( 73 | ("2022-01-01", "2022-01-01"), self.validate_dates(date_from, date_to) 74 | ) 75 | self.assertIn( 76 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_from to 2022-01-01", 77 | lcm.output, 78 | ) 79 | self.assertIn( 80 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_to to 2022-01-01", 81 | lcm.output, 82 | ) 83 | 84 | @patch("time.time") 85 | def test_003_validate_dates(self, mock_time): 86 | """test validate dates with correct data""" 87 | date_from = "2024-01-01" 88 | date_to = "2024-01-10" 89 | mock_time.return_value = 1726309859 90 | self.assertEqual( 91 | ("2024-01-01", "2024-01-10"), self.validate_dates(date_from, date_to) 92 | ) 93 | 94 | @patch("time.time") 95 | def test_004_validate_dates(self, mock_time): 96 | """test validate dates dates to be corrected""" 97 | date_from = "2021-12-01" 98 | date_to = "2021-12-11" 99 | mock_time.return_value = 1726309859 100 | with self.assertLogs("dkb_robo", level="INFO") as lcm: 101 | self.assertEqual( 102 | ("2022-01-01", "2022-01-01"), self.validate_dates(date_from, date_to) 103 | ) 104 | self.assertIn( 105 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_from to 2022-01-01", 106 | lcm.output, 107 | ) 108 | self.assertIn( 109 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_to to 2022-01-01", 110 | lcm.output, 111 | ) 112 | 113 | @patch("time.time") 114 | def test_005_validate_dates(self, mock_time): 115 | """test validate dates dates to be corrected""" 116 | date_from = "01.12.2024" 117 | date_to = "11.12.2024" 118 | mock_time.return_value = 1726309859 119 | with self.assertLogs("dkb_robo", level="INFO") as lcm: 120 | self.assertEqual( 121 | ("2024-09-14", "2024-09-14"), self.validate_dates(date_from, date_to) 122 | ) 123 | self.assertIn( 124 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_from to 2024-09-14", 125 | lcm.output, 126 | ) 127 | self.assertIn( 128 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_to to 2024-09-14", 129 | lcm.output, 130 | ) 131 | 132 | @patch("time.time") 133 | def test_006_validate_dates(self, mock_time): 134 | """test validate dates dates to be corrected""" 135 | date_from = "2024-12-01" 136 | date_to = "2024-12-11" 137 | mock_time.return_value = 1726309859 138 | with self.assertLogs("dkb_robo", level="INFO") as lcm: 139 | self.assertEqual( 140 | ("2024-09-14", "2024-09-14"), self.validate_dates(date_from, date_to) 141 | ) 142 | self.assertIn( 143 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_from to 2024-09-14", 144 | lcm.output, 145 | ) 146 | self.assertIn( 147 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_to to 2024-09-14", 148 | lcm.output, 149 | ) 150 | 151 | @patch("time.time") 152 | def test_007_validate_dates(self, mock_time): 153 | """test validate dates with correct data""" 154 | date_from = "15.01.2024" 155 | date_to = "10.01.2024" 156 | mock_time.return_value = 1726309859 157 | with self.assertLogs("dkb_robo", level="INFO") as lcm: 158 | self.assertEqual( 159 | ("2024-01-10", "2024-01-10"), self.validate_dates(date_from, date_to) 160 | ) 161 | self.assertIn( 162 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_from to date_to", 163 | lcm.output, 164 | ) 165 | 166 | @patch("time.time") 167 | def test_008_validate_dates(self, mock_time): 168 | """test validate dates with correct data""" 169 | date_from = "2024-01-15" 170 | date_to = "2024-01-10" 171 | mock_time.return_value = 1726309859 172 | with self.assertLogs("dkb_robo", level="INFO") as lcm: 173 | self.assertEqual( 174 | ("2024-01-10", "2024-01-10"), self.validate_dates(date_from, date_to) 175 | ) 176 | self.assertIn( 177 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_from to date_to", 178 | lcm.output, 179 | ) 180 | 181 | @patch("time.time") 182 | def test_009_validate_dates(self, mock_time): 183 | """test validate dates with correct data""" 184 | date_from = "15.01.2024" 185 | date_to = "10.01.2024" 186 | mock_time.return_value = 1726309859 187 | with self.assertLogs("dkb_robo", level="INFO") as lcm: 188 | self.assertEqual( 189 | ("2024-01-10", "2024-01-10"), self.validate_dates(date_from, date_to) 190 | ) 191 | self.assertIn( 192 | "INFO:dkb_robo.utilities:validate_dates(): adjust date_from to date_to", 193 | lcm.output, 194 | ) 195 | 196 | @patch("random.choice") 197 | def test_010_generate_random_string(self, mock_rc): 198 | """test generate_random_string""" 199 | mock_rc.return_value = "1a" 200 | length = 5 201 | self.assertEqual("1a1a1a1a1a", self.generate_random_string(length)) 202 | 203 | @patch("random.choice") 204 | def test_011_generate_random_string(self, mock_rc): 205 | """generate_random_string""" 206 | mock_rc.return_value = "1a" 207 | length = 10 208 | self.assertEqual("1a1a1a1a1a1a1a1a1a1a", self.generate_random_string(length)) 209 | 210 | def test_012_string2float(self): 211 | """test string2float""" 212 | value = 1000 213 | self.assertEqual(1000.0, self.string2float(value)) 214 | 215 | def test_013_string2float(self): 216 | """test string2float""" 217 | value = 1000.0 218 | self.assertEqual(1000.0, self.string2float(value)) 219 | 220 | def test_014_string2float(self): 221 | """test string2float""" 222 | value = "1.000,00" 223 | self.assertEqual(1000.0, self.string2float(value)) 224 | 225 | def test_015_string2float(self): 226 | """test string2float""" 227 | value = "1000,00" 228 | self.assertEqual(1000.0, self.string2float(value)) 229 | 230 | def test_016_string2float(self): 231 | """test string2float""" 232 | value = "1.000" 233 | self.assertEqual(1000.0, self.string2float(value)) 234 | 235 | def test_017_string2float(self): 236 | """test string2float""" 237 | value = "1.000,23" 238 | self.assertEqual(1000.23, self.string2float(value)) 239 | 240 | def test_018_string2float(self): 241 | """test string2float""" 242 | value = "1000,23" 243 | self.assertEqual(1000.23, self.string2float(value)) 244 | 245 | def test_019_string2float(self): 246 | """test string2float""" 247 | value = 1000.23 248 | self.assertEqual(1000.23, self.string2float(value)) 249 | 250 | def test_020_string2float(self): 251 | """test string2float""" 252 | value = "-1.000" 253 | self.assertEqual(-1000.0, self.string2float(value)) 254 | 255 | def test_021_string2float(self): 256 | """test string2float""" 257 | value = "-1.000,23" 258 | self.assertEqual(-1000.23, self.string2float(value)) 259 | 260 | def test_022_string2float(self): 261 | """test string2float""" 262 | value = "-1000,23" 263 | self.assertEqual(-1000.23, self.string2float(value)) 264 | 265 | def test_023_string2float(self): 266 | """test string2float""" 267 | value = -1000.23 268 | self.assertEqual(-1000.23, self.string2float(value)) 269 | 270 | def test_024__convert_date_format(self): 271 | """test _convert_date_format()""" 272 | self.assertEqual( 273 | "01.01.2023", 274 | self._convert_date_format("2023/01/01", ["%Y/%m/%d"], "%d.%m.%Y"), 275 | ) 276 | 277 | def test_025__convert_date_format(self): 278 | """test _convert_date_format()""" 279 | self.assertEqual( 280 | "wrong date", 281 | self._convert_date_format("wrong date", ["%Y/%m/%d"], "%d.%m.%Y"), 282 | ) 283 | 284 | def test_026__convert_date_format(self): 285 | """test _convert_date_format() first match""" 286 | self.assertEqual( 287 | "01.01.2023", 288 | self._convert_date_format( 289 | "2023/01/01", ["%Y/%m/%d", "%d.%m.%Y"], "%d.%m.%Y" 290 | ), 291 | ) 292 | 293 | def test_027__convert_date_format(self): 294 | """test _convert_date_format() last match""" 295 | self.assertEqual( 296 | "01.01.2023", 297 | self._convert_date_format( 298 | "2023/01/01", ["%d.%m.%Y", "%Y/%m/%d"], "%d.%m.%Y" 299 | ), 300 | ) 301 | 302 | def test_028__convert_date_format(self): 303 | """test _convert_date_format() last match""" 304 | self.assertEqual( 305 | "2023/01/01", 306 | self._convert_date_format( 307 | "2023/01/01", ["%Y/%m/%d", "%d.%m.%Y"], "%Y/%m/%d" 308 | ), 309 | ) 310 | 311 | def test_029__convert_date_format(self): 312 | """test _convert_date_format() first match""" 313 | self.assertEqual( 314 | "2023/01/01", 315 | self._convert_date_format( 316 | "2023/01/01", ["%d.%m.%Y", "%Y/%m/%d"], "%Y/%m/%d" 317 | ), 318 | ) 319 | 320 | def test_030__convert_date_format(self): 321 | """test _convert_date_format() no match""" 322 | self.assertEqual( 323 | "wrong date", 324 | self._convert_date_format( 325 | "wrong date", ["%Y/%m/%d", "%Y-%m-%d"], "%d.%m.%Y" 326 | ), 327 | ) 328 | 329 | def test_039__get_valid_filename(self): 330 | """test get_valid_filename""" 331 | filename = "test.pdf" 332 | self.assertEqual("test.pdf", self.get_valid_filename(filename)) 333 | 334 | def test_040__get_valid_filename(self): 335 | """test get_valid_filename""" 336 | filename = "test test.pdf" 337 | self.assertEqual("test_test.pdf", self.get_valid_filename(filename)) 338 | 339 | def test_041__get_valid_filename(self): 340 | """test get_valid_filename""" 341 | filename = "testötest.pdf" 342 | self.assertEqual("testötest.pdf", self.get_valid_filename(filename)) 343 | 344 | def test_042__get_valid_filename(self): 345 | """test get_valid_filename""" 346 | filename = "test/test.pdf" 347 | self.assertEqual("test_test.pdf", self.get_valid_filename(filename)) 348 | 349 | def test_043_get_valid_filename(self): 350 | """test get_valid_filename""" 351 | filename = "test\\test.pdf" 352 | self.assertEqual("test_test.pdf", self.get_valid_filename(filename)) 353 | 354 | def test_044_get_valid_filename(self): 355 | """test get_valid_filename""" 356 | filename = ".\test.pdf" 357 | self.assertEqual("._est.pdf", self.get_valid_filename(filename)) 358 | 359 | def test_045_get_valid_filename(self): 360 | """test get_valid_filename""" 361 | filename = "../test.pdf" 362 | self.assertEqual(".._test.pdf", self.get_valid_filename(filename)) 363 | 364 | @patch("dkb_robo.utilities.generate_random_string") 365 | def test_046_get_valid_filename(self, mock_rand): 366 | """test get_valid_filename""" 367 | filename = ".." 368 | mock_rand.return_value = "random" 369 | self.assertEqual("random.pdf", self.get_valid_filename(filename)) 370 | 371 | def test_047_object2dictionary(self): 372 | """test object2dictionary""" 373 | 374 | nested_obj = DataclassObject( 375 | Attr1="nested_value1", attr2=3, attr3="nested_value1", attr4="nested_value2" 376 | ) 377 | 378 | test_obj = DataclassObject( 379 | Attr1="value1", attr2=2, attr3=nested_obj, attr4="foo" 380 | ) 381 | 382 | expected_output = { 383 | "Attr1": "value1", 384 | "attr2": 2, 385 | "attr3": { 386 | "Attr1": "nested_value1", 387 | "attr2": 3, 388 | "attr3": "nested_value1", 389 | "attr4": "nested_value2", 390 | }, 391 | "attr4": "foo", 392 | } 393 | 394 | result = self.object2dictionary(test_obj) 395 | self.assertEqual(result, expected_output) 396 | 397 | def test_048_object2dictionary(self): 398 | """test object2dictionary""" 399 | test_obj = DataclassObject( 400 | Attr1="value1", attr2=2, attr3="attr3", attr4="attr4" 401 | ) 402 | expected_output = {"attr1": "value1", "attr3": "attr3", "attr4": "attr4"} 403 | result = self.object2dictionary(test_obj, key_lc=True, skip_list=["attr2"]) 404 | self.assertEqual(result, expected_output) 405 | 406 | def test_049_logger_setup(self): 407 | """logger setup""" 408 | self.assertTrue(self.logger_setup(False)) 409 | 410 | def test_050_logger_setup(self): 411 | """logger setup""" 412 | self.assertTrue(self.logger_setup(True)) 413 | 414 | def test_051_ulal(self): 415 | """test ulal() with_valid_parameter""" 416 | mapclass = Mock() 417 | parameter = {"key1": "value1", "key2": "value2"} 418 | result = self.ulal(mapclass, parameter) 419 | mapclass.assert_called_once_with(**parameter) 420 | self.assertEqual(result, mapclass.return_value) 421 | 422 | def test_052_ulal(self): 423 | """test ulal() with none parameter""" 424 | mapclass = MagicMock() 425 | parameter = None 426 | result = self.ulal(mapclass, parameter) 427 | mapclass.assert_not_called() 428 | self.assertIsNone(result) 429 | 430 | 431 | class TestAmount(unittest.TestCase): 432 | """test class""" 433 | 434 | def setUp(self): 435 | self.dir_path = os.path.dirname(os.path.realpath(__file__)) 436 | from dkb_robo.utilities import Amount 437 | 438 | self.Amount = Amount 439 | 440 | @patch("dkb_robo.utilities.logger", autospec=True) 441 | def test_047_amount(self, mock_logger): 442 | """test Amount""" 443 | amount_data = { 444 | "value": "1000", 445 | "currencyCode": "USD", 446 | "conversionRate": "1.2", 447 | "date": "2022-01-01", 448 | "unit": "unit", 449 | } 450 | amount = self.Amount(**amount_data) 451 | self.assertEqual(amount.value, 1000.0) 452 | self.assertEqual(amount.currencyCode, "USD") 453 | self.assertEqual(amount.conversionRate, 1.2) 454 | self.assertEqual(amount.date, "2022-01-01") 455 | self.assertEqual(amount.unit, "unit") 456 | mock_logger.error.assert_not_called() 457 | 458 | @patch("dkb_robo.utilities.logger", autospec=True) 459 | def test_049_amount(self, mock_logger): 460 | """test Amount with wrong value""" 461 | amount_data = { 462 | "value": "invalid", 463 | "currencyCode": "USD", 464 | "conversionRate": "1.2", 465 | "date": "2022-01-01", 466 | "unit": "unit", 467 | } 468 | amount = self.Amount(**amount_data) 469 | self.assertFalse(amount.value) 470 | self.assertEqual(amount.currencyCode, "USD") 471 | self.assertEqual(amount.conversionRate, 1.2) 472 | self.assertEqual(amount.date, "2022-01-01") 473 | self.assertEqual(amount.unit, "unit") 474 | mock_logger.error.assert_called_with( 475 | "Account.__post_init: value conversion error: %s", 476 | "could not convert string to float: 'invalid'", 477 | ) 478 | 479 | @patch("dkb_robo.utilities.logger", autospec=True) 480 | def test_050_amount(self, mock_logger): 481 | """test Amount with wrong conversation rate""" 482 | amount_data = { 483 | "value": "1000", 484 | "currencyCode": "USD", 485 | "conversionRate": "invalid", 486 | "date": "2022-01-01", 487 | "unit": "unit", 488 | } 489 | amount = self.Amount(**amount_data) 490 | self.assertEqual(amount.value, 1000) 491 | self.assertEqual(amount.currencyCode, "USD") 492 | self.assertFalse(amount.conversionRate) 493 | self.assertEqual(amount.date, "2022-01-01") 494 | self.assertEqual(amount.unit, "unit") 495 | mock_logger.error.assert_called_with( 496 | "Account.__post_init: converstionRate conversion error: %s", 497 | "could not convert string to float: 'invalid'", 498 | ) 499 | 500 | 501 | class TestPerformanceValue(unittest.TestCase): 502 | def setUp(self): 503 | self.dir_path = os.path.dirname(os.path.realpath(__file__)) 504 | from dkb_robo.utilities import PerformanceValue 505 | 506 | self.PerformanceValue = PerformanceValue 507 | 508 | @patch("dkb_robo.utilities.logger", autospec=True) 509 | def test_051_performancevalue(self, mock_logger): 510 | performance_value_data = { 511 | "currencyCode": "USD", 512 | "value": "1000", 513 | "unit": "unit", 514 | } 515 | 516 | performance_value = self.PerformanceValue(**performance_value_data) 517 | 518 | self.assertEqual(performance_value.currencyCode, "USD") 519 | self.assertEqual(performance_value.value, 1000.0) 520 | self.assertEqual(performance_value.unit, "unit") 521 | mock_logger.error.assert_not_called() 522 | 523 | @patch("dkb_robo.utilities.logger", autospec=True) 524 | def test_052_performancevalue(self, mock_logger): 525 | performance_value_data = { 526 | "currencyCode": "USD", 527 | "value": "invalid", 528 | "unit": "unit", 529 | } 530 | 531 | performance_value = self.PerformanceValue(**performance_value_data) 532 | 533 | self.assertEqual(performance_value.currencyCode, "USD") 534 | self.assertIsNone(performance_value.value) 535 | self.assertEqual(performance_value.unit, "unit") 536 | mock_logger.error.assert_called_with( 537 | "PerformanceValue.__post_init: conversion error: %s", 538 | "could not convert string to float: 'invalid'", 539 | ) 540 | 541 | 542 | if __name__ == "__main__": 543 | 544 | unittest.main() 545 | --------------------------------------------------------------------------------