├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── codeql.yml │ ├── dependency-review.yml │ └── sonarcloud.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── comdirect ├── README.md ├── cmd │ ├── account.go │ ├── balance.go │ ├── depot.go │ ├── document.go │ ├── login.go │ ├── logout.go │ ├── position.go │ ├── report.go │ ├── root.go │ ├── transaction.go │ └── version.go ├── keychain │ └── keychain.go └── main.go ├── docs ├── .nojekyll ├── README.md ├── _sidebar.md ├── contributing.md ├── getting-started.md └── index.html ├── go.mod ├── go.sum ├── internal ├── httpstatus │ └── status.go └── mediatype │ └── types.go ├── pkg └── comdirect │ ├── account.go │ ├── account_test.go │ ├── auth.go │ ├── auth_test.go │ ├── client.go │ ├── client_test.go │ ├── depot.go │ ├── depot_test.go │ ├── document.go │ ├── document_test.go │ ├── instrument.go │ ├── instrument_test.go │ ├── order.go │ ├── order_test.go │ ├── quote.go │ ├── quote_test.go │ ├── report.go │ ├── report_test.go │ └── util.go └── sonar-project.properties /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'gomod' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | - package-ecosystem: 'github-actions' 8 | directory: '/' 9 | schedule: 10 | interval: 'weekly' -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | pull_request: 4 | branches: 5 | - 'main' 6 | jobs: 7 | build: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - uses: actions/setup-go@v4 15 | with: 16 | go-version-file: 'go.mod' 17 | - run: | 18 | go build ./... -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | pull_request: 7 | branches: 8 | - 'main' 9 | 10 | jobs: 11 | analyze: 12 | name: Analyze 13 | runs-on: ubuntu-latest 14 | permissions: 15 | actions: read 16 | contents: read 17 | security-events: write 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | language: [ 'go' ] 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | 28 | # Initializes the CodeQL tools for scanning. 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | 34 | 35 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 36 | # If this step fails, then you should remove it and run the build manually (see below) 37 | - name: Autobuild 38 | uses: github/codeql-action/autobuild@v2 39 | 40 | # ℹ️ Command-line programs to run using the OS shell. 41 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 42 | 43 | # If the Autobuild fails above, remove it and uncomment the following three lines. 44 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 45 | 46 | # - run: | 47 | # echo "Run, Build Application using script" 48 | # ./location_of_script_within_repo/buildscript.sh 49 | 50 | - name: Perform CodeQL Analysis 51 | uses: github/codeql-action/analyze@v2 52 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: Dependency Review 2 | on: 3 | pull_request: 4 | branches: 5 | - 'main' 6 | permissions: 7 | contents: read 8 | jobs: 9 | dependency-review: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: 'Checkout Repository' 13 | uses: actions/checkout@v4 14 | - name: Dependency Review 15 | uses: actions/dependency-review-action@v3 16 | with: 17 | fail-on-severity: 'high' -------------------------------------------------------------------------------- /.github/workflows/sonarcloud.yml: -------------------------------------------------------------------------------- 1 | name: SonarCloud 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | pull_request: 7 | branches: 8 | - 'main' 9 | jobs: 10 | sonarcloud: 11 | name: SonarCloud 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: SonarCloud Scan 18 | uses: SonarSource/sonarcloud-github-action@master 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | .idea/ 6 | # User-specific stuff 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/**/usage.statistics.xml 10 | .idea/**/dictionaries 11 | .idea/**/shelf 12 | 13 | # Generated files 14 | .idea/**/contentModel.xml 15 | 16 | # Sensitive or high-churn files 17 | .idea/**/dataSources/ 18 | .idea/**/dataSources.ids 19 | .idea/**/dataSources.local.xml 20 | .idea/**/sqlDataSources.xml 21 | .idea/**/dynamic.xml 22 | .idea/**/uiDesigner.xml 23 | .idea/**/dbnavigator.xml 24 | 25 | # Gradle 26 | .idea/**/gradle.xml 27 | .idea/**/libraries 28 | 29 | # Gradle and Maven with auto-import 30 | # When using Gradle or Maven with auto-import, you should exclude module files, 31 | # since they will be recreated, and may cause churn. Uncomment if using 32 | # auto-import. 33 | # .idea/artifacts 34 | # .idea/compiler.xml 35 | # .idea/jarRepositories.xml 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | *.iml 40 | # *.ipr 41 | 42 | # CMake 43 | cmake-build-*/ 44 | 45 | # Mongo Explorer plugin 46 | .idea/**/mongoSettings.xml 47 | 48 | # File-based project format 49 | *.iws 50 | 51 | # IntelliJ 52 | out/ 53 | 54 | # mpeltonen/sbt-idea plugin 55 | .idea_modules/ 56 | 57 | # JIRA plugin 58 | atlassian-ide-plugin.xml 59 | 60 | # Cursive Clojure plugin 61 | .idea/replstate.xml 62 | 63 | # Crashlytics plugin (for Android Studio and IntelliJ) 64 | com_crashlytics_export_strings.xml 65 | crashlytics.properties 66 | crashlytics-build.properties 67 | fabric.properties 68 | 69 | # Editor-based Rest Client 70 | .idea/httpRequests 71 | 72 | # Android studio 3.1+ serialized cache file 73 | .idea/caches/build_file_checksums.ser 74 | 75 | ### Go template 76 | # Binaries for programs and plugins 77 | *.exe 78 | *.exe~ 79 | *.dll 80 | *.so 81 | *.dylib 82 | 83 | # Test binary, built with `go test -c` 84 | *.test 85 | 86 | # Output of the go coverage tool, specifically when used with LiteIDE 87 | *.out 88 | 89 | # Dependency directories (remove the comment below to include it) 90 | # vendor/ 91 | 92 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to this project! 4 | We appreciate any contributions that help improve the project and make it even better. 5 | To ensure a smooth and collaborative development process, please follow these guidelines when contributing. 6 | 7 | ## Opening an Issue 8 | Before starting to work on a new feature or bug fix, it is recommended to create an issue on the GitHub repository. 9 | This allows for discussion and ensures that your contribution aligns with the project's goals. 10 | 11 | ## Opening a Pull Request 12 | To contribute your changes, please follow these steps: 13 | 1. Create a new branch from the main branch of your local repository or create branch directly from the issue. 14 | 2. Make your changes and commit them following the conventional commit format. 15 | 3. Provide a descriptive title and a detailed description of your changes in the pull request. 16 | 4. Submit the pull request. 17 | 18 | ### Conventional Commits 19 | Conventional Commits 20 | 21 | We follow the conventional commits specification (https://www.conventionalcommits.org/) for our commit messages. 22 | Please ensure that your commit messages adhere to this format. 23 | This convention helps maintain a standardized and readable commit history. 24 | A conventional commit message should have the following format: 25 | 26 | ```text 27 | (): 28 | 29 | [optional body] 30 | 31 | [optional footer(s)] 32 | ``` 33 | 34 | Here's an example of a valid commit message: 35 | 36 | ```text 37 | feat(api): add user authentication endpoint 38 | 39 | - Implement the user authentication endpoint. 40 | - Add tests for the new endpoint. 41 | - Update documentation. 42 | ``` 43 | 44 | ## Licence 45 | By contributing to this project, you agree that your contributions will be licensed under the project's [LICENSE](LICENSE). 46 | 47 | 48 | **Thank you for your contribution! Your time and effort are greatly appreciated.** -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Joshua Sattler 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-comdirect 2 | === 3 | ![version](https://img.shields.io/github/v/release/jsattler/go-comdirect?include_prereleases) 4 | [![Apache License v2](https://img.shields.io/github/license/jsattler/go-comdirect)](http://www.apache.org/licenses/) 5 | [![GitHub go.mod Go version of a Go module](https://img.shields.io/github/go-mod/go-version/jsattler/go-comdirect)](https://github.com/jsattler/go-comdirect) 6 | 7 | `go-comdirect` is both a client library and [CLI tool](comdirect) to interact with 8 | the [comdirect REST API](https://www.comdirect.de/cms/kontakt-zugaenge-api.html). 9 | 10 | > **Additional Notes** 11 | > * The library is currently unstable and will change frequently until version 1.0.0 is released 12 | > * Please read the comdirect API documentation prior to using this software 13 | > * Use of this software is at your own risk 14 | > * 10 requests per second are allowed by comdirect 15 | > * 3 invalid TAN validation attempts will cancel the online access 16 | 17 | Features 18 | --- 19 | * **Auth:** Authenticate and authorize with the comdirect API. 20 | * **Account:** Access your account data like balances or transactions. 21 | * **Depot:** Access your depot data like balances, positions or transactions. 22 | * **Instrument:** Access instrument data by providing a WKN, ISIN or symbol. 23 | * **Order:** create, modify and delete orders. 24 | In addition, you can query the order book and the status of individual orders, as well as view the display the cost statement for an order. (open #8) 25 | * **Quote:** Do live trading and prepare the query of a quote or execute it (open #9). 26 | * **Documents:** Access and download Postbox-documents. 27 | * **Reports:** Access aggregated reports for multiple of your comdirect products. 28 | 29 | Install 30 | --- 31 | Use `go get` to install the latest version of this library: 32 | ```bash 33 | $ go get -u github.com/jsattler/go-comdirect 34 | ``` 35 | 36 | Use `go install` to install the `comdirect` CLI tool: 37 | ```shell 38 | go install github.com/jsattler/go-comdirect/comdirect@main 39 | ``` 40 | 41 | Quick Start 42 | --- 43 | ```go 44 | // omitting error validation, imports and packages 45 | 46 | options := &comdirect.AuthOptions{ 47 | Username: os.Getenv("COMDIRECT_USERNAME"), 48 | Password: os.Getenv("COMDIRECT_PASSWORD"), 49 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 50 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 51 | } 52 | 53 | client := comdirect.NewWithAuthOptions(options) 54 | ``` 55 | 56 | Documentation 57 | --- 58 | You can find detailed documentation 59 | * [on how to install and use the comdirect CLI tool](comdirect/README.md) 60 | * on this [website](https://jsattler.github.io/go-comdirect/#/) 61 | * in the [`docs/`](docs/getting-started.md) folder 62 | * or in the tests of [`pkg/comdirect`](pkg/comdirect) 63 | 64 | 65 | ## Contributing 66 | Your contributions are appreciated! Please refer to [CONTRIBUTING.md](CONTRIBUTING.md) for further information. 67 | 68 | ## License 69 | Please refer to [LICENSE](LICENSE) for further information. -------------------------------------------------------------------------------- /comdirect/README.md: -------------------------------------------------------------------------------- 1 | `comdirect` CLI 2 | === 3 | `comdirect` CLI tool lets you interact with your comdirect account, depot, documents and other resources. 4 | 5 | > So far only tested on macOS 12.1 and Fedora 33 6 | 7 | Install 8 | --- 9 | ```shell 10 | go install github.com/jsattler/go-comdirect/comdirect@main 11 | ``` 12 | 13 | Getting Started 14 | --- 15 | >All commands and subcommands use the singular form, so instead of `accounts` it's `account`. 16 | >This is a convention to make it easier for users to remember the commands. 17 | 18 | ```text 19 | Usage: 20 | comdirect [COMMAND] [SUBCOMMAND] [OPTIONS] [ID] 21 | ``` 22 | 23 | ### Authentication 24 | To log in you can specify the credentials through the options or when prompted. 25 | > The login command will try to store the credentials in one of the following locations depending on your OS 26 | > * OS X KeyChain 27 | > * Secret Service dbus interface (GNOME Keyring) 28 | > * Windows Credentials Manager 29 | 30 | 31 | ```shell 32 | comdirect login \ 33 | --id= \ 34 | --secret= \ 35 | --username= \ 36 | --password= 37 | ``` 38 | or 39 | ```shell 40 | comdirect login 41 | ``` 42 | 43 | The logout command will remove all stored credentials, access and refresh tokens from the mentioned credential providers. 44 | 45 | ```shell 46 | comdirect logout 47 | ``` 48 | 49 | ### Account 50 | 51 | List basic account information 52 | 53 | ```shell 54 | comdirect account 55 | ``` 56 | 57 | List all account information and balances (giro Konto, tagesgeldplus etc.) 58 | 59 | ```shell 60 | comdirect account balance 61 | ``` 62 | 63 | Retrieve account information and balances for a specific account 64 | 65 | ```shell 66 | comdirect account balance 67 | ``` 68 | 69 | Retrieve account transactions for a specific account 70 | ```shell 71 | comdirect account transaction 72 | ``` 73 | 74 | ### Depot 75 | 76 | Retrieve *depot* information 77 | 78 | ```shell 79 | comdirect depot 80 | ``` 81 | 82 | Retrieve *depot* positions for a specific depot 83 | 84 | ```shell 85 | comdirect depot position 86 | ``` 87 | 88 | Retrieve a specific depot position for a specific depot 89 | 90 | ```shell 91 | comdirect depot position --position= 92 | ``` 93 | 94 | Retrieve all transactions for a specific depot 95 | 96 | ```shell 97 | comdirect depot transaction 98 | ``` 99 | 100 | ### Document 101 | Some notes on the current behavior: 102 | * the tool does not check if a file already exists. If it does, it will download and truncate the existing file 103 | * You need to specify the `--download` flag to download the files 104 | 105 | List all documents from the postbox 106 | ```shell 107 | comdirect document 108 | ``` 109 | 110 | List a specific document 111 | ```shell 112 | comdirect document 113 | ``` 114 | 115 | Download first 20 documents 116 | ```shell 117 | comdirect document --count=20 --download 118 | ``` 119 | 120 | Download document by ID 121 | ```shell 122 | comdirect document --download 123 | ``` 124 | -------------------------------------------------------------------------------- /comdirect/cmd/account.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/jsattler/go-comdirect/pkg/comdirect" 5 | "github.com/olekukonko/tablewriter" 6 | "github.com/spf13/cobra" 7 | "log" 8 | "os" 9 | ) 10 | 11 | var ( 12 | accountCmd = &cobra.Command{ 13 | Use: "account", 14 | Short: "list all available accounts", 15 | Args: cobra.MinimumNArgs(0), 16 | Run: Account, 17 | } 18 | ) 19 | 20 | func Account(cmd *cobra.Command, args []string) { 21 | ctx, cancel := contextWithTimeout() 22 | defer cancel() 23 | client := initClient() 24 | balances, err := client.Balances(ctx) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | switch formatFlag { 29 | case "json": 30 | printJSON(balances) 31 | case "markdown": 32 | printAccountTable(balances) 33 | default: 34 | printAccountTable(balances) 35 | } 36 | } 37 | 38 | func printAccountTable(account *comdirect.AccountBalances) { 39 | table := tablewriter.NewWriter(os.Stdout) 40 | table.SetHeader([]string{"ID", "TYPE", "IBAN", "CREDIT LIMIT"}) 41 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 42 | table.SetCenterSeparator("|") 43 | for _, a := range account.Values { 44 | table.Append([]string{a.AccountId, a.Account.AccountType.Text, a.Account.Iban, a.Account.CreditLimit.Value}) 45 | } 46 | table.Render() 47 | } 48 | -------------------------------------------------------------------------------- /comdirect/cmd/balance.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/csv" 5 | "github.com/jsattler/go-comdirect/pkg/comdirect" 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/spf13/cobra" 8 | "log" 9 | "os" 10 | ) 11 | 12 | var ( 13 | balanceCmd = &cobra.Command{ 14 | Use: "balance", 15 | Short: "list account balances", 16 | Run: balance, 17 | } 18 | ) 19 | 20 | func balance(cmd *cobra.Command, args []string) { 21 | client := initClient() 22 | ctx, cancel := contextWithTimeout() 23 | defer cancel() 24 | balances, err := client.Balances(ctx) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | switch formatFlag { 30 | case "json": 31 | printJSON(balances) 32 | case "markdown": 33 | printBalanceTable(balances) 34 | case "csv": 35 | printBalanceCSV(balances) 36 | default: 37 | printBalanceTable(balances) 38 | } 39 | } 40 | 41 | func printBalanceCSV(balances *comdirect.AccountBalances) { 42 | table := csv.NewWriter(os.Stdout) 43 | table.Write([]string{"ID", "TYPE", "IBAN", "BALANCE"}) 44 | for _, a := range balances.Values { 45 | table.Write([]string{a.AccountId, a.Account.AccountType.Text, a.Account.Iban, a.Balance.Value}) 46 | } 47 | table.Flush() 48 | } 49 | 50 | func printBalanceTable(account *comdirect.AccountBalances) { 51 | table := tablewriter.NewWriter(os.Stdout) 52 | table.SetHeader([]string{"ID", "TYPE", "IBAN", "BALANCE"}) 53 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 54 | table.SetCenterSeparator("|") 55 | for _, a := range account.Values { 56 | table.Append([]string{a.AccountId, a.Account.AccountType.Text, a.Account.Iban, a.Balance.Value}) 57 | } 58 | table.Render() 59 | } 60 | -------------------------------------------------------------------------------- /comdirect/cmd/depot.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/csv" 5 | "github.com/jsattler/go-comdirect/pkg/comdirect" 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/spf13/cobra" 8 | "os" 9 | ) 10 | 11 | var ( 12 | depotCmd = &cobra.Command{ 13 | Use: "depot", 14 | Short: "list basic depot information", 15 | Run: depot, 16 | } 17 | ) 18 | 19 | func depot(cmd *cobra.Command, args []string) { 20 | client := initClient() 21 | ctx, cancel := contextWithTimeout() 22 | defer cancel() 23 | 24 | depots, err := client.Depots(ctx) 25 | if err != nil { 26 | return 27 | } 28 | switch formatFlag { 29 | case "json": 30 | printJSON(depots) 31 | case "markdown": 32 | printDepotsTable(depots) 33 | case "csv": 34 | printDepotsCSV(depots) 35 | default: 36 | printDepotsTable(depots) 37 | } 38 | } 39 | 40 | func printDepotsCSV(depots *comdirect.Depots) { 41 | table := csv.NewWriter(os.Stdout) 42 | table.Write([]string{"DEPOT ID", "DISPLAY ID", "HOLDER NAME", "CLIENT ID"}) 43 | for _, d := range depots.Values { 44 | table.Write([]string{d.DepotId, d.DepotDisplayId, d.HolderName, d.ClientId}) 45 | } 46 | table.Flush() 47 | } 48 | func printDepotsTable(depots *comdirect.Depots) { 49 | table := tablewriter.NewWriter(os.Stdout) 50 | table.SetHeader([]string{"DEPOT ID", "DISPLAY ID", "HOLDER NAME", "CLIENT ID"}) 51 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 52 | table.SetCenterSeparator("|") 53 | for _, d := range depots.Values { 54 | table.Append([]string{d.DepotId, d.DepotDisplayId, d.HolderName, d.ClientId}) 55 | } 56 | table.Render() 57 | } 58 | -------------------------------------------------------------------------------- /comdirect/cmd/document.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jsattler/go-comdirect/pkg/comdirect" 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/spf13/cobra" 8 | "log" 9 | "os" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | var ( 15 | documentCmd = &cobra.Command{ 16 | Use: "document", 17 | Short: "list and download postbox documents", 18 | Run: document, 19 | } 20 | ) 21 | 22 | func document(cmd *cobra.Command, args []string) { 23 | client := initClient() 24 | ctx, cancel := contextWithTimeout() 25 | defer cancel() 26 | options := comdirect.EmptyOptions() 27 | options.Add(comdirect.PagingFirstQueryKey, indexFlag) 28 | options.Add(comdirect.PagingCountQueryKey, countFlag) 29 | documents, err := client.Documents(ctx, options) 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | 34 | filtered := &comdirect.Documents{Values: []comdirect.Document{}, Paging: documents.Paging} 35 | if len(args) != 0 { 36 | for _, d := range documents.Values { 37 | for _, a := range args { 38 | if a == d.DocumentID { 39 | filtered.Values = append(filtered.Values, d) 40 | } 41 | } 42 | } 43 | } else { 44 | filtered.Values = documents.Values 45 | } 46 | 47 | if downloadFlag { 48 | download(client, filtered) 49 | } else { 50 | switch formatFlag { 51 | case "json": 52 | printJSON(filtered) 53 | case "markdown": 54 | printDocumentTable(filtered) 55 | default: 56 | printDocumentTable(filtered) 57 | } 58 | } 59 | 60 | } 61 | 62 | func download(client *comdirect.Client, documents *comdirect.Documents) { 63 | ctx, cancel := contextWithTimeout() 64 | defer cancel() 65 | for i, d := range documents.Values { 66 | err := client.DownloadDocument(ctx, &d, folderFlag) 67 | if err != nil { 68 | log.Fatal("failed to download document: ", err) 69 | } 70 | // TODO: think about a better solution to limit download requests to 10/sec 71 | if i % 10 == 0 && i != 0{ 72 | time.Sleep(900 * time.Millisecond) 73 | } 74 | fmt.Printf("Download complete for document with ID %s\n", d.DocumentID) 75 | } 76 | } 77 | 78 | func printDocumentTable(documents *comdirect.Documents) { 79 | table := tablewriter.NewWriter(os.Stdout) 80 | table.SetHeader([]string{"ID", "NAME", "DATE", "OPENED", "TYPE"}) 81 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 82 | table.SetCenterSeparator("|") 83 | table.SetCaption(true, fmt.Sprintf("%d out of %d", len(documents.Values), documents.Paging.Matches)) 84 | for _, d := range documents.Values { 85 | name := strings.ReplaceAll(d.Name, " ", "-") 86 | if len(name) > 30 { 87 | name = name[:30] 88 | } 89 | table.Append([]string{d.DocumentID, name + "...", d.DateCreation, fmt.Sprintf("%t", d.DocumentMetaData.AlreadyRead), d.MimeType}) 90 | } 91 | table.Render() 92 | } 93 | -------------------------------------------------------------------------------- /comdirect/cmd/login.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/jsattler/go-comdirect/comdirect/keychain" 7 | "github.com/jsattler/go-comdirect/pkg/comdirect" 8 | "github.com/spf13/cobra" 9 | "golang.org/x/term" 10 | "log" 11 | "os" 12 | "syscall" 13 | "time" 14 | ) 15 | 16 | var ( 17 | loginCmd = &cobra.Command{ 18 | Use: "login", 19 | Short: "log in to comdirect", 20 | Run: login, 21 | } 22 | ) 23 | 24 | func login(cmd *cobra.Command, args []string) { 25 | scanner := bufio.NewScanner(os.Stdin) 26 | 27 | if usernameFlag == "" { 28 | fmt.Print("Username: ") 29 | scanner.Scan() 30 | usernameFlag = scanner.Text() 31 | } 32 | 33 | if passwordFlag == "" { 34 | fmt.Print("Password: ") 35 | bytePassword, _ := term.ReadPassword(syscall.Stdin) 36 | passwordFlag = string(bytePassword) 37 | fmt.Println() 38 | } 39 | 40 | if clientIDFlag == "" { 41 | fmt.Print("Client ID: ") 42 | scanner.Scan() 43 | clientIDFlag = scanner.Text() 44 | } 45 | 46 | if clientSecretFlag == "" { 47 | fmt.Print("Client Secret: ") 48 | byteClientSecret, _ := term.ReadPassword(syscall.Stdin) 49 | clientSecretFlag = string(byteClientSecret) 50 | fmt.Println() 51 | } 52 | 53 | options := &comdirect.AuthOptions{ 54 | Username: usernameFlag, 55 | Password: passwordFlag, 56 | ClientId: clientIDFlag, 57 | ClientSecret: clientSecretFlag, 58 | } 59 | 60 | if err := keychain.StoreAuthOptions(options); err != nil { 61 | log.Fatal(err) 62 | } 63 | 64 | authenticator := comdirect.NewAuthenticator(options) 65 | ctx, cancel := contextWithTimeout() 66 | defer cancel() 67 | 68 | fmt.Println("Open your comdirect photoTAN app to complete the login") 69 | 70 | authentication, err := authenticator.Authenticate(ctx) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | 75 | if err := keychain.StoreAuthentication(authentication); err != nil { 76 | log.Fatal(err) 77 | } 78 | 79 | fmt.Printf("Successfully logged in - the session will expire in 10 minutes (%s)\n", 80 | authentication.ExpiryTime(). 81 | Add(time.Duration(authentication.AccessToken().ExpiresIn)*time.Second). 82 | Format(time.RFC3339)) 83 | } 84 | -------------------------------------------------------------------------------- /comdirect/cmd/logout.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jsattler/go-comdirect/comdirect/keychain" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var ( 10 | logoutCmd = &cobra.Command{ 11 | Use: "logout", 12 | Short: "log out of comdirect", 13 | Run: logout, 14 | } 15 | ) 16 | 17 | func logout(cmd *cobra.Command, args []string) { 18 | keychain.DeleteAuthentication() 19 | keychain.DeleteAuthOptions() 20 | fmt.Println("Successfully logged out") 21 | } 22 | -------------------------------------------------------------------------------- /comdirect/cmd/position.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/csv" 5 | "github.com/jsattler/go-comdirect/pkg/comdirect" 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/spf13/cobra" 8 | "os" 9 | ) 10 | 11 | var ( 12 | positionCmd = &cobra.Command{ 13 | Use: "position", 14 | Short: "list depot position information", 15 | Args: cobra.MinimumNArgs(1), 16 | Run: position, 17 | } 18 | ) 19 | 20 | func position(cmd *cobra.Command, args []string) { 21 | client := initClient() 22 | ctx, cancel := contextWithTimeout() 23 | defer cancel() 24 | 25 | positions, err := client.DepotPositions(ctx, args[0]) 26 | if err != nil { 27 | return 28 | } 29 | switch formatFlag { 30 | case "json": 31 | printJSON(positions) 32 | case "markdown": 33 | printPositionsTable(positions) 34 | case "csv": 35 | printPositionsCSV(positions) 36 | default: 37 | printPositionsTable(positions) 38 | } 39 | } 40 | 41 | func printPositionsCSV(positions *comdirect.DepotPositions) { 42 | table := csv.NewWriter(os.Stdout) 43 | table.Write([]string{"POSITION ID", "WKN", "QUANTITY", "CURRENT PRICE", "PREVDAY %", "PURCHASE %", "PURCHASE", "CURRENT"}) 44 | for _, d := range positions.Values { 45 | table.Write([]string{d.PositionId, d.Wkn, d.Quantity.Value, d.CurrentPrice.Price.Value, d.ProfitLossPrevDayRel, d.ProfitLossPurchaseRel, d.PurchaseValue.Value, d.CurrentValue.Value}) 46 | } 47 | table.Flush() 48 | } 49 | 50 | func printPositionsTable(depots *comdirect.DepotPositions) { 51 | table := tablewriter.NewWriter(os.Stdout) 52 | table.SetHeader([]string{"POSITION ID", "WKN", "QUANTITY", "CURRENT PRICE", "PREVDAY %", "PURCHASE %", "PURCHASE", "CURRENT"}) 53 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 54 | table.SetCenterSeparator("|") 55 | for _, d := range depots.Values { 56 | table.Append([]string{d.PositionId, d.Wkn, d.Quantity.Value, d.CurrentPrice.Price.Value, d.ProfitLossPrevDayRel, d.ProfitLossPurchaseRel, d.PurchaseValue.Value, d.CurrentValue.Value}) 57 | } 58 | table.Render() 59 | } 60 | -------------------------------------------------------------------------------- /comdirect/cmd/report.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/csv" 5 | "github.com/jsattler/go-comdirect/pkg/comdirect" 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/spf13/cobra" 8 | "os" 9 | ) 10 | 11 | var ( 12 | reportsHeader = []string{"ID", "TYPE", "BALANCE"} 13 | 14 | reportCmd = &cobra.Command{ 15 | Use: "report", 16 | Short: "list aggregated account information", 17 | Run: report, 18 | } 19 | ) 20 | 21 | func report(cmd *cobra.Command, args []string) { 22 | client := initClient() 23 | ctx, cancel := contextWithTimeout() 24 | defer cancel() 25 | reports, err := client.Reports(ctx) 26 | if err != nil { 27 | return 28 | } 29 | switch formatFlag { 30 | case "json": 31 | printJSON(reports) 32 | case "markdown": 33 | printReportsTable(reports) 34 | case "csv": 35 | printReportsCSV(reports) 36 | default: 37 | printReportsTable(reports) 38 | } 39 | } 40 | 41 | func printReportsCSV(reports *comdirect.Reports) { 42 | table := csv.NewWriter(os.Stdout) 43 | table.Write(reportsHeader) 44 | for _, r := range reports.Values { 45 | var balance string 46 | if r.Balance.Balance.Value == "" { 47 | balance = formatAmountValue(r.Balance.PrevDayValue) 48 | } else { 49 | balance = formatAmountValue(r.Balance.Balance) 50 | } 51 | table.Write([]string{r.ProductID, r.ProductType, balance}) 52 | } 53 | table.Flush() 54 | } 55 | 56 | func printReportsTable(reports *comdirect.Reports) { 57 | table := tablewriter.NewWriter(os.Stdout) 58 | table.SetHeader(reportsHeader) 59 | table.SetColumnAlignment([]int{tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_RIGHT}) 60 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 61 | table.SetCenterSeparator("|") 62 | for _, r := range reports.Values { 63 | var balance string 64 | if r.Balance.Balance.Value == "" { 65 | balance = formatAmountValue(r.Balance.PrevDayValue) 66 | } else { 67 | balance = formatAmountValue(r.Balance.Balance) 68 | } 69 | table.Append([]string{r.ProductID, r.ProductType, balance}) 70 | } 71 | table.Append([]string{"", "TOTAL", formatAmountValue(reports.ReportAggregated.BalanceEUR)}) 72 | table.Render() 73 | } 74 | -------------------------------------------------------------------------------- /comdirect/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/jsattler/go-comdirect/comdirect/keychain" 12 | "github.com/jsattler/go-comdirect/pkg/comdirect" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | folderFlag string 18 | excludeFlag string 19 | timeoutFlag int 20 | formatFlag string 21 | indexFlag string 22 | countFlag string 23 | sinceFlag string 24 | downloadFlag bool 25 | usernameFlag string 26 | passwordFlag string 27 | clientIDFlag string 28 | clientSecretFlag string 29 | 30 | rootCmd = &cobra.Command{ 31 | Use: "comdirect", 32 | Short: "comdirect is a CLI tool to interact with the comdirect REST API", 33 | } 34 | ) 35 | 36 | func Execute() error { 37 | return rootCmd.Execute() 38 | } 39 | 40 | func init() { 41 | 42 | loginCmd.Flags().StringVarP(&passwordFlag, "password", "p", "", "comdirect password (PIN)") 43 | loginCmd.Flags().StringVarP(&usernameFlag, "username", "u", "", "comdirect username") 44 | loginCmd.Flags().StringVarP(&clientSecretFlag, "secret", "s", "", "comdirect client secret") 45 | loginCmd.Flags().StringVarP(&clientIDFlag, "id", "i", "", "comdirect client ID") 46 | 47 | documentCmd.Flags().StringVar(&folderFlag, "folder", "", "folder to save downloads") 48 | documentCmd.Flags().BoolVar(&downloadFlag, "download", false, "whether to download documents") 49 | 50 | transactionCmd.PersistentFlags().StringVar(&sinceFlag, "since", "", "Date of the earliest transaction date to retrieve in the form YYYY-MM-DD") 51 | 52 | rootCmd.PersistentFlags().StringVar(&indexFlag, "index", "0", "page index") 53 | rootCmd.PersistentFlags().StringVar(&countFlag, "count", "20", "page count") 54 | rootCmd.PersistentFlags().StringVarP(&formatFlag, "format", "f", "markdown", "output format (markdown, csv or json)") 55 | rootCmd.PersistentFlags().IntVarP(&timeoutFlag, "timeout", "t", 30, "timeout in seconds to validate session TAN (default 30sec)") 56 | rootCmd.PersistentFlags().StringVar(&excludeFlag, "exclude", "", "exclude field from response") 57 | 58 | rootCmd.AddCommand(documentCmd) 59 | rootCmd.AddCommand(depotCmd) 60 | rootCmd.AddCommand(accountCmd) 61 | rootCmd.AddCommand(loginCmd) 62 | rootCmd.AddCommand(logoutCmd) 63 | rootCmd.AddCommand(reportCmd) 64 | rootCmd.AddCommand(versionCmd) 65 | 66 | accountCmd.AddCommand(balanceCmd) 67 | accountCmd.AddCommand(transactionCmd) 68 | 69 | depotCmd.AddCommand(positionCmd) 70 | } 71 | 72 | func contextWithTimeout() (context.Context, context.CancelFunc) { 73 | return context.WithTimeout(context.Background(), time.Duration(timeoutFlag)*time.Second) 74 | } 75 | 76 | func formatAmountValue(av comdirect.AmountValue) string { 77 | value, err := strconv.ParseFloat(av.Value, 64) 78 | if err != nil { 79 | return "" 80 | } 81 | 82 | return fmt.Sprintf("%+5.2f", value) 83 | } 84 | 85 | func initClient() *comdirect.Client { 86 | authentication, err := keychain.RetrieveAuthentication() 87 | if err != nil || authentication.IsExpired() { 88 | // The session is expired, and we need to create a new session TAN 89 | authOptions, err := keychain.RetrieveAuthOptions() 90 | if err != nil { 91 | fmt.Println("You're not logged in. Please use 'comdirect login' to log in") 92 | os.Exit(1) 93 | } 94 | fmt.Println("Your session expired. Please open the comdirect photoTAN app to validate a new session.") 95 | client := comdirect.NewWithAuthOptions(authOptions) 96 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 97 | defer cancel() 98 | keychain.DeleteAuthentication() 99 | authentication, err = client.Authenticate(ctx) 100 | if err != nil { 101 | log.Fatal(err) 102 | } 103 | err = keychain.StoreAuthentication(authentication) 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | return client 108 | } 109 | return comdirect.NewWithAuthentication(authentication) 110 | } 111 | -------------------------------------------------------------------------------- /comdirect/cmd/transaction.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/csv" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/jsattler/go-comdirect/pkg/comdirect" 13 | "github.com/olekukonko/tablewriter" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var ( 18 | transactionHeader = []string{"REMITTER", "DEPTOR", "BOOKING DATE", "STATUS", "TYPE", "VALUE", "UNIT"} 19 | transactionCmd = &cobra.Command{ 20 | Use: "transaction", 21 | Short: "list account transactions", 22 | Args: cobra.MinimumNArgs(1), 23 | Run: transaction, 24 | } 25 | ) 26 | 27 | func transaction(cmd *cobra.Command, args []string) { 28 | var transactions = &comdirect.AccountTransactions{} 29 | var err error 30 | client := initClient() 31 | ctx, cancel := contextWithTimeout() 32 | defer cancel() 33 | 34 | if sinceFlag == "" { 35 | options := comdirect.EmptyOptions() 36 | options.Add(comdirect.PagingCountQueryKey, countFlag) 37 | options.Add(comdirect.PagingFirstQueryKey, indexFlag) 38 | transactions, err = client.Transactions(ctx, args[0], options) 39 | if err != nil { 40 | log.Fatalf("Failed to retrieve transactions: %s", err) 41 | } 42 | } else { 43 | transactions = getTransactionsSince(sinceFlag, client, args[0]) 44 | } 45 | 46 | switch formatFlag { 47 | case "json": 48 | printJSON(transactions) 49 | case "markdown": 50 | printTransactionTable(transactions) 51 | case "csv": 52 | printTransactionCSV(transactions) 53 | default: 54 | printTransactionTable(transactions) 55 | } 56 | } 57 | 58 | func getTransactionsSince(since string, client *comdirect.Client, accountID string) *comdirect.AccountTransactions { 59 | const dateLayout = "2006-01-02" 60 | var transactions = &comdirect.AccountTransactions{} 61 | ctx, cancel := contextWithTimeout() 62 | defer cancel() 63 | 64 | s, err := time.Parse(dateLayout, since) 65 | if err != nil { 66 | log.Fatalf("Failed to parse date from command line: %s", err) 67 | } 68 | 69 | page := 1 70 | pageCount, err := strconv.Atoi(countFlag) 71 | if err != nil { 72 | log.Fatalf("Can't convert string to int: %s", err) 73 | } 74 | for { 75 | options := comdirect.EmptyOptions() 76 | options.Add(comdirect.PagingCountQueryKey, fmt.Sprint(pageCount*page)) 77 | options.Add(comdirect.PagingFirstQueryKey, fmt.Sprint(0)) 78 | 79 | transactions, err = client.Transactions(ctx, accountID, options) 80 | if err != nil { 81 | log.Fatalf("Error retrieving transactions: %e", err) 82 | } 83 | 84 | if len(transactions.Values) == 0 { 85 | break 86 | } 87 | 88 | lastDate, err := time.Parse(dateLayout, transactions.Values[len(transactions.Values)-1].BookingDate) 89 | if err != nil { 90 | lastDate = time.Now() 91 | 92 | } 93 | if transactions.Paging.Matches == len(transactions.Values) || lastDate.Before(s) { 94 | break 95 | } 96 | page++ 97 | } 98 | 99 | transactions, err = transactions.FilterSince(s) 100 | if err != nil { 101 | log.Fatalf("Error filtering transactions by date: %e", err) 102 | } 103 | return transactions 104 | } 105 | 106 | func printJSON(v interface{}) { 107 | b, err := json.MarshalIndent(v, "", " ") 108 | if err != nil { 109 | log.Fatal(err) 110 | } 111 | fmt.Println(string(b)) 112 | } 113 | 114 | func printTransactionCSV(transactions *comdirect.AccountTransactions) { 115 | table := csv.NewWriter(os.Stdout) 116 | table.Write(transactionHeader) 117 | for _, t := range transactions.Values { 118 | holderName := t.Remitter.HolderName 119 | if len(holderName) > 30 { 120 | holderName = holderName[:30] 121 | } else if holderName == "" { 122 | holderName = "N/A" 123 | } 124 | table.Write([]string{holderName, t.Creditor.HolderName, t.BookingDate, t.BookingStatus, t.TransactionType.Text, formatAmountValue(t.Amount), t.Amount.Unit}) 125 | } 126 | table.Flush() 127 | } 128 | func printTransactionTable(transactions *comdirect.AccountTransactions) { 129 | table := tablewriter.NewWriter(os.Stdout) 130 | table.SetHeader(transactionHeader) 131 | table.SetColumnAlignment([]int{tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_RIGHT}) 132 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 133 | table.SetCenterSeparator("|") 134 | table.SetCaption(true, fmt.Sprintf("%d out of %d", len(transactions.Values), transactions.Paging.Matches)) 135 | for _, t := range transactions.Values { 136 | holderName := t.Remitter.HolderName 137 | if len(holderName) > 30 { 138 | holderName = holderName[:30] 139 | } else if holderName == "" { 140 | holderName = "N/A" 141 | } 142 | table.Append([]string{holderName, t.Creditor.HolderName, t.BookingDate, t.BookingStatus, t.TransactionType.Text, formatAmountValue(t.Amount), t.Amount.Unit}) 143 | } 144 | table.Render() 145 | } 146 | -------------------------------------------------------------------------------- /comdirect/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var ( 9 | versionCmd = &cobra.Command{ 10 | Use: "version", 11 | Short: "print the version of the comdirect CLI tool", 12 | Run: func(cmd *cobra.Command, args []string) { 13 | fmt.Println("comdirect CLI v0.5.0") 14 | }, 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /comdirect/keychain/keychain.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/jsattler/go-comdirect/pkg/comdirect" 6 | "github.com/zalando/go-keyring" 7 | "log" 8 | "time" 9 | ) 10 | 11 | const servicePrefix = "github.com.jsattler.go-comdirect." 12 | const user = "comdirect" 13 | 14 | func StoreAuthOptions(options *comdirect.AuthOptions) error { 15 | if err := keyring.Set(servicePrefix+"username", user, options.Username); err != nil { 16 | return err 17 | } 18 | if err := keyring.Set(servicePrefix+"password", user, options.Password); err != nil { 19 | return err 20 | } 21 | if err := keyring.Set(servicePrefix+"clientID", user, options.ClientId); err != nil { 22 | return err 23 | } 24 | return keyring.Set(servicePrefix+"clientSecret", user, options.ClientSecret) 25 | } 26 | 27 | func StoreAuthentication(authentication *comdirect.Authentication) error { 28 | 29 | accessToken := authentication.AccessToken() 30 | loginTime := authentication.ExpiryTime() 31 | sessionID := authentication.SessionID() 32 | 33 | byteJson, err := json.Marshal(accessToken) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | 38 | if err := keyring.Set(servicePrefix+"accessToken", user, string(byteJson)); err != nil { 39 | return err 40 | } 41 | 42 | if err := keyring.Set(servicePrefix+"loginTime", user, loginTime.Format(time.RFC3339)); err != nil { 43 | return err 44 | } 45 | 46 | return keyring.Set(servicePrefix+"sessionID", user, sessionID) 47 | } 48 | 49 | func RetrieveAuthOptions() (*comdirect.AuthOptions, error) { 50 | username, err := keyring.Get(servicePrefix+"username", user) 51 | if err != nil { 52 | return nil, err 53 | } 54 | password, err := keyring.Get(servicePrefix+"password", user) 55 | if err != nil { 56 | return nil, err 57 | } 58 | clientID, err := keyring.Get(servicePrefix+"clientID", user) 59 | if err != nil { 60 | return nil, err 61 | } 62 | clientSecret, err := keyring.Get(servicePrefix+"clientSecret", user) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | return &comdirect.AuthOptions{ 68 | Username: username, 69 | Password: password, 70 | ClientId: clientID, 71 | ClientSecret: clientSecret, 72 | }, nil 73 | 74 | } 75 | 76 | func RetrieveAuthentication() (*comdirect.Authentication, error) { 77 | loginTimeString, err := keyring.Get(servicePrefix+"loginTime", user) 78 | 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | sessionID, err := keyring.Get(servicePrefix+"sessionID", user) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | accessTokenString, err := keyring.Get(servicePrefix+"accessToken", user) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | loginTime, err := time.Parse(time.RFC3339, loginTimeString) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | accessToken := comdirect.AccessToken{} 99 | 100 | err = json.Unmarshal([]byte(accessTokenString), &accessToken) 101 | 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | return comdirect.NewAuthentication(accessToken, sessionID, loginTime), nil 107 | } 108 | 109 | func DeleteAuthentication() { 110 | _ = keyring.Delete(servicePrefix+"loginTime", user) 111 | _ = keyring.Delete(servicePrefix+"sessionID", user) 112 | _ = keyring.Delete(servicePrefix+"accessToken", user) 113 | } 114 | 115 | func DeleteAuthOptions() { 116 | _ = keyring.Delete(servicePrefix+"username", user) 117 | _ = keyring.Delete(servicePrefix+"password", user) 118 | _ = keyring.Delete(servicePrefix+"clientID", user) 119 | _ = keyring.Delete(servicePrefix+"clientSecret", user) 120 | } 121 | -------------------------------------------------------------------------------- /comdirect/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jsattler/go-comdirect/comdirect/cmd" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | if err := cmd.Execute(); err != nil { 10 | os.Exit(1) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsattler/go-comdirect/df621258629e1e352487bde55a8137e66e2c7477/docs/.nojekyll -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ## What is comdirect? 2 | [*From Wikipedia:*](https://en.wikipedia.org/wiki/Comdirect_Bank) 3 | > Comdirect Bank Aktiengesellschaft was the third-largest German direct bank and was based in Quickborn, Schleswig-Holstein. 4 | > Founded by the Commerzbank in 1994, the company went public in 2000. The Commerzbank integrated the company on November 1, 2020. 5 | 6 | ## What is `go-comdirect`? 7 | `go-comdirect` is a client library written in golang to interact with the comdirect REST API. 8 | You can use the package to implement various use cases for example: 9 | * A CLI tool to interact with comdirect 10 | * A prometheus exporter to monitor your account balance or depots 11 | * A tool to automatically fetch your comdirect reports and documents 12 | * A tool that triggers sell/buy requests in certain events 13 | 14 | There are more use cases that you can implement using the package. 15 | 16 | ## Useful Links 17 | * [**comdirect REST API**](https://www.comdirect.de/cms/kontakt-zugaenge-api.html) 18 | * [**Introduction Video comdirect REST API (German)**](https://www.youtube.com/watch?v=HNTeqKTCKTs) 19 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | 2 | * [Introduction](/) 3 | * [Getting Started](getting-started.md) 4 | * [Contributing](contributing.md) -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | --- 3 | Thanks for considering contributing to this project. 4 | This document helps to get you started as quick as possible. 5 | 6 | ### Asking for Help 7 | You can use GitHub Discussions for providing suggestions or asking questions on how to set up the project. 8 | 9 | ### Versioning 10 | This project uses [Semantic Versioning](https://semver.org/). 11 | Hence, every released version that is below 1.0.0 does not provide a stable API. 12 | Once the projects is released with 1.0.0, you can expect that new bug fixes and features are backwards compatible until another major version is released. 13 | 14 | ### Formatting 15 | Please use the `gofmt` tool to automatically format your code. 16 | You can find more information about formatting [here](https://blog.golang.org/gofmt). 17 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | Getting Started 2 | --- 3 | The following sections will help you to get started quickly. 4 | 5 | ### Install 6 | Use `go get` to install the latest version of this library: 7 | 8 | ```bash 9 | $ go get -u github.com/jsattler/comdirect-golang 10 | ``` 11 | 12 | ### Authentication 13 | 14 | **Creating a new Authenticator from AuthOptions**: 15 | 16 | ```go 17 | // omitting error validation, imports and packages 18 | 19 | options := &comdirect.AuthOptions{ 20 | Username: os.Getenv("COMDIRECT_USERNAME"), 21 | Password: os.Getenv("COMDIRECT_PASSWORD"), 22 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 23 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 24 | } 25 | authenticator := options.NewAuthenticator() 26 | ``` 27 | 28 | **Creating a new Authenticator with AuthOptions**: 29 | 30 | ```go 31 | // omitting error validation, imports and packages 32 | 33 | options := &comdirect.AuthOptions{ 34 | Username: os.Getenv("COMDIRECT_USERNAME"), 35 | Password: os.Getenv("COMDIRECT_PASSWORD"), 36 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 37 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 38 | } 39 | 40 | authenticator := comdirect.NewAuthenticator(options) 41 | ``` 42 | 43 | **Authenticate using the `Authenticator`** 44 | 45 | ```go 46 | authentication, err := authenticator.Authenticate() 47 | ``` 48 | The `Authentication` struct holds all relevant information for subsequent requests to the API. 49 | 50 | ### First Steps with comdirect.Client 51 | 52 | **Create a new `Client` from `AuthOptions`** 53 | 54 | ```go 55 | // omitting error validation, imports and packages 56 | 57 | options := &comdirect.AuthOptions{ 58 | Username: os.Getenv("COMDIRECT_USERNAME"), 59 | Password: os.Getenv("COMDIRECT_PASSWORD"), 60 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 61 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 62 | } 63 | 64 | client := comdirect.NewWithAuthOptions(options) 65 | ``` 66 | 67 | **Create a new `Client` from an `Authenticator`** 68 | ```go 69 | // omitting error validation, imports and packages 70 | 71 | options := &comdirect.AuthOptions{ 72 | Username: os.Getenv("COMDIRECT_USERNAME"), 73 | Password: os.Getenv("COMDIRECT_PASSWORD"), 74 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 75 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 76 | } 77 | 78 | authenticator := options.NewAuthenticator() 79 | 80 | client := comdirect.NewWithAuthenticator(authenticator) 81 | ``` 82 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | comdirect-golang 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jsattler/go-comdirect 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/olekukonko/tablewriter v0.0.5 7 | github.com/spf13/cobra v1.7.0 8 | github.com/zalando/go-keyring v0.2.3 9 | golang.org/x/term v0.11.0 10 | golang.org/x/time v0.3.0 11 | ) 12 | 13 | require ( 14 | github.com/alessio/shellescape v1.4.1 // indirect 15 | github.com/danieljoos/wincred v1.2.0 // indirect 16 | github.com/godbus/dbus/v5 v5.1.0 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/mattn/go-runewidth v0.0.13 // indirect 19 | github.com/rivo/uniseg v0.3.1 // indirect 20 | github.com/spf13/pflag v1.0.5 // indirect 21 | golang.org/x/sys v0.11.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= 2 | github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 4 | github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= 5 | github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 8 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 9 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 10 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 11 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 12 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 13 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 14 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 15 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 18 | github.com/rivo/uniseg v0.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk= 19 | github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 20 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 21 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 22 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 23 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 24 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 25 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 26 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 27 | github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= 28 | github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= 29 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 30 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= 32 | golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 33 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 34 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 37 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 | -------------------------------------------------------------------------------- /internal/httpstatus/status.go: -------------------------------------------------------------------------------- 1 | package httpstatus 2 | 3 | import "net/http" 4 | 5 | func Is2xx(response *http.Response) bool { 6 | return response != nil && response.StatusCode >= 200 && response.StatusCode < 300 7 | } 8 | 9 | func Is3xx(response *http.Response) bool { 10 | return response != nil && response.StatusCode >= 300 && response.StatusCode < 400 11 | } 12 | 13 | func Is4xx(response *http.Response) bool { 14 | return response != nil && response.StatusCode >= 400 && response.StatusCode < 500 15 | } 16 | 17 | func Is5xx(response *http.Response) bool { 18 | return response != nil && response.StatusCode >= 500 && response.StatusCode < 600 19 | } 20 | -------------------------------------------------------------------------------- /internal/mediatype/types.go: -------------------------------------------------------------------------------- 1 | package mediatype 2 | 3 | const ( 4 | ApplicationJson = "application/json" 5 | XWWWFormUrlEncoded = "application/x-www-form-urlencoded" 6 | ) 7 | -------------------------------------------------------------------------------- /pkg/comdirect/account.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | type AccountBalance struct { 13 | Account Account `json:"account"` 14 | AccountId string `json:"accountId"` 15 | Balance AmountValue `json:"balance"` 16 | BalanceEUR AmountValue `json:"balanceEUR"` 17 | AvailableCashAmount AmountValue `json:"availableCashAmount"` 18 | AvailableCashAmountEUR AmountValue `json:"availableCashAmountEUR"` 19 | } 20 | 21 | type Account struct { 22 | AccountID string `json:"accountId"` 23 | AccountDisplayID string `json:"accountDisplayId"` 24 | Currency string `json:"currency"` 25 | ClientID string `json:"clientId"` 26 | AccountType AccountType `json:"accountType"` 27 | Iban string `json:"iban"` 28 | CreditLimit AmountValue `json:"creditLimit"` 29 | } 30 | 31 | type AccountType struct { 32 | Key string `json:"key"` 33 | Text string `json:"text"` 34 | } 35 | 36 | type AccountBalances struct { 37 | Paging Paging `json:"paging"` 38 | Values []AccountBalance `json:"values"` 39 | } 40 | 41 | type AccountTransactions struct { 42 | Paging Paging `json:"paging"` 43 | Values []AccountTransaction `json:"values"` 44 | } 45 | 46 | type AccountTransaction struct { 47 | Reference string `json:"reference"` 48 | BookingStatus string `json:"bookingStatus"` 49 | BookingDate string `json:"bookingDate"` 50 | Amount AmountValue `json:"amount"` 51 | Remitter Remitter `json:"remitter"` 52 | Deptor string `json:"deptor"` 53 | Creditor Creditor `json:"creditor"` 54 | ValutaDate string `json:"valutaDate"` 55 | DirectDebitCreditorID string `json:"directDebitCreditorId"` 56 | DirectDebitMandateID string `json:"directDebitMandateId"` 57 | EndToEndReference string `json:"endToEndReference"` 58 | NewTransaction bool `json:"newTransaction"` 59 | RemittanceInfo string `json:"remittanceInfo"` 60 | TransactionType TransactionType `json:"transactionType"` 61 | } 62 | 63 | type TransactionType struct { 64 | Key string `json:"key"` 65 | Text string `json:"text"` 66 | } 67 | 68 | type Remitter struct { 69 | HolderName string `json:"holderName"` 70 | } 71 | 72 | type Creditor struct { 73 | HolderName string `json:"holderName"` 74 | Iban string `json:"iban"` 75 | Bic string `json:"bic"` 76 | } 77 | 78 | func (c *Client) Balances(ctx context.Context) (*AccountBalances, error) { 79 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 80 | return nil, errors.New("authentication is expired or not initialized") 81 | } 82 | info, err := requestInfoJSON(c.authentication.sessionID) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | req := &http.Request{ 88 | Method: http.MethodGet, 89 | URL: apiURL("/banking/clients/user/v2/accounts/balances"), 90 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 91 | } 92 | req = req.WithContext(ctx) 93 | 94 | accountBalances := &AccountBalances{} 95 | _, err = c.http.exchange(req, accountBalances) 96 | return accountBalances, err 97 | } 98 | 99 | func (c *Client) Balance(ctx context.Context, accountId string) (*AccountBalance, error) { 100 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 101 | return nil, errors.New("authentication is expired or not initialized") 102 | } 103 | 104 | info, err := requestInfoJSON(c.authentication.sessionID) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | req := &http.Request{ 110 | Method: http.MethodGet, 111 | URL: apiURL(fmt.Sprintf("/banking/v2/accounts/%s/balances", accountId)), 112 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 113 | } 114 | accountBalance := &AccountBalance{} 115 | _, err = c.http.exchange(req, accountBalance) 116 | 117 | return accountBalance, err 118 | } 119 | 120 | func (c *Client) Transactions(ctx context.Context, accountId string, options ...Options) (*AccountTransactions, error) { 121 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 122 | return nil, errors.New("authentication is expired or not initialized") 123 | } 124 | info, err := requestInfoJSON(c.authentication.sessionID) 125 | if err != nil { 126 | log.Fatal(err) 127 | } 128 | url := apiURL(fmt.Sprintf("/banking/v1/accounts/%s/transactions", accountId)) 129 | encodeOptions(url, options) 130 | req := &http.Request{ 131 | Method: http.MethodGet, 132 | URL: url, 133 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 134 | } 135 | 136 | tr := &AccountTransactions{} 137 | _, err = c.http.exchange(req, tr) 138 | 139 | return tr, err 140 | } 141 | 142 | func (a *AccountTransactions) FilterSince(date time.Time) (*AccountTransactions, error) { 143 | filteredTransactions := &AccountTransactions{Paging: a.Paging} 144 | for _, v := range a.Values { 145 | if v.BookingStatus == "NOTBOOKED" { 146 | continue 147 | } 148 | const transactionDateLayout = "2006-01-02" 149 | d, err := time.Parse(transactionDateLayout, v.BookingDate) 150 | if err != nil { 151 | log.Fatalf("Failed to parse date from transaction: %s", err) 152 | } 153 | if d.Before(date) { 154 | break 155 | } else { 156 | filteredTransactions.Values = append(filteredTransactions.Values, v) 157 | } 158 | } 159 | return filteredTransactions, nil 160 | } 161 | -------------------------------------------------------------------------------- /pkg/comdirect/account_test.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestClient_Balances(t *testing.T) { 12 | client := clientFromEnv() 13 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 14 | defer cancel() 15 | auth, err := client.Authenticate(ctx) 16 | if err != nil { 17 | t.Errorf("failed to authenticate: %s", err) 18 | } 19 | fmt.Printf("%+v\n", auth) 20 | balances, err := client.Balances(ctx) 21 | if err != nil { 22 | t.Errorf("failed to exchange account balances %s", err) 23 | } 24 | 25 | fmt.Printf("%+v\n", balances) 26 | 27 | auth, err = client.Refresh() 28 | if err != nil { 29 | return 30 | } 31 | fmt.Printf("%+v\n", auth) 32 | 33 | err = client.Revoke() 34 | if err != nil { 35 | return 36 | } 37 | fmt.Println("successfully revoked access token") 38 | } 39 | 40 | func TestClient_Balance(t *testing.T) { 41 | client := clientFromEnv() 42 | ctx, cancel := contextTimeout10Seconds() 43 | defer cancel() 44 | _, err := client.Balance(ctx, os.Getenv("COMDIRECT_ACCOUNT_ID")) 45 | 46 | if err != nil { 47 | t.Errorf("failed to exchange account balance %s", err) 48 | return 49 | } 50 | } 51 | 52 | func TestClient_Transactions(t *testing.T) { 53 | client := clientFromEnv() 54 | ctx, cancel := contextTimeout10Seconds() 55 | defer cancel() 56 | since := time.Now().Add(time.Hour * -1 * 24 * 30) 57 | transactions, err := client.Transactions(ctx, os.Getenv("COMDIRECT_ACCOUNT_ID"), since) 58 | 59 | if err != nil { 60 | t.Errorf("failed to exchange account balance %s", err) 61 | return 62 | } 63 | 64 | for _, tr := range transactions.Values { 65 | fmt.Printf("%v\n", tr) 66 | } 67 | } 68 | 69 | func clientFromEnv() *Client { 70 | options := &AuthOptions{ 71 | Username: os.Getenv("COMDIRECT_USERNAME"), 72 | Password: os.Getenv("COMDIRECT_PASSWORD"), 73 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 74 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 75 | } 76 | return NewWithAuthOptions(options) 77 | } 78 | 79 | func contextTimeout10Seconds() (context.Context, context.CancelFunc) { 80 | return context.WithTimeout(context.Background(), time.Second*10) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/comdirect/auth.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | "time" 14 | 15 | "github.com/jsattler/go-comdirect/internal/mediatype" 16 | "golang.org/x/time/rate" 17 | ) 18 | 19 | // Authenticator is responsible for authenticating against the comdirect REST API. 20 | // It uses the given AuthOptions for authentication and returns an AccessToken in case 21 | // the authentication flow was successful. Authenticator is using golang's default http.Client. 22 | type Authenticator struct { 23 | authOptions *AuthOptions 24 | http *HTTPClient 25 | } 26 | 27 | // authContext encapsulates the state that is passed through the comdirect authentication flow. 28 | type authContext struct { 29 | accessToken AccessToken 30 | requestInfo requestInfo 31 | session session 32 | onceAuthInfo onceAuthenticationInfo 33 | } 34 | 35 | // Authentication represents an authentication object for the comdirect REST API. 36 | type Authentication struct { 37 | accessToken AccessToken 38 | sessionID string 39 | time time.Time 40 | } 41 | 42 | // AuthOptions encapsulates the information for authentication against the comdirect REST API. 43 | type AuthOptions struct { 44 | Username string 45 | Password string 46 | ClientId string 47 | ClientSecret string 48 | } 49 | 50 | // AccessToken represents an OAuth2 token that is returned from the comdirect REST API. 51 | type AccessToken struct { 52 | AccessToken string `json:"access_token"` 53 | TokenType string `json:"token_type"` 54 | RefreshToken string `json:"refresh_token"` 55 | ExpiresIn int `json:"expires_in"` 56 | Scope string `json:"scope"` 57 | CustomerID string `json:"kdnr"` 58 | BPID int `json:"bpid"` 59 | ContactID int `json:"kontaktId"` 60 | } 61 | 62 | // requestInfo represents an x-http-request-info header exchanged with the comdirect REST API. 63 | type requestInfo struct { 64 | ClientRequestID clientRequestID `json:"clientRequestId"` 65 | } 66 | 67 | // clientRequestID represents a client request ID that is exchanged with the comdirect REST API. 68 | type clientRequestID struct { 69 | SessionID string `json:"sessionId"` 70 | RequestID string `json:"requestId"` 71 | } 72 | 73 | // session represents a TAN session that is exchanged with the comdirect REST API. 74 | type session struct { 75 | Identifier string `json:"identifier"` 76 | SessionTanActive bool `json:"sessionTanActive"` 77 | Activated2FA bool `json:"activated2FA"` 78 | } 79 | 80 | // onceAuthenticationInfo represents the x-once-authentication-info header exchanged with 81 | // the comdirect REST API. 82 | type onceAuthenticationInfo struct { 83 | Id string `json:"id"` 84 | Typ string `json:"typ"` 85 | AvailableTypes []string `json:"availableTypes"` 86 | Link link `json:"link"` 87 | } 88 | 89 | type link struct { 90 | Href string `json:"href"` 91 | Rel string `json:"rel"` 92 | Method string `json:"method"` 93 | } 94 | 95 | type authStatus struct { 96 | AuthenticationId string `json:"authenticationId"` 97 | Status string `json:"status"` 98 | } 99 | 100 | // NewAuthenticator creates a new Authenticator by passing AuthOptions 101 | // and an http.Client with a timeout of DefaultHttpTimeout. 102 | func NewAuthenticator(options *AuthOptions) *Authenticator { 103 | return &Authenticator{ 104 | authOptions: options, 105 | http: &HTTPClient{http.Client{Timeout: DefaultHttpTimeout}, *rate.NewLimiter(10, 10)}, 106 | } 107 | } 108 | 109 | func NewAuthentication(accessToken AccessToken, sessionID string, time time.Time) *Authentication { 110 | return &Authentication{ 111 | accessToken: accessToken, 112 | sessionID: sessionID, 113 | time: time, 114 | } 115 | } 116 | 117 | func (a *Authentication) AccessToken() AccessToken { 118 | return a.accessToken 119 | } 120 | 121 | func (a *Authentication) SessionID() string { 122 | return a.sessionID 123 | } 124 | 125 | func (a *Authentication) ExpiryTime() time.Time { 126 | return a.time 127 | } 128 | 129 | // Authenticate authenticates against the comdirect REST API. 130 | func (a *Authenticator) Authenticate(ctx context.Context) (*Authentication, error) { 131 | 132 | authCtx, err := a.passwordGrant(ctx, a.authOptions) 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | authCtx, err = a.fetchSessionStatus(ctx, authCtx) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | authCtx, err = a.validateSessionTan(ctx, authCtx) 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | authCtx, err = a.checkAuthenticationStatus(ctx, authCtx) 148 | if err != nil { 149 | return nil, err 150 | } 151 | 152 | authCtx, err = a.activateSessionTan(ctx, authCtx) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | authCtx, err = a.secondaryFlow(ctx, authCtx) 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | return &Authentication{ 163 | accessToken: authCtx.accessToken, 164 | sessionID: authCtx.requestInfo.ClientRequestID.SessionID, 165 | time: time.Now(), 166 | }, err 167 | } 168 | 169 | func (a *Authenticator) Refresh(auth Authentication) (Authentication, error) { 170 | encoded := url.Values{ 171 | "grant_type": {RefreshTokenGrantType}, 172 | "client_id": {a.authOptions.ClientId}, 173 | "client_secret": {a.authOptions.ClientSecret}, 174 | "refresh_token": {auth.accessToken.RefreshToken}, 175 | }.Encode() 176 | 177 | body := ioutil.NopCloser(strings.NewReader(encoded)) 178 | req := &http.Request{ 179 | Method: http.MethodPost, 180 | URL: &url.URL{Host: Host, Scheme: HttpsScheme, Path: OAuthTokenPath}, 181 | Header: http.Header{ 182 | http.CanonicalHeaderKey(AcceptHeaderKey): {mediatype.ApplicationJson}, 183 | http.CanonicalHeaderKey(ContentTypeHeaderKey): {mediatype.XWWWFormUrlEncoded}, 184 | }, 185 | Body: body, 186 | } 187 | 188 | var accessToken AccessToken 189 | _, err := a.http.exchange(req, &accessToken) 190 | 191 | auth.accessToken = accessToken 192 | auth.time = time.Now() 193 | return auth, err 194 | } 195 | 196 | func (a *Authenticator) Revoke(auth Authentication) error { 197 | 198 | req := &http.Request{ 199 | Method: http.MethodDelete, 200 | URL: &url.URL{Host: Host, Scheme: HttpsScheme, Path: OAuthTokenPath}, 201 | Header: http.Header{ 202 | http.CanonicalHeaderKey(AcceptHeaderKey): {mediatype.ApplicationJson}, 203 | http.CanonicalHeaderKey(ContentTypeHeaderKey): {mediatype.XWWWFormUrlEncoded}, 204 | http.CanonicalHeaderKey(AuthorizationHeaderKey): {BearerPrefix + auth.accessToken.AccessToken}, 205 | }, 206 | } 207 | response, err := a.http.Do(req) 208 | if err != nil { 209 | return err 210 | } 211 | if response.StatusCode != 204 { 212 | return errors.New("could not revoke access token") 213 | } 214 | return nil 215 | } 216 | 217 | func (a *Authentication) IsExpired() bool { 218 | expiresIn := time.Duration(a.accessToken.ExpiresIn) 219 | return a.time.Add(expiresIn * time.Second).Before(time.Now()) 220 | } 221 | 222 | // Step 2.1 223 | func (a *Authenticator) passwordGrant(ctx context.Context, options *AuthOptions) (authContext, error) { 224 | 225 | urlEncoded := url.Values{ 226 | "username": {options.Username}, 227 | "password": {options.Password}, 228 | "grant_type": {PasswordGrantType}, 229 | "client_id": {options.ClientId}, 230 | "client_secret": {options.ClientSecret}, 231 | }.Encode() 232 | 233 | body := ioutil.NopCloser(strings.NewReader(urlEncoded)) 234 | req := &http.Request{ 235 | Method: http.MethodPost, 236 | URL: &url.URL{Host: Host, Scheme: HttpsScheme, Path: OAuthTokenPath}, 237 | Header: http.Header{ 238 | http.CanonicalHeaderKey(AcceptHeaderKey): {mediatype.ApplicationJson}, 239 | http.CanonicalHeaderKey(ContentTypeHeaderKey): {mediatype.XWWWFormUrlEncoded}, 240 | }, 241 | Body: body, 242 | } 243 | req = req.WithContext(ctx) 244 | 245 | authCtx := authContext{ 246 | accessToken: AccessToken{}, 247 | } 248 | _, err := a.http.exchange(req, &authCtx.accessToken) 249 | 250 | return authCtx, err 251 | } 252 | 253 | // Step 2.2 254 | func (a *Authenticator) fetchSessionStatus(ctx context.Context, authCtx authContext) (authContext, error) { 255 | authCtx.requestInfo = requestInfo{ 256 | ClientRequestID: clientRequestID{ 257 | SessionID: generateSessionID(), 258 | RequestID: generateRequestID(), 259 | }, 260 | } 261 | requestInfoJSON, err := json.Marshal(authCtx.requestInfo) 262 | if err != nil { 263 | return authCtx, err 264 | } 265 | 266 | req := &http.Request{ 267 | Method: http.MethodGet, 268 | URL: comdirectURL("/api/session/clients/user/v1/sessions"), 269 | Header: http.Header{ 270 | AuthorizationHeaderKey: {BearerPrefix + authCtx.accessToken.AccessToken}, 271 | AcceptHeaderKey: {mediatype.ApplicationJson}, 272 | ContentTypeHeaderKey: {mediatype.ApplicationJson}, 273 | HttpRequestInfoHeaderKey: {string(requestInfoJSON)}, 274 | }, 275 | } 276 | req = req.WithContext(ctx) 277 | 278 | var sessions []session 279 | if _, err = a.http.exchange(req, &sessions); err != nil { 280 | return authCtx, err 281 | } 282 | 283 | if len(sessions) == 0 { 284 | return authCtx, errors.New("length of returned session array is zero; expected at least one") 285 | } 286 | 287 | authCtx.session = sessions[0] 288 | 289 | return authCtx, err 290 | } 291 | 292 | // Step: 2.3 293 | func (a *Authenticator) validateSessionTan(ctx context.Context, authCtx authContext) (authContext, error) { 294 | authCtx.session.SessionTanActive = true 295 | authCtx.session.Activated2FA = true 296 | jsonSession, err := json.Marshal(authCtx.session) 297 | if err != nil { 298 | return authCtx, err 299 | } 300 | 301 | authCtx.requestInfo.ClientRequestID.RequestID = generateRequestID() 302 | JSONRequestInfo, err := json.Marshal(authCtx.requestInfo) 303 | if err != nil { 304 | return authCtx, err 305 | } 306 | 307 | path := fmt.Sprintf("/api/session/clients/user/v1/sessions/%s/validate", authCtx.session.Identifier) 308 | body := ioutil.NopCloser(strings.NewReader(string(jsonSession))) 309 | req := &http.Request{ 310 | Method: http.MethodPost, 311 | URL: comdirectURL(path), 312 | Header: http.Header{ 313 | AuthorizationHeaderKey: {BearerPrefix + authCtx.accessToken.AccessToken}, 314 | AcceptHeaderKey: {mediatype.ApplicationJson}, 315 | ContentTypeHeaderKey: {mediatype.ApplicationJson}, 316 | HttpRequestInfoHeaderKey: {string(JSONRequestInfo)}, 317 | }, 318 | Body: body, 319 | } 320 | req = req.WithContext(ctx) 321 | 322 | res, err := a.http.exchange(req, &authCtx.session) 323 | if err != nil || res == nil { 324 | return authCtx, err 325 | } 326 | 327 | jsonOnceAuthInfo := res.Header.Get(OnceAuthenticationInfoHeaderKey) 328 | if jsonOnceAuthInfo == "" { 329 | return authCtx, errors.New("x-once-authentication-info header missing in response") 330 | } 331 | 332 | err = json.Unmarshal([]byte(jsonOnceAuthInfo), &authCtx.onceAuthInfo) 333 | 334 | if err != nil { 335 | return authCtx, err 336 | } 337 | 338 | return authCtx, res.Body.Close() 339 | } 340 | 341 | // Step 2.4 342 | func (a *Authenticator) activateSessionTan(ctx context.Context, authCtx authContext) (authContext, error) { 343 | authCtx.requestInfo.ClientRequestID.RequestID = generateRequestID() 344 | requestInfoJSON, err := json.Marshal(authCtx.requestInfo) 345 | if err != nil { 346 | return authCtx, err 347 | } 348 | 349 | onceAuthInfoHeader := fmt.Sprintf(`{"id":"%s"}`, authCtx.onceAuthInfo.Id) 350 | path := fmt.Sprintf("/api/session/clients/user/v1/sessions/%s", authCtx.session.Identifier) 351 | JSONSession, err := json.Marshal(authCtx.session) 352 | 353 | if err != nil { 354 | return authContext{}, err 355 | } 356 | 357 | req := &http.Request{ 358 | Method: http.MethodPatch, 359 | URL: comdirectURL(path), 360 | Header: http.Header{ 361 | AuthorizationHeaderKey: {BearerPrefix + authCtx.accessToken.AccessToken}, 362 | AcceptHeaderKey: {mediatype.ApplicationJson}, 363 | ContentTypeHeaderKey: {mediatype.ApplicationJson}, 364 | HttpRequestInfoHeaderKey: {string(requestInfoJSON)}, 365 | OnceAuthenticationInfoHeaderKey: {onceAuthInfoHeader}, 366 | }, 367 | Body: ioutil.NopCloser(strings.NewReader(string(JSONSession))), 368 | } 369 | req = req.WithContext(ctx) 370 | 371 | _, err = a.http.exchange(req, &authCtx.session) 372 | 373 | if err != nil { 374 | return authCtx, err 375 | } 376 | 377 | return authCtx, nil 378 | } 379 | 380 | // Step 2.5 381 | func (a *Authenticator) secondaryFlow(ctx context.Context, authCtx authContext) (authContext, error) { 382 | 383 | body := url.Values{ 384 | "token": {authCtx.accessToken.AccessToken}, 385 | "grant_type": {SecondaryGrantType}, 386 | "client_id": {a.authOptions.ClientId}, 387 | "client_secret": {a.authOptions.ClientSecret}, 388 | }.Encode() 389 | 390 | req := &http.Request{ 391 | Method: http.MethodPost, 392 | URL: &url.URL{Host: Host, Scheme: HttpsScheme, Path: OAuthTokenPath}, 393 | Header: http.Header{ 394 | http.CanonicalHeaderKey(AcceptHeaderKey): {mediatype.ApplicationJson}, 395 | http.CanonicalHeaderKey(ContentTypeHeaderKey): {mediatype.XWWWFormUrlEncoded}, 396 | }, 397 | Body: io.NopCloser(strings.NewReader(body)), 398 | } 399 | req = req.WithContext(ctx) 400 | 401 | res, err := a.http.Do(req) 402 | 403 | if err != nil { 404 | return authCtx, err 405 | } 406 | 407 | if err = json.NewDecoder(res.Body).Decode(&authCtx.accessToken); err != nil { 408 | return authCtx, err 409 | } 410 | 411 | return authCtx, res.Body.Close() 412 | } 413 | 414 | func (a *Authenticator) checkAuthenticationStatus(ctx context.Context, authCtx authContext) (authContext, error) { 415 | authCtx.requestInfo.ClientRequestID.RequestID = generateRequestID() 416 | requestInfoJson, err := json.Marshal(authCtx.requestInfo) 417 | if err != nil { 418 | return authCtx, err 419 | } 420 | 421 | req := &http.Request{ 422 | Method: http.MethodGet, 423 | URL: comdirectURL(authCtx.onceAuthInfo.Link.Href), 424 | Header: http.Header{ 425 | AuthorizationHeaderKey: {BearerPrefix + authCtx.accessToken.AccessToken}, 426 | AcceptHeaderKey: {mediatype.ApplicationJson}, 427 | ContentTypeHeaderKey: {mediatype.ApplicationJson}, 428 | HttpRequestInfoHeaderKey: {string(requestInfoJson)}, 429 | }, 430 | } 431 | req = req.WithContext(ctx) 432 | 433 | for { 434 | select { 435 | // Poll authentication status every 3 seconds 436 | case <-time.After(3 * time.Second): 437 | response, err := a.http.Do(req) 438 | if err != nil { 439 | return authCtx, err 440 | } 441 | var authStatus authStatus 442 | if err = json.NewDecoder(response.Body).Decode(&authStatus); err != nil { 443 | return authCtx, err 444 | } 445 | 446 | if authStatus.Status == "AUTHENTICATED" { 447 | return authCtx, nil 448 | } 449 | // When timeout is reached return with error 450 | case <-ctx.Done(): 451 | return authCtx, ctx.Err() 452 | } 453 | } 454 | 455 | } 456 | -------------------------------------------------------------------------------- /pkg/comdirect/auth_test.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestNewAuthenticator(t *testing.T) { 11 | options := &AuthOptions{ 12 | Username: "", 13 | Password: "", 14 | ClientId: "", 15 | ClientSecret: "", 16 | } 17 | authenticator := NewAuthenticator(options) 18 | if authenticator.authOptions != options { 19 | t.Errorf("actual AuthOptions differ from expected: %v", authenticator.authOptions) 20 | } 21 | } 22 | 23 | func TestNewAuthenticator2(t *testing.T) { 24 | options := &AuthOptions{ 25 | Username: "", 26 | Password: "", 27 | ClientId: "", 28 | ClientSecret: "", 29 | } 30 | authenticator := NewAuthenticator(options) 31 | if authenticator.authOptions != options { 32 | t.Errorf("actual AuthOptions differ from expected: %v", authenticator.authOptions) 33 | } 34 | } 35 | 36 | func TestAuthenticator_Authenticate(t *testing.T) { 37 | authenticator := AuthenticatorFromEnv() 38 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 39 | defer cancel() 40 | _, err := authenticator.Authenticate(ctx) 41 | if err != nil { 42 | t.Errorf("authentication failed %s", err) 43 | } 44 | } 45 | 46 | func AuthenticatorFromEnv() *Authenticator { 47 | options := &AuthOptions{ 48 | Username: os.Getenv("COMDIRECT_USERNAME"), 49 | Password: os.Getenv("COMDIRECT_PASSWORD"), 50 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 51 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 52 | } 53 | return NewAuthenticator(options) 54 | } 55 | 56 | func TestGenerateSessionId(t *testing.T) { 57 | sessionID := generateSessionID() 58 | if len(sessionID) != 32 { 59 | t.Errorf("length of session id not equal to 32: %d", len(sessionID)) 60 | } 61 | } 62 | 63 | func TestGenerateRequestId(t *testing.T) { 64 | requestID := generateRequestID() 65 | if len(requestID) != 9 { 66 | t.Errorf("length of request ID is not equal to 9: %d", len(requestID)) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/comdirect/client.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "time" 8 | 9 | "golang.org/x/time/rate" 10 | ) 11 | 12 | const ( 13 | HttpRequestInfoHeaderKey = "X-Http-Request-Info" 14 | OnceAuthenticationInfoHeaderKey = "X-Once-Authentication-Info" 15 | OnceAuthenticationHeaderKey = "X-Once-Authentication" 16 | AuthorizationHeaderKey = "Authorization" 17 | ContentTypeHeaderKey = "Content-Type" 18 | AcceptHeaderKey = "Accept" 19 | 20 | Host = "api.comdirect.de" 21 | ApiPath = "/api" 22 | OAuthTokenPath = "/oauth/token" 23 | 24 | PasswordGrantType = "password" 25 | SecondaryGrantType = "cd_secondary" 26 | RefreshTokenGrantType = "refresh_token" 27 | 28 | DefaultHttpTimeout = time.Second * 30 29 | HttpsScheme = "https" 30 | BearerPrefix = "Bearer " 31 | 32 | PagingFirstQueryKey = "paging-first" 33 | PagingCountQueryKey = "paging-count" 34 | ProductTypeQueryKey = "productType" 35 | TargetClientIDQueryKey = "targetClientId" 36 | WithoutAttrQueryKey = "without-attr" 37 | WithAttrQueryKey = "with-attr" 38 | ClientConnectionTypeQueryKey = "clientConnectionType" 39 | OrderStatusQueryKey = "orderStatus" 40 | VenueIDQueryKey = "venueId" 41 | SideQueryKey = "side" 42 | OrderTypeQueryKey = "orderType" 43 | InstrumentIDQueryKey = "instrumentId" 44 | WKNQueryKey = "wkn" 45 | ISINQueryKey = "isin" 46 | TypeQueryKey = "type" 47 | BookingStatusQueryKey = "bookingStatus" 48 | MaxBookingDateQueryKey = "max-bookingDate" 49 | ) 50 | 51 | type Client struct { 52 | authenticator *Authenticator 53 | http *HTTPClient 54 | authentication *Authentication 55 | } 56 | 57 | type AmountValue struct { 58 | Value string `json:"value"` 59 | Unit string `json:"unit"` 60 | } 61 | 62 | type Paging struct { 63 | Index int `json:"index"` 64 | Matches int `json:"matches"` 65 | } 66 | 67 | type Options struct { 68 | values Values 69 | } 70 | 71 | type Values map[string]string 72 | 73 | func (o *Options) Add(key string, value string) *Options { 74 | o.values[key] = value 75 | return o 76 | } 77 | 78 | func EmptyOptions() Options { 79 | return Options{ 80 | values: map[string]string{}, 81 | } 82 | } 83 | 84 | func (o *Options) WithValues(values Values) *Options { 85 | for k, v := range values { 86 | o.values[k] = v 87 | } 88 | return o 89 | } 90 | 91 | func (o *Options) Values() Values { 92 | return o.values 93 | } 94 | 95 | // NewWithAuthenticator creates a new Client with a given Authenticator 96 | func NewWithAuthenticator(authenticator *Authenticator) *Client { 97 | return &Client{ 98 | authenticator: authenticator, 99 | http: &HTTPClient{http.Client{Timeout: DefaultHttpTimeout}, *rate.NewLimiter(10, 10)}, 100 | } 101 | } 102 | 103 | // NewWithAuthOptions creates a new Client with given AuthOptions 104 | func NewWithAuthOptions(options *AuthOptions) *Client { 105 | return NewWithAuthenticator(NewAuthenticator(options)) 106 | } 107 | 108 | func NewWithAuthentication(authentication *Authentication) *Client { 109 | return &Client{ 110 | authentication: authentication, 111 | http: &HTTPClient{http.Client{Timeout: DefaultHttpTimeout}, *rate.NewLimiter(10, 10)}, 112 | } 113 | } 114 | 115 | // Authenticate uses the underlying Authenticator to authenticate against the comdirect REST API. 116 | func (c *Client) Authenticate(ctx context.Context) (*Authentication, error) { 117 | if c.authenticator == nil { 118 | return nil, errors.New("authenticator cannot be nil") 119 | } 120 | authentication, err := c.authenticator.Authenticate(ctx) 121 | if err != nil { 122 | return nil, err 123 | } 124 | c.authentication = authentication 125 | return c.authentication, nil 126 | } 127 | 128 | func (c *Client) SetAuthentication(auth *Authentication) error { 129 | if auth == nil { 130 | return errors.New("authentication cannot be nil") 131 | } 132 | c.authentication = auth 133 | return nil 134 | } 135 | 136 | func (c *Client) GetAuthentication() *Authentication { 137 | return c.authentication 138 | } 139 | 140 | func (c *Client) IsAuthenticated() bool { 141 | return c.authentication != nil && c.authentication.accessToken.AccessToken != "" && !c.authentication.IsExpired() 142 | } 143 | 144 | // Revoke uses the underlying Authenticator to revoke an access token. 145 | func (c *Client) Revoke() error { 146 | if c.authenticator == nil { 147 | return errors.New("authenticator cannot be nil") 148 | } 149 | err := c.authenticator.Revoke(*c.authentication) 150 | if err != nil { 151 | return err 152 | } 153 | c.authentication = nil 154 | return nil 155 | } 156 | 157 | // Refresh uses the underlying Authenticator to refresh an access token. 158 | func (c *Client) Refresh() (*Authentication, error) { 159 | if c.authenticator == nil { 160 | return nil, errors.New("authenticator cannot be nil") 161 | } 162 | authentication, err := c.authenticator.Refresh(*c.authentication) 163 | if err != nil { 164 | return nil, err 165 | } 166 | c.authentication = &authentication 167 | return c.authentication, nil 168 | } 169 | -------------------------------------------------------------------------------- /pkg/comdirect/client_test.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import "testing" 4 | 5 | func TestNewWithAuthenticator(t *testing.T) { 6 | // TODO 7 | } 8 | 9 | func TestNewWithAuthOptions(t *testing.T) { 10 | // TODO 11 | } 12 | 13 | func TestClient_Authenticate(t *testing.T) { 14 | // TODO 15 | } 16 | 17 | func TestClient_Refresh(t *testing.T) { 18 | // TODO 19 | } 20 | 21 | func TestClient_Revoke(t *testing.T) { 22 | // TODO 23 | } 24 | -------------------------------------------------------------------------------- /pkg/comdirect/depot.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | type Depot struct { 11 | DepotId string `json:"depotId"` 12 | DepotDisplayId string `json:"depotDisplayId"` 13 | ClientId string `json:"clientId"` 14 | DefaultSettlementAccountId string `json:"defaultSettlementAccountId"` 15 | SettlementAccountIds []string `json:"settlementAccountIds"` 16 | HolderName string `json:"holderName"` 17 | } 18 | 19 | type DepotPosition struct { 20 | DepotId string `json:"depotId"` 21 | PositionId string `json:"positionId"` 22 | Wkn string `json:"wkn"` 23 | CustodyType string `json:"custodyType"` 24 | Quantity AmountValue `json:"quantity"` 25 | AvailableQuantity AmountValue `json:"availableQuantity"` 26 | CurrentPrice Price `json:"currentPrice"` 27 | PrevDayPrice Price `json:"prevDayPrice"` 28 | CurrentValue AmountValue `json:"currentValue"` 29 | PurchaseValue AmountValue `json:"purchaseValue"` 30 | ProfitLossPurchaseAbs AmountValue `json:"profitLossPurchaseAbs"` 31 | ProfitLossPurchaseRel string `json:"profitLossPurchaseRel"` 32 | ProfitLossPrevDayAbs AmountValue `json:"profitLossPrevDayAbs"` 33 | ProfitLossPrevDayRel string `json:"profitLossPrevDayRel"` 34 | AvailableQuantityToHedge AmountValue `json:"availableQuantityToHedge"` 35 | } 36 | 37 | type Price struct { 38 | Price AmountValue `json:"price"` 39 | PriceDateTime string `json:"priceDateTime"` 40 | } 41 | 42 | type DepotTransaction struct { 43 | TransactionID string `json:"transactionId"` 44 | Instrument Instrument `json:"instrument"` 45 | ExecutionPrice AmountValue `json:"executionPrice"` 46 | TransactionValue AmountValue `json:"transactionValue"` 47 | TransactionDirection string `json:"transactionDirection"` 48 | TransactionType string `json:"transactionType"` 49 | FXRate string `json:"fxRate"` 50 | } 51 | 52 | type DepotAggregated struct { 53 | Depot Depot `json:"depot"` 54 | PrevDayValue AmountValue `json:"prevDayValue"` 55 | CurrentValue AmountValue `json:"currentValue"` 56 | PurchaseValue AmountValue `json:"purchaseValue"` 57 | ProfitLossPurchaseAbs AmountValue `json:"ProfitLossPurchaseAbs"` 58 | ProfitLossPurchaseRel string `json:"profitLossPurchaseRel"` 59 | ProfitLossPrevDayAbs AmountValue `json:"profitLossPrevDayAbs"` 60 | ProfitLossPrevDayRel string `json:"profitLossPrevDayRel"` 61 | } 62 | 63 | type DepotTransactions struct { 64 | Paging Paging `json:"paging"` 65 | Values []DepotTransaction `json:"values"` 66 | } 67 | 68 | type DepotPositions struct { 69 | Paging Paging `json:"paging"` 70 | Aggregated DepotAggregated `json:"aggregated"` 71 | Values []DepotPosition `json:"values"` 72 | } 73 | 74 | type Depots struct { 75 | Paging Paging `json:"paging"` 76 | Values []Depot `json:"values"` 77 | } 78 | 79 | // Depots retrieves all depots for the current Authentication. 80 | func (c *Client) Depots(ctx context.Context) (*Depots, error) { 81 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 82 | return nil, errors.New("authentication is expired or not initialized") 83 | } 84 | info, err := requestInfoJSON(c.authentication.sessionID) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | req := &http.Request{ 90 | Method: http.MethodGet, 91 | URL: apiURL("/brokerage/clients/user/v3/depots"), 92 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 93 | } 94 | req = req.WithContext(ctx) 95 | depots := &Depots{} 96 | _, err = c.http.exchange(req, depots) 97 | return depots, err 98 | } 99 | 100 | // DepotPositions retrieves all positions for a specific depot ID. 101 | func (c *Client) DepotPositions(ctx context.Context, depotID string, options ...Options) (*DepotPositions, error) { 102 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 103 | return nil, errors.New("authentication is expired or not initialized") 104 | } 105 | info, err := requestInfoJSON(c.authentication.sessionID) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | req := &http.Request{ 111 | Method: http.MethodGet, 112 | URL: apiURL(fmt.Sprintf("/brokerage/v3/depots/%s/positions", depotID)), 113 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 114 | } 115 | req = req.WithContext(ctx) 116 | depots := &DepotPositions{} 117 | _, err = c.http.exchange(req, depots) 118 | return depots, err 119 | } 120 | 121 | // DepotPosition retrieves a position by its ID from the depot specified by its ID. 122 | func (c *Client) DepotPosition(ctx context.Context, depotID string, positionID string, options ...Options) (*DepotPosition, error) { 123 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 124 | return nil, errors.New("authentication is expired or not initialized") 125 | } 126 | info, err := requestInfoJSON(c.authentication.sessionID) 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | req := &http.Request{ 132 | Method: http.MethodGet, 133 | URL: apiURL(fmt.Sprintf("/brokerage/v3/depots/%s/positions/%s", depotID, positionID)), 134 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 135 | } 136 | req = req.WithContext(ctx) 137 | 138 | positions := &DepotPosition{} 139 | _, err = c.http.exchange(req, positions) 140 | return positions, err 141 | } 142 | 143 | // DepotTransactions retrieves all transactions for a depot specified by its ID. 144 | func (c *Client) DepotTransactions(ctx context.Context, depotID string, options ...Options) (*DepotTransactions, error) { 145 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 146 | return nil, errors.New("authentication is expired or not initialized") 147 | } 148 | info, err := requestInfoJSON(c.authentication.sessionID) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | req := &http.Request{ 154 | Method: http.MethodGet, 155 | URL: apiURL(fmt.Sprintf("/brokerage/v3/depots/%s/transactions", depotID)), 156 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 157 | } 158 | req = req.WithContext(ctx) 159 | 160 | depotTransactions := &DepotTransactions{} 161 | _, err = c.http.exchange(req, depotTransactions) 162 | return depotTransactions, err 163 | } 164 | -------------------------------------------------------------------------------- /pkg/comdirect/depot_test.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestClient_Depots(t *testing.T) { 10 | options := &AuthOptions{ 11 | Username: os.Getenv("COMDIRECT_USERNAME"), 12 | Password: os.Getenv("COMDIRECT_PASSWORD"), 13 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 14 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 15 | } 16 | client := NewWithAuthOptions(options) 17 | 18 | ctx, cancel := contextTimeout10Seconds() 19 | defer cancel() 20 | if _, err := client.Authenticate(ctx); err != nil { 21 | t.Errorf("authentication failed: %s", err) 22 | return 23 | } 24 | 25 | depots, err := client.Depots() 26 | if err != nil { 27 | t.Errorf("failed to retrieve depots: %s", err) 28 | } 29 | 30 | fmt.Printf("successfully retrieved depots:\n%+v", depots) 31 | } 32 | 33 | func TestClient_DepotPositions(t *testing.T) { 34 | options := &AuthOptions{ 35 | Username: os.Getenv("COMDIRECT_USERNAME"), 36 | Password: os.Getenv("COMDIRECT_PASSWORD"), 37 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 38 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 39 | } 40 | client := NewWithAuthOptions(options) 41 | ctx, cancel := contextTimeout10Seconds() 42 | defer cancel() 43 | if _, err := client.Authenticate(ctx); err != nil { 44 | t.Errorf("authentication failed: %s", err) 45 | return 46 | } 47 | 48 | depotPositions, err := client.DepotPositions(os.Getenv("COMDIRECT_DEPOT_ID")) 49 | if err != nil { 50 | t.Errorf("failed to retrieve depot positions: %s", err) 51 | } 52 | 53 | fmt.Printf("successfully retrieved depot positions:\n%+v", depotPositions) 54 | } 55 | 56 | func TestClient_DepotPosition(t *testing.T) { 57 | options := &AuthOptions{ 58 | Username: os.Getenv("COMDIRECT_USERNAME"), 59 | Password: os.Getenv("COMDIRECT_PASSWORD"), 60 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 61 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 62 | } 63 | client := NewWithAuthOptions(options) 64 | 65 | ctx, cancel := contextTimeout10Seconds() 66 | defer cancel() 67 | if _, err := client.Authenticate(ctx); err != nil { 68 | t.Errorf("authentication failed: %s", err) 69 | return 70 | } 71 | 72 | depotPositions, err := client.DepotPosition(os.Getenv("COMDIRECT_DEPOT_ID"), os.Getenv("COMDIRECT_POSITION_ID")) 73 | if err != nil { 74 | t.Errorf("failed to retrieve depot position: %s", err) 75 | } 76 | 77 | fmt.Printf("successfully retrieved depot position:\n%+v", depotPositions) 78 | } 79 | 80 | func TestClient_DepotTransactions(t *testing.T) { 81 | options := &AuthOptions{ 82 | Username: os.Getenv("COMDIRECT_USERNAME"), 83 | Password: os.Getenv("COMDIRECT_PASSWORD"), 84 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 85 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 86 | } 87 | client := NewWithAuthOptions(options) 88 | 89 | ctx, cancel := contextTimeout10Seconds() 90 | defer cancel() 91 | if _, err := client.Authenticate(ctx); err != nil { 92 | t.Errorf("authentication failed: %s", err) 93 | return 94 | } 95 | 96 | depotTransactions, err := client.DepotTransactions(os.Getenv("COMDIRECT_DEPOT_ID")) 97 | if err != nil { 98 | t.Errorf("failed to retrieve depot transactions: %s", err) 99 | } 100 | 101 | fmt.Printf("successfully retrieved depot transactions:\n%+v", depotTransactions) 102 | } 103 | -------------------------------------------------------------------------------- /pkg/comdirect/document.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | ) 13 | 14 | type Document struct { 15 | DocumentID string `json:"documentId"` 16 | Name string `json:"name"` 17 | DateCreation string `json:"dateCreation"` 18 | MimeType string `json:"mimeType"` 19 | Deletable bool `json:"deletable"` 20 | Advertisement bool `json:"advertisement"` 21 | DocumentMetaData DocumentMetaData `json:"documentMetaData"` 22 | } 23 | 24 | type Documents struct { 25 | Paging Paging `json:"paging"` 26 | Values []Document `json:"values"` 27 | } 28 | 29 | type DocumentMetaData struct { 30 | Archived bool `json:"archived"` 31 | AlreadyRead bool `json:"alreadyRead"` 32 | PreDocumentExists bool `json:"predocumentExists"` 33 | } 34 | 35 | func (c *Client) Documents(ctx context.Context, options ...Options) (*Documents, error) { 36 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 37 | return nil, errors.New("authentication is expired or not initialized") 38 | } 39 | info, err := requestInfoJSON(c.authentication.sessionID) 40 | if err != nil { 41 | return nil, err 42 | } 43 | url := apiURL("/messages/clients/user/v2/documents") 44 | 45 | encodeOptions(url, options) 46 | 47 | req := &http.Request{ 48 | Method: http.MethodGet, 49 | URL: url, 50 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 51 | } 52 | req = req.WithContext(ctx) 53 | documents := &Documents{} 54 | _, err = c.http.exchange(req, documents) 55 | return documents, err 56 | } 57 | 58 | func (c *Client) DownloadDocument(ctx context.Context, document *Document, folder string) error { 59 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 60 | return errors.New("authentication is expired or not initialized") 61 | } 62 | info, err := requestInfoJSON(c.authentication.sessionID) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | req := &http.Request{ 68 | Method: http.MethodGet, 69 | URL: apiURL(fmt.Sprintf("/messages/v2/documents/%s", document.DocumentID)), 70 | Header: http.Header{ 71 | AcceptHeaderKey: {document.MimeType}, 72 | ContentTypeHeaderKey: {"application/json"}, 73 | AuthorizationHeaderKey: {BearerPrefix + c.authentication.accessToken.AccessToken}, 74 | HttpRequestInfoHeaderKey: {string(info)}, 75 | }, 76 | } 77 | req = req.WithContext(ctx) 78 | res, err := c.http.Do(req) 79 | defer res.Body.Close() 80 | 81 | if err != nil { 82 | return err 83 | } 84 | 85 | if folder == "" { 86 | folder, err = os.Getwd() 87 | if err != nil { 88 | log.Fatal(err) 89 | } 90 | } 91 | 92 | ext := strings.Split(document.MimeType, "/") 93 | fileName := strings.ReplaceAll(document.Name, " ", "_") 94 | file, err := os.Create(folder + "/" + document.DateCreation + "-" + fileName + "." + ext[1]) 95 | if err != nil { 96 | return err 97 | } 98 | defer file.Close() 99 | 100 | _, _ = io.Copy(file, res.Body) 101 | 102 | return err 103 | } 104 | -------------------------------------------------------------------------------- /pkg/comdirect/document_test.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestClient_Documents(t *testing.T) { 10 | options := &AuthOptions{ 11 | Username: os.Getenv("COMDIRECT_USERNAME"), 12 | Password: os.Getenv("COMDIRECT_PASSWORD"), 13 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 14 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 15 | } 16 | client := NewWithAuthOptions(options) 17 | ctx, cancel := contextTimeout10Seconds() 18 | defer cancel() 19 | if _, err := client.Authenticate(ctx); err != nil { 20 | t.Errorf("authentication failed: %s", err) 21 | return 22 | } 23 | 24 | documents, err := client.Documents(ctx) 25 | if err != nil { 26 | t.Errorf("failed to retrieve instruments: %s", err) 27 | } 28 | 29 | fmt.Printf("successfully retrieved instrument:\n%+v", documents[0]) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/comdirect/instrument.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | type Instrument struct { 10 | InstrumentID string `json:"instrumentId"` 11 | WKN string `json:"wkn"` 12 | ISIN string `json:"isin"` 13 | Mnemonic string `json:"mnemonic"` 14 | Name string `json:"name"` 15 | ShortName string `json:"shortName"` 16 | StaticData StaticData `json:"staticData"` 17 | } 18 | 19 | type Instruments struct { 20 | Values []Instrument `json:"values"` 21 | } 22 | 23 | type StaticData struct { 24 | Notation string `json:"notation"` 25 | Currency string `json:"currency"` 26 | InstrumentType string `json:"instrumentType"` 27 | PriipsRelevant bool `json:"priipsRelevant"` 28 | KidAvailable bool `json:"kidAvailable"` 29 | ShippingWaiverRequired bool `json:"shippingWaiverRequired"` 30 | FundRedemptionLimited bool `json:"fundRedemptionLimited"` 31 | } 32 | 33 | // Instrument retrieves instrument information by WKN, ISIN or mnemonic 34 | func (c *Client) Instrument(instrument string) ([]Instrument, error) { 35 | if !c.IsAuthenticated() { 36 | return nil, errors.New("authentication is expired or not initialized") 37 | } 38 | info, err := requestInfoJSON(c.authentication.sessionID) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | req := &http.Request{ 44 | Method: http.MethodGet, 45 | URL: apiURL(fmt.Sprintf("/brokerage/v1/instruments/%s", instrument)), 46 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 47 | } 48 | 49 | instruments := &Instruments{} 50 | _, err = c.http.exchange(req, instruments) 51 | return instruments.Values, err 52 | } 53 | -------------------------------------------------------------------------------- /pkg/comdirect/instrument_test.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestClient_Instrument(t *testing.T) { 10 | options := &AuthOptions{ 11 | Username: os.Getenv("COMDIRECT_USERNAME"), 12 | Password: os.Getenv("COMDIRECT_PASSWORD"), 13 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 14 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 15 | } 16 | client := NewWithAuthOptions(options) 17 | ctx, cancel := contextTimeout10Seconds() 18 | defer cancel() 19 | if _, err := client.Authenticate(ctx); err != nil { 20 | t.Errorf("authentication failed: %s", err) 21 | return 22 | } 23 | 24 | instruments, err := client.Instrument("865985") 25 | if err != nil { 26 | t.Errorf("failed to retrieve instruments: %s", err) 27 | } 28 | 29 | fmt.Printf("successfully retrieved instrument:\n%+v", instruments[0]) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/comdirect/order.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | const ( 10 | CLIENT_NOT_AUTHENTICATED = "client is not authenticated" 11 | ) 12 | 13 | type Dimension struct { 14 | Venues []Venue `json:"venues"` 15 | } 16 | 17 | type Venue struct { 18 | Name string `json:"name"` 19 | VenueID string `json:"venueId"` 20 | Country string `json:"country"` 21 | Type string `json:"type"` 22 | Currencies []string `json:"currencies"` 23 | Sides []string `json:"sides"` 24 | ValidityTypes []string `json:"validityTypes"` 25 | OrderTypes OrderTypes `json:"orderTypes"` 26 | } 27 | 28 | type Dimensions struct { 29 | Paging Paging `json:"paging"` 30 | Values []Dimension `json:"values"` 31 | } 32 | 33 | type OrderTypes struct { 34 | Quote OrderType `json:"QUOTE"` 35 | Market OrderType `json:"MARKET"` 36 | StopMarket OrderType `json:"STOP_MARKET"` 37 | NextOrder OrderType `json:"NEXT_ORDER"` 38 | OneCancelsOther OrderType `json:"ONE_CANCELS_ORDER"` 39 | Limit OrderType `json:"LIMIT"` 40 | TrailingStopMarket OrderType `json:"TRAILING_STOP_MARKET"` 41 | } 42 | 43 | type OrderType struct { 44 | LimitExtensions []string `json:"limitExtensions"` 45 | TradingRestrictions []string `json:"tradingRestrictions"` 46 | } 47 | 48 | type OrderRequest struct { 49 | DepotID string `json:"depotId,omitempty"` 50 | OrderID string `json:"orderId,omitempty"` 51 | Side string `json:"side"` 52 | InstrumentID string `json:"instrumentId"` 53 | OrderType string `json:"orderType"` 54 | Quantity AmountValue `json:"quantity"` 55 | VenueID string `json:"venueId"` 56 | Limit AmountValue `json:"limit"` 57 | ValidityType string `json:"validityType"` 58 | Validity string `json:"validity"` 59 | } 60 | 61 | type Orders struct { 62 | Paging Paging `json:"paging"` 63 | Values []Order `json:"values"` 64 | } 65 | 66 | type Order struct { 67 | DepotID string `json:"depotId"` 68 | SettlementAccountID string `json:"settlementAccountId"` 69 | OrderID string `json:"orderID"` 70 | CreationTimestamp string `json:"creationTimestamp"` 71 | LegNumber string `json:"legNumber"` 72 | BestEx bool `json:"bestEx"` 73 | OrderType string `json:"orderType"` 74 | OrderStatus string `json:"orderStatus"` 75 | SubOrders []Order `json:"subOrders"` 76 | Side string `json:"side"` 77 | InstrumentID string `json:"instrumentId"` 78 | QuoteTicketID string `json:"quoteTicketId"` 79 | QuoteID string `json:"quoteID"` 80 | VenueID string `json:"venueID"` 81 | Quantity AmountValue `json:"quantity"` 82 | LimitExtension string `json:"limitExtension"` 83 | TradingRestriction string `json:"tradingRestriction"` 84 | Limit AmountValue `json:"limit"` 85 | TriggerLimit AmountValue `json:"triggerLimit"` 86 | // TODO: AmountString 87 | TrailingLimitDistAbs string `json:"trailingLimitDistAbs"` 88 | // TODO: PercentageString 89 | TrailingLimitDistRel string `json:"trailingLimitDistRel"` 90 | ValidityType string `json:"validityType"` 91 | Validity string `json:"validity"` 92 | OpenQuantity AmountValue `json:"openQuantity"` 93 | CancelledQuantity AmountValue `json:"cancelledQuantity"` 94 | ExecutedQuantity AmountValue `json:"executedQuantity"` 95 | ExpectedValue AmountValue `json:"expectedValue"` 96 | Executions []Execution `json:"executions"` 97 | } 98 | 99 | type Execution struct { 100 | ExecutionID string `json:"executionID"` 101 | ExecutionNumber int `json:"executionNumber"` 102 | ExecutedQuantity AmountValue `json:"executedQuantity"` 103 | ExecutionPrice AmountValue `json:"executionPrice"` 104 | ExecutionTimestamp string `json:"executionTimestamp"` 105 | } 106 | 107 | func (c *Client) Dimensions() ([]Dimension, error) { 108 | if !c.IsAuthenticated() { 109 | return nil, errors.New(CLIENT_NOT_AUTHENTICATED) 110 | } 111 | info, err := requestInfoJSON(c.authentication.sessionID) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | req := &http.Request{ 117 | Method: http.MethodGet, 118 | URL: apiURL("/brokerage/v3/orders/dimensions"), 119 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 120 | } 121 | 122 | dimensions := &Dimensions{} 123 | _, err = c.http.exchange(req, dimensions) 124 | return dimensions.Values, err 125 | } 126 | 127 | func (c *Client) Orders(depotID string) ([]Order, error) { 128 | if !c.IsAuthenticated() { 129 | return nil, errors.New(CLIENT_NOT_AUTHENTICATED) 130 | } 131 | info, err := requestInfoJSON(c.authentication.sessionID) 132 | if err != nil { 133 | return nil, err 134 | } 135 | req := &http.Request{ 136 | Method: http.MethodGet, 137 | URL: apiURL(fmt.Sprintf("/brokerage/depots/%s/v3/orders", depotID)), 138 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 139 | } 140 | 141 | orders := &Orders{} 142 | _, err = c.http.exchange(req, orders) 143 | return orders.Values, nil 144 | } 145 | 146 | func (c *Client) Order(orderID string) { 147 | 148 | } 149 | 150 | func (c *Client) CreateOrder(order OrderRequest, tan string) { 151 | // TODO 152 | } 153 | 154 | func (c *Client) UpdateOrder(orderID string, tan string) { 155 | // TODO 156 | } 157 | 158 | func (c *Client) DeleteOrder(orderID string, tan string) { 159 | // TODO 160 | } 161 | 162 | func (c *Client) PreValidateOrder() { 163 | // TODO 164 | } 165 | 166 | func (c *Client) ValidateOrder() { 167 | // TODO 168 | } 169 | 170 | func (c *Client) ExAnteOrder() { 171 | // TODO 172 | } 173 | 174 | func (c *Client) ValidateOrderUpdate(order OrderRequest) { 175 | // TODO 176 | } 177 | 178 | func (c *Client) ValidateOrderDeletion(orderID string) { 179 | // TODO 180 | } 181 | -------------------------------------------------------------------------------- /pkg/comdirect/order_test.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestClient_Dimensions(t *testing.T) { 10 | options := &AuthOptions{ 11 | Username: os.Getenv("COMDIRECT_USERNAME"), 12 | Password: os.Getenv("COMDIRECT_PASSWORD"), 13 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 14 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 15 | } 16 | client := NewWithAuthOptions(options) 17 | ctx, cancel := contextTimeout10Seconds() 18 | defer cancel() 19 | if _, err := client.Authenticate(ctx); err != nil { 20 | t.Errorf("authentication failed: %s", err) 21 | return 22 | } 23 | 24 | instruments, err := client.Dimensions() 25 | if err != nil { 26 | t.Errorf("failed to retrieve instruments: %s", err) 27 | } 28 | 29 | fmt.Printf("successfully retrieved instrument:\n%+v", instruments[0]) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/comdirect/quote.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | func (c *Client) CreateQuoteTicket() { 4 | 5 | } 6 | 7 | func (c *Client) UpdateQuoteTicket(quoteTicketID string) { 8 | 9 | } 10 | 11 | func (c *Client) CreateQuoteRequest() { 12 | 13 | } 14 | 15 | func (c *Client) ValidateQuoteOrder() { 16 | 17 | } 18 | 19 | func (c *Client) CreateQuoteOrder() { 20 | 21 | } 22 | -------------------------------------------------------------------------------- /pkg/comdirect/quote_test.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | -------------------------------------------------------------------------------- /pkg/comdirect/report.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | ) 8 | 9 | type Report struct { 10 | ProductID string `json:"productId"` 11 | ProductType string `json:"productType"` 12 | TargetClientID string `json:"targetClientId"` 13 | ClientConnectionType string `json:"clientConnectionType"` 14 | Balance ReportBalance `json:"balance"` 15 | } 16 | 17 | type ReportBalance struct { 18 | Account Account `json:"account"` 19 | AccountId string `json:"accountId"` 20 | Balance AmountValue `json:"balance"` 21 | BalanceEUR AmountValue `json:"balanceEUR"` 22 | AvailableCashAmount AmountValue `json:"availableCashAmount"` 23 | AvailableCashAmountEUR AmountValue `json:"availableCashAmountEUR"` 24 | Depot Depot `json:"depot"` 25 | DepotID string `json:"depotId"` 26 | DateLastUpdate string `json:"dateLastUpdate"` 27 | PrevDayValue AmountValue `json:"prevDayValue"` 28 | } 29 | 30 | type ReportAggregated struct { 31 | BalanceEUR AmountValue `json:"balanceEUR"` 32 | AvailableCashAmountEUR AmountValue `json:"availableCashAmountEUR"` 33 | } 34 | 35 | type Reports struct { 36 | Paging Paging `json:"paging"` 37 | ReportAggregated ReportAggregated `json:"Aggregated"` 38 | Values []Report `json:"values"` 39 | } 40 | 41 | // Reports returns the balance for all available accounts. 42 | func (c *Client) Reports(ctx context.Context, options ...Options) (*Reports, error) { 43 | if c.authentication == nil || c.authentication.accessToken.AccessToken == "" || c.authentication.IsExpired() { 44 | return nil, errors.New("authentication is expired or not initialized") 45 | } 46 | info, err := requestInfoJSON(c.authentication.sessionID) 47 | if err != nil { 48 | return nil, err 49 | } 50 | req := &http.Request{ 51 | Method: http.MethodGet, 52 | URL: apiURL("/reports/participants/user/v1/allbalances"), 53 | Header: defaultHeaders(c.authentication.accessToken.AccessToken, string(info)), 54 | } 55 | req = req.WithContext(ctx) 56 | 57 | reports := &Reports{} 58 | _, err = c.http.exchange(req, reports) 59 | return reports, err 60 | } 61 | -------------------------------------------------------------------------------- /pkg/comdirect/report_test.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestClient_Reports(t *testing.T) { 9 | options := &AuthOptions{ 10 | Username: os.Getenv("COMDIRECT_USERNAME"), 11 | Password: os.Getenv("COMDIRECT_PASSWORD"), 12 | ClientId: os.Getenv("COMDIRECT_CLIENT_ID"), 13 | ClientSecret: os.Getenv("COMDIRECT_CLIENT_SECRET"), 14 | } 15 | client := NewWithAuthOptions(options) 16 | ctx, cancel := contextTimeout10Seconds() 17 | defer cancel() 18 | if _, err := client.Authenticate(ctx); err != nil { 19 | t.Errorf("authentication failed: %s", err) 20 | return 21 | } 22 | 23 | _, err := client.Reports(ctx) 24 | if err != nil { 25 | t.Errorf("failed to retrieve instruments: %s", err) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/comdirect/util.go: -------------------------------------------------------------------------------- 1 | package comdirect 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "golang.org/x/time/rate" 13 | ) 14 | 15 | // TODO: Think about where to put the stuff in here. 16 | // TODO: Currently this is a pool for everything that does not fit somewhere else 17 | 18 | type HTTPClient struct { 19 | http.Client 20 | rate.Limiter 21 | } 22 | 23 | // api.comdirect.de/api/{path} 24 | func apiURL(path string) *url.URL { 25 | return comdirectURL(ApiPath + path) 26 | } 27 | 28 | // api.comdirect.de/{path} 29 | func comdirectURL(path string) *url.URL { 30 | return &url.URL{Host: Host, Scheme: HttpsScheme, Path: path} 31 | } 32 | 33 | func encodeOptions(url *url.URL, options []Options) { 34 | if options == nil { 35 | return 36 | } 37 | q := url.Query() 38 | for _, o := range options { 39 | for k, v := range o.Values() { 40 | q.Add(k, v) 41 | } 42 | } 43 | url.RawQuery = q.Encode() 44 | } 45 | 46 | func generateSessionID() string { 47 | buf := make([]byte, 16) 48 | if _, err := rand.Read(buf); err != nil { 49 | return fmt.Sprintf("%032d", time.Now().UnixNano()) 50 | } 51 | return hex.EncodeToString(buf) 52 | } 53 | 54 | func generateRequestID() string { 55 | unix := time.Now().Unix() 56 | id := fmt.Sprintf("%09d", unix) 57 | return id[0:9] 58 | } 59 | 60 | func (h *HTTPClient) exchange(request *http.Request, target interface{}) (*http.Response, error) { 61 | err := h.Wait(request.Context()) 62 | if err != nil { 63 | return nil, err 64 | } 65 | res, err := h.Do(request) 66 | if err != nil { 67 | return res, err 68 | } 69 | 70 | if err = json.NewDecoder(res.Body).Decode(target); err != nil { 71 | return res, err 72 | } 73 | 74 | return res, res.Body.Close() 75 | } 76 | 77 | func requestInfoJSON(sessionID string) ([]byte, error) { 78 | info := &requestInfo{ClientRequestID: clientRequestID{ 79 | SessionID: sessionID, 80 | RequestID: generateRequestID(), 81 | }} 82 | return json.Marshal(info) 83 | } 84 | 85 | func defaultHeaders(accessToken string, requestInfoHeader string) http.Header { 86 | return http.Header{ 87 | AcceptHeaderKey: {"application/json"}, 88 | ContentTypeHeaderKey: {"application/json"}, 89 | AuthorizationHeaderKey: {BearerPrefix + accessToken}, 90 | HttpRequestInfoHeaderKey: {requestInfoHeader}, 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=go-comdirect 2 | sonar.organization=jsattler --------------------------------------------------------------------------------