├── .editorconfig ├── .eslintrc ├── .github ├── helper │ └── install.sh └── workflows │ ├── linters.yaml │ ├── release.yaml │ └── server-tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc.json ├── .releaserc ├── LICENSE ├── MANIFEST.in ├── README.md ├── banking ├── .editorconfig ├── __init__.py ├── config │ ├── __init__.py │ ├── desktop.py │ └── docs.py ├── connectors │ ├── admin_request.py │ └── admin_transaction.py ├── custom │ ├── __init__.py │ ├── bank.js │ └── bank.py ├── ebics │ ├── __init__.py │ ├── doctype │ │ ├── __init__.py │ │ ├── ebics_request │ │ │ ├── __init__.py │ │ │ ├── ebics_request.js │ │ │ ├── ebics_request.json │ │ │ ├── ebics_request.py │ │ │ └── test_ebics_request.py │ │ └── ebics_user │ │ │ ├── __init__.py │ │ │ ├── ebics_user.js │ │ │ ├── ebics_user.json │ │ │ ├── ebics_user.py │ │ │ └── test_ebics_user.py │ ├── manager.py │ └── utils.py ├── hooks.py ├── install.py ├── klarna_kosma_integration │ ├── __init__.py │ ├── admin.py │ ├── doctype │ │ ├── __init__.py │ │ ├── bank_reconciliation_tool_beta │ │ │ ├── __init__.py │ │ │ ├── bank_reconciliation_tool_beta.js │ │ │ ├── bank_reconciliation_tool_beta.json │ │ │ ├── bank_reconciliation_tool_beta.md │ │ │ ├── bank_reconciliation_tool_beta.py │ │ │ ├── test_bank_reconciliation_tool_beta.py │ │ │ └── utils.py │ │ ├── banking_reference_mapping │ │ │ ├── __init__.py │ │ │ ├── banking_reference_mapping.json │ │ │ └── banking_reference_mapping.py │ │ └── banking_settings │ │ │ ├── __init__.py │ │ │ ├── banking_settings.js │ │ │ ├── banking_settings.json │ │ │ ├── banking_settings.py │ │ │ └── test_banking_settings.py │ ├── exception_handler.py │ ├── test_kosma.py │ └── workspace │ │ └── alyf_banking │ │ └── alyf_banking.json ├── locale │ ├── de.po │ ├── es.po │ ├── fr.po │ ├── it.po │ └── main.pot ├── modules.txt ├── overrides │ └── bank_transaction.py ├── patches.txt ├── patches │ ├── __init__.py │ └── recreate_custom_fields.py ├── public │ ├── .gitkeep │ ├── images │ │ ├── alyf-logo.png │ │ ├── bank_reco_tool.png │ │ ├── create_voucher.png │ │ ├── match_transaction.png │ │ └── update_transaction.gif │ ├── js │ │ └── bank_reconciliation_beta │ │ │ ├── actions_panel │ │ │ ├── actions_panel_manager.js │ │ │ ├── create_tab.js │ │ │ ├── details_tab.js │ │ │ └── match_tab.js │ │ │ ├── bank_reconciliation_beta.bundle.js │ │ │ ├── panel_manager.js │ │ │ └── summary_number_card.js │ └── scss │ │ ├── bank_reconciliation_beta.bundle.scss │ │ └── bank_reconciliation_beta.scss ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py ├── utils.py └── www │ └── __init__.py ├── pyproject.toml └── ready_for_ebics.jpg /.editorconfig: -------------------------------------------------------------------------------- 1 | # Root editor config file 2 | root = true 3 | 4 | # Common settings 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | # python, js indentation settings 12 | [{*.py,*.js,*.vue,*.css,*.scss,*.html}] 13 | indent_style = tab 14 | indent_size = 4 15 | max_line_length = 110 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es2022": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "extends": "eslint:recommended", 11 | "rules": { 12 | "indent": "off", 13 | "brace-style": "off", 14 | "no-mixed-spaces-and-tabs": "off", 15 | "no-useless-escape": "off", 16 | "space-unary-ops": ["error", { "words": true }], 17 | "linebreak-style": "off", 18 | "quotes": ["off"], 19 | "semi": "off", 20 | "camelcase": "off", 21 | "no-unused-vars": "off", 22 | "no-console": ["warn"], 23 | "no-extra-boolean-cast": ["off"], 24 | "no-control-regex": ["off"], 25 | }, 26 | "root": true, 27 | "globals": { 28 | "frappe": true, 29 | "erpnext": true, 30 | "Vue": true, 31 | "__": true, 32 | "repl": true, 33 | "Class": true, 34 | "locals": true, 35 | "cint": true, 36 | "cstr": true, 37 | "cur_frm": true, 38 | "cur_dialog": true, 39 | "cur_page": true, 40 | "cur_list": true, 41 | "cur_tree": true, 42 | "msg_dialog": true, 43 | "is_null": true, 44 | "in_list": true, 45 | "has_common": true, 46 | "has_words": true, 47 | "validate_email": true, 48 | "validate_name": true, 49 | "validate_phone": true, 50 | "validate_url": true, 51 | "get_number_format": true, 52 | "format_number": true, 53 | "format_currency": true, 54 | "comment_when": true, 55 | "open_url_post": true, 56 | "toTitle": true, 57 | "lstrip": true, 58 | "rstrip": true, 59 | "strip": true, 60 | "strip_html": true, 61 | "replace_all": true, 62 | "flt": true, 63 | "precision": true, 64 | "CREATE": true, 65 | "AMEND": true, 66 | "CANCEL": true, 67 | "copy_dict": true, 68 | "get_number_format_info": true, 69 | "strip_number_groups": true, 70 | "print_table": true, 71 | "Layout": true, 72 | "web_form_settings": true, 73 | "$c": true, 74 | "$a": true, 75 | "$i": true, 76 | "$bg": true, 77 | "$y": true, 78 | "$c_obj": true, 79 | "refresh_many": true, 80 | "refresh_field": true, 81 | "toggle_field": true, 82 | "get_field_obj": true, 83 | "get_query_params": true, 84 | "unhide_field": true, 85 | "hide_field": true, 86 | "set_field_options": true, 87 | "getCookie": true, 88 | "getCookies": true, 89 | "get_url_arg": true, 90 | "md5": true, 91 | "$": true, 92 | "jQuery": true, 93 | "moment": true, 94 | "hljs": true, 95 | "Awesomplete": true, 96 | "Sortable": true, 97 | "Showdown": true, 98 | "Taggle": true, 99 | "Gantt": true, 100 | "Slick": true, 101 | "Webcam": true, 102 | "PhotoSwipe": true, 103 | "PhotoSwipeUI_Default": true, 104 | "fluxify": true, 105 | "io": true, 106 | "JsBarcode": true, 107 | "L": true, 108 | "Chart": true, 109 | "DataTable": true, 110 | "Cypress": true, 111 | "cy": true, 112 | "it": true, 113 | "describe": true, 114 | "expect": true, 115 | "context": true, 116 | "before": true, 117 | "beforeEach": true, 118 | "after": true, 119 | "qz": true, 120 | "localforage": true, 121 | "extend_cscript": true, 122 | "erpnext_germany": true 123 | } 124 | } -------------------------------------------------------------------------------- /.github/helper/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd ~ || exit 6 | 7 | sudo apt update && sudo apt install redis-server libcups2-dev 8 | 9 | pip install frappe-bench 10 | 11 | git clone https://github.com/frappe/frappe --branch version-15 --depth 1 12 | bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench 13 | 14 | mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" 15 | mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" 16 | 17 | mysql --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" 18 | mysql --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe" 19 | mysql --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" 20 | 21 | mysql --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES" 22 | 23 | cd ~/frappe-bench || exit 24 | 25 | sed -i 's/watch:/# watch:/g' Procfile 26 | sed -i 's/schedule:/# schedule:/g' Procfile 27 | sed -i 's/socketio:/# socketio:/g' Procfile 28 | sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile 29 | 30 | bench get-app payments --branch version-15 31 | bench get-app erpnext --branch version-15 32 | bench get-app hrms --branch version-15 33 | bench get-app banking "${GITHUB_WORKSPACE}" 34 | 35 | bench start &> bench_start.log & 36 | bench new-site --db-root-password root --admin-password admin test_site --install-app erpnext 37 | bench --site test_site install-app hrms 38 | bench --site test_site install-app banking 39 | bench setup requirements --dev 40 | -------------------------------------------------------------------------------- /.github/workflows/linters.yaml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | pull_request: { } 5 | 6 | jobs: 7 | 8 | linters: 9 | name: linters 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Set up Python 3.10 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.10' 18 | cache: pip 19 | 20 | - name: Install and Run Pre-commit 21 | uses: pre-commit/action@v3.0.0 22 | 23 | - name: Download Semgrep rules 24 | run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules 25 | 26 | - name: Download semgrep 27 | run: pip install semgrep 28 | 29 | - name: Run Semgrep rules 30 | run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Generate Semantic Release 2 | on: 3 | push: 4 | branches: 5 | - version-15 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Entire Repository 12 | uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | persist-credentials: false # https://github.com/semantic-release/semantic-release/blob/master/docs/recipes/ci-configurations/github-actions.md#pushing-packagejson-changes-to-a-master-branch 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: "lts/*" 20 | - name: Setup dependencies 21 | run: | 22 | npm install @semantic-release/git @semantic-release/exec --no-save 23 | - name: Create Release 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 26 | GIT_AUTHOR_NAME: "alyf-linus" 27 | GIT_AUTHOR_EMAIL: "136631072+alyf-linus@users.noreply.github.com" 28 | GIT_COMMITTER_NAME: "alyf-linus" 29 | GIT_COMMITTER_EMAIL: "136631072+alyf-linus@users.noreply.github.com" 30 | run: npx semantic-release 31 | -------------------------------------------------------------------------------- /.github/workflows/server-tests.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Server 3 | 4 | on: 5 | schedule: 6 | - cron: "42 4 * * *" 7 | push: 8 | branches: 9 | - version-14-hotfix 10 | - version-15-hotfix 11 | paths-ignore: 12 | - "**.css" 13 | - "**.js" 14 | - "**.md" 15 | - "**.html" 16 | - "**.csv" 17 | - "**.pot" 18 | - "**.po" 19 | pull_request: 20 | paths-ignore: 21 | - "**.css" 22 | - "**.js" 23 | - "**.md" 24 | - "**.html" 25 | - "**.csv" 26 | - "**.pot" 27 | - "**.po" 28 | 29 | concurrency: 30 | group: version-15-banking-${{ github.event.number }} 31 | cancel-in-progress: true 32 | 33 | jobs: 34 | tests: 35 | name: Unit Tests 36 | runs-on: ubuntu-latest 37 | timeout-minutes: 60 38 | env: 39 | NODE_ENV: "production" 40 | 41 | strategy: 42 | fail-fast: false 43 | 44 | services: 45 | mariadb: 46 | image: mariadb:10.6 47 | env: 48 | MYSQL_ROOT_PASSWORD: root 49 | ports: 50 | - 3306:3306 51 | options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 52 | 53 | steps: 54 | - name: Clone 55 | uses: actions/checkout@v4 56 | 57 | - name: Setup Python 58 | uses: actions/setup-python@v4 59 | with: 60 | python-version: '3.11' 61 | 62 | - name: Setup Node 63 | uses: actions/setup-node@v3 64 | with: 65 | node-version: 18 66 | check-latest: true 67 | 68 | - name: Add to Hosts 69 | run: | 70 | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts 71 | 72 | - name: Cache pip 73 | uses: actions/cache@v4 74 | with: 75 | path: ~/.cache/pip 76 | key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }} 77 | restore-keys: | 78 | ${{ runner.os }}-pip- 79 | ${{ runner.os }}- 80 | 81 | - name: Get yarn cache directory path 82 | id: yarn-cache-dir-path 83 | run: 'echo "::set-output name=dir::$(yarn cache dir)"' 84 | 85 | - uses: actions/cache@v4 86 | id: yarn-cache 87 | with: 88 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 89 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 90 | restore-keys: | 91 | ${{ runner.os }}-yarn- 92 | 93 | - name: Install 94 | run: | 95 | bash ${GITHUB_WORKSPACE}/.github/helper/install.sh 96 | 97 | - name: Run Tests 98 | working-directory: /home/runner/frappe-bench 99 | run: | 100 | bench --site test_site set-config allow_tests true 101 | bench --site test_site run-tests --app banking 102 | env: 103 | TYPE: server 104 | 105 | - name: Show bench output 106 | if: ${{ always() }} 107 | run: cat ~/frappe-bench/bench_start.log || true 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | dist/ 6 | tags 7 | banking/docs/current 8 | node_modules/ 9 | __pycache__ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: 'node_modules|.git' 2 | default_stages: [pre-commit] 3 | fail_fast: false 4 | 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.3.0 9 | hooks: 10 | - id: trailing-whitespace 11 | files: "banking.*" 12 | exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" 13 | - id: check-merge-conflict 14 | - id: check-ast 15 | - id: check-json 16 | - id: check-toml 17 | - id: check-yaml 18 | - id: debug-statements 19 | 20 | - repo: https://github.com/astral-sh/ruff-pre-commit 21 | rev: v0.11.7 22 | hooks: 23 | - id: ruff 24 | name: "Run ruff linter and apply fixes" 25 | args: ["--fix"] 26 | 27 | - id: ruff-format 28 | name: "Format Python code" 29 | 30 | - repo: https://github.com/pre-commit/mirrors-prettier 31 | rev: v2.7.1 32 | hooks: 33 | - id: prettier 34 | types_or: [javascript, vue, scss] 35 | # Ignore any files that might contain jinja / bundles 36 | exclude: | 37 | (?x)^( 38 | .*node_modules.* 39 | )$ 40 | 41 | - repo: https://github.com/pre-commit/mirrors-eslint 42 | rev: v8.44.0 43 | hooks: 44 | - id: eslint 45 | types_or: [javascript] 46 | args: ['--quiet'] 47 | # Ignore any files that might contain jinja / bundles 48 | exclude: | 49 | (?x)^( 50 | .*node_modules.* 51 | )$ 52 | 53 | ci: 54 | autoupdate_schedule: weekly 55 | skip: [] 56 | submodules: false -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "useTabs": true 5 | } 6 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["version-15"], 3 | "plugins": [ 4 | [ 5 | "@semantic-release/commit-analyzer", { 6 | "releaseRules": [ 7 | {"breaking": true, "release": "minor"}, 8 | {"revert": true, "release": "patch"}, 9 | {"type": "feat", "release": "minor"}, 10 | {"type": "patch", "release": "minor"}, 11 | {"type": "fix", "release": "patch"}, 12 | {"type": "perf", "release": "patch"}, 13 | {"type": "refactor", "release": "patch"}, 14 | {"type": "docs", "release": "patch"}, 15 | {"type": "chore", "release": "patch"} 16 | ] 17 | } 18 | ], 19 | "@semantic-release/release-notes-generator", 20 | [ 21 | "@semantic-release/exec", { 22 | "prepareCmd": 'sed -ir -E "s/\"[0-9]+\.[0-9]+\.[0-9]+\"/\"${nextRelease.version}\"/" banking/__init__.py' 23 | } 24 | ], 25 | [ 26 | "@semantic-release/git", { 27 | "assets": ["banking/__init__.py"], 28 | "message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}" 29 | } 30 | ], 31 | "@semantic-release/github" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include requirements.txt 3 | include *.json 4 | include *.md 5 | include *.py 6 | include *.txt 7 | recursive-include klarna_kosma_integration *.css 8 | recursive-include klarna_kosma_integration *.csv 9 | recursive-include klarna_kosma_integration *.html 10 | recursive-include klarna_kosma_integration *.ico 11 | recursive-include klarna_kosma_integration *.js 12 | recursive-include klarna_kosma_integration *.json 13 | recursive-include klarna_kosma_integration *.md 14 | recursive-include klarna_kosma_integration *.png 15 | recursive-include klarna_kosma_integration *.py 16 | recursive-include klarna_kosma_integration *.svg 17 | recursive-include klarna_kosma_integration *.txt 18 | recursive-exclude klarna_kosma_integration *.pyc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

ALYF Banking

4 |
5 | 6 |
7 |

ALYF Banking is a seamless solution for connecting your bank accounts with ERPNext.

8 | 9 |

This app is designed to simplify your financial management by effortlessly fetching transactions from thousands of banks and integrating them directly into your ERPNext system. Say goodbye to manual data entry and time-consuming reconciliations ✨

10 | 11 |

Experience the ease of automation and gain better control over your finances with the ultimate banking integration app for ERPNext users.

12 |
13 | 14 |
15 | Note: Our improved Bank Reconciliation Tool is free to use and compatible with other bank integrations. The Bank Integration works with a paid subscription. Visit banking.alyf.de to check out the pricing and sign up. 16 |
17 | 18 | ## Documentation 19 | Check out the [Banking Wiki](https://github.com/alyf-de/banking/wiki) for a step-by-step guide on how to use the app. 20 | 21 | ## Country and Bank Coverage 22 | 23 | 24 | 25 | We use the EBICS protocol which is widely supported by banks in the following countries: 26 | 27 | - 🇦🇹 Austria 28 | - 🇫🇷 France 29 | - 🇩🇪 Germany 30 | - 🇨🇭 Switzerland 31 | 32 | ## Installation 33 | 34 | Install [via Frappe Cloud](https://frappecloud.com/marketplace/apps/banking) or on your local bench: 35 | 36 | ```bash 37 | bench get-app https://github.com/alyf-de/banking.git 38 | bench --site install-app banking 39 | ``` 40 | 41 | If you want to use ebics on Apple Silicon, the runtime library must be signed manually: 42 | 43 | ```bash 44 | # python3.11 45 | sudo codesign --force --deep --sign - env/lib/python3.11/site-packages/fintech/runtime/darwin/aarch64/pyarmor_runtime.so 46 | 47 | # python3.10 48 | sudo codesign --force --deep --sign - env/lib/python3.10/site-packages/fintech/pytransform/platforms/darwin/aarch64/_pytransform.dylib 49 | ``` 50 | -------------------------------------------------------------------------------- /banking/.editorconfig: -------------------------------------------------------------------------------- 1 | # Root editor config file 2 | root = true 3 | 4 | # Common settings 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | # python, js indentation settings 12 | [{*.py,*.js}] 13 | indent_style = tab 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /banking/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "15.17.2" 2 | -------------------------------------------------------------------------------- /banking/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/config/__init__.py -------------------------------------------------------------------------------- /banking/config/desktop.py: -------------------------------------------------------------------------------- 1 | from frappe import _ 2 | 3 | 4 | def get_data(): 5 | return [ 6 | { 7 | "module_name": "Klarna Kosma Integration", 8 | "type": "module", 9 | "label": _("Klarna Kosma Integration"), 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /banking/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/banking" 6 | # headline = "App that does everything" 7 | # sub_heading = "Yes, you got that right the first time, everything" 8 | 9 | 10 | def get_context(context): 11 | context.brand_html = "ALYF Banking" 12 | -------------------------------------------------------------------------------- /banking/connectors/admin_request.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, ALYF GmbH and contributors 2 | # For license information, please see license.txt 3 | import requests 4 | 5 | 6 | class AdminRequest: 7 | def __init__( 8 | self, 9 | api_token: str, 10 | url: str, 11 | customer_id: str, 12 | ) -> None: 13 | self.api_token = api_token 14 | self.base_url = url 15 | self.customer_id = customer_id 16 | 17 | def request(self, method: str, endpoint: str, data: dict | None = None): 18 | return requests.request( 19 | method=method, 20 | url=f"{self.base_url}/api/method/{endpoint}", 21 | headers={ 22 | "Alyf-Banking-Authorization": f"Token {self.api_token}", 23 | "Alyf-Customer-Id": self.customer_id, 24 | }, 25 | json=data, 26 | ) 27 | 28 | def fetch_subscription(self): 29 | return self.request("GET", "banking_admin.api.fetch_subscription_details") 30 | 31 | def get_customer_portal(self): 32 | return self.request("GET", "banking_admin.api.get_customer_portal") 33 | 34 | def get_fintech_license(self): 35 | return self.request("GET", "banking_admin.ebics_api.get_fintech_license") 36 | 37 | def register_ebics_user(self, host_id: str, partner_id: str, user_id: str, remove: bool = False): 38 | if remove: 39 | endpoint = "banking_admin.ebics_api.remove_ebics_user" 40 | else: 41 | endpoint = "banking_admin.ebics_api.register_ebics_user" 42 | 43 | return self.request( 44 | "POST", 45 | endpoint, 46 | {"host_id": host_id, "partner_id": partner_id, "user_id": user_id}, 47 | ) 48 | -------------------------------------------------------------------------------- /banking/connectors/admin_transaction.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, ALYF GmbH and contributors 2 | # For license information, please see license.txt 3 | 4 | from frappe.utils import formatdate, today 5 | 6 | 7 | class AdminTransaction: 8 | def __init__(self, response_value) -> None: 9 | self.result = response_value.get("result", {}) 10 | self.pagination = self.result.get("pagination", {}) 11 | self.transaction_list = self.result.get("transactions", []) 12 | 13 | def is_next_page(self) -> bool: 14 | next_page = bool(self.pagination and self.pagination.get("next")) 15 | return next_page 16 | 17 | def next_page_request(self): 18 | url = self.pagination.get("url") 19 | offset = self.pagination.get("next").get("offset") 20 | return url, offset 21 | 22 | @staticmethod 23 | def payload(account_id: str, start_date: str) -> dict: 24 | payload = { 25 | "account_id": account_id, 26 | "from_date": start_date, 27 | "to_date": formatdate(today(), "YYYY-MM-dd"), 28 | "preferred_pagination_size": 1000, 29 | } 30 | 31 | return payload 32 | -------------------------------------------------------------------------------- /banking/custom/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/custom/__init__.py -------------------------------------------------------------------------------- /banking/custom/bank.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on("Bank", { 2 | refresh: function (frm) { 3 | if (frm.doc.ebics_host_id && frm.doc.ebics_url) { 4 | frm.add_custom_button( 5 | __("Show Protocol Versions"), 6 | () => { 7 | frappe.call({ 8 | method: "banking.custom.bank.get_protocol_versions", 9 | args: { 10 | bank_name: frm.doc.name, 11 | }, 12 | callback: (r) => { 13 | frappe.msgprint({ 14 | title: __("EBICS Protocol Versions"), 15 | message: JSON.stringify(r.message, null, 2), 16 | }); 17 | }, 18 | }); 19 | }, 20 | "EBICS" 21 | ); 22 | } 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /banking/custom/bank.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | 4 | from banking.ebics.utils import get_protocol_versions as _get_protocol_versions 5 | 6 | 7 | @frappe.whitelist() 8 | def get_protocol_versions(bank_name: str): 9 | bank = frappe.get_doc("Bank", bank_name) 10 | bank.check_permission("write") 11 | 12 | if not bank.ebics_host_id or not bank.ebics_url: 13 | frappe.throw( 14 | _("Bank {0} does not have EBICS Host ID or URL set").format(bank_name), 15 | title=_("EBICS Configuration Missing"), 16 | ) 17 | 18 | return _get_protocol_versions(bank.ebics_host_id, bank.ebics_url) 19 | -------------------------------------------------------------------------------- /banking/ebics/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/ebics/__init__.py -------------------------------------------------------------------------------- /banking/ebics/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/ebics/doctype/__init__.py -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/ebics/doctype/ebics_request/__init__.py -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_request/ebics_request.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025, ALYF GmbH and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("EBICS Request", { 5 | // refresh: function(frm) { 6 | // } 7 | }); 8 | -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_request/ebics_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2025-03-12 17:57:07.347730", 4 | "default_view": "List", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "ebics_user", 10 | "order_type", 11 | "requested_by", 12 | "parameters", 13 | "status", 14 | "response" 15 | ], 16 | "fields": [ 17 | { 18 | "fieldname": "ebics_user", 19 | "fieldtype": "Link", 20 | "label": "EBICS User", 21 | "options": "EBICS User" 22 | }, 23 | { 24 | "fieldname": "order_type", 25 | "fieldtype": "Data", 26 | "in_list_view": 1, 27 | "label": "Order Type" 28 | }, 29 | { 30 | "fieldname": "requested_by", 31 | "fieldtype": "Select", 32 | "in_list_view": 1, 33 | "label": "Requested By", 34 | "options": "System\nUser" 35 | }, 36 | { 37 | "fieldname": "parameters", 38 | "fieldtype": "Code", 39 | "label": "Parameters" 40 | }, 41 | { 42 | "fieldname": "status", 43 | "fieldtype": "Select", 44 | "in_list_view": 1, 45 | "label": "Status", 46 | "options": "Successful\nNo Data Available\nFailed" 47 | }, 48 | { 49 | "fieldname": "response", 50 | "fieldtype": "Code", 51 | "label": "Response" 52 | } 53 | ], 54 | "in_create": 1, 55 | "links": [], 56 | "modified": "2025-03-12 19:06:17.168084", 57 | "modified_by": "Administrator", 58 | "module": "EBICS", 59 | "name": "EBICS Request", 60 | "owner": "Administrator", 61 | "permissions": [ 62 | { 63 | "export": 1, 64 | "read": 1, 65 | "report": 1, 66 | "role": "System Manager" 67 | } 68 | ], 69 | "sort_field": "modified", 70 | "sort_order": "DESC", 71 | "states": [] 72 | } -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_request/ebics_request.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, ALYF GmbH and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class EBICSRequest(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_request/test_ebics_request.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, ALYF GmbH and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestEBICSRequest(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/ebics/doctype/ebics_user/__init__.py -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_user/ebics_user.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, ALYF GmbH and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("EBICS User", { 5 | refresh(frm) { 6 | if (frm.doc.initialized && !frm.doc.bank_keys_activated) { 7 | frm.dashboard.set_headline( 8 | __( 9 | "Please print the attached INI letter, send it to your bank and wait for confirmation. Then verify the bank keys." 10 | ) 11 | ); 12 | } 13 | 14 | if (!frm.doc.initialized || frappe.boot.developer_mode) { 15 | frm.add_custom_button( 16 | __("Initialize"), 17 | () => { 18 | frappe.prompt( 19 | [ 20 | { 21 | fieldname: "passphrase", 22 | label: __("Passphrase"), 23 | fieldtype: "Password", 24 | description: __( 25 | "Set a new password for downloading bank statements from your bank." 26 | ), 27 | }, 28 | { 29 | fieldname: "store_passphrase", 30 | label: __("Store Passphrase"), 31 | fieldtype: "Check", 32 | default: 1, 33 | description: __( 34 | "Store the passphrase in the ERPNext database to enable automated, regular download of bank statements." 35 | ), 36 | }, 37 | { 38 | fieldname: "signature_passphrase", 39 | label: __("Signature Passphrase"), 40 | fieldtype: "Password", 41 | description: __( 42 | "Set a new password for uploading transactions to your bank." 43 | ), 44 | }, 45 | { 46 | fieldname: "info", 47 | fieldtype: "HTML", 48 | options: __( 49 | "Note: When you lose these passwords, you will have to go through the initialization process with your bank again." 50 | ), 51 | }, 52 | ], 53 | (values) => { 54 | frappe.call({ 55 | method: 56 | "banking.ebics.doctype.ebics_user.ebics_user.initialize", 57 | args: { ebics_user: frm.doc.name, ...values }, 58 | freeze: true, 59 | freeze_message: __("Initializing..."), 60 | callback: () => frm.reload_doc(), 61 | }); 62 | }, 63 | __("Initialize EBICS User"), 64 | __("Initialize") 65 | ); 66 | }, 67 | frm.doc.initialized ? __("Actions") : null 68 | ); 69 | } 70 | 71 | if ( 72 | frm.doc.initialized && 73 | (!frm.doc.bank_keys_activated || frappe.boot.developer_mode) 74 | ) { 75 | frm.add_custom_button( 76 | __("Verify Bank Keys"), 77 | async () => { 78 | let passphrase = null; 79 | if (!frm.doc.passphrase) { 80 | passphrase = await ask_for_passphrase(); 81 | } 82 | 83 | const bank_keys = await get_bank_keys(frm.doc.name, passphrase); 84 | if (!bank_keys) { 85 | return; 86 | } 87 | 88 | const message = __( 89 | "Please confirm that the following keys are identical to the ones mentioned on your bank's letter:" 90 | ); 91 | frappe.confirm( 92 | `

${message}

93 |
${bank_keys}
`, 94 | async () => { 95 | await confirm_bank_keys(frm.doc.name, passphrase); 96 | frm.reload_doc(); 97 | } 98 | ); 99 | }, 100 | frm.doc.bank_keys_activated ? __("Actions") : null 101 | ); 102 | } 103 | 104 | if (frm.doc.initialized && frm.doc.bank_keys_activated) { 105 | frm.add_custom_button(__("Download Bank Statements"), () => { 106 | download_bank_statements(frm.doc.name, !frm.doc.passphrase); 107 | }); 108 | } 109 | }, 110 | }); 111 | 112 | function ask_for_passphrase() { 113 | return new Promise((resolve) => { 114 | frappe.prompt( 115 | [ 116 | { 117 | fieldname: "passphrase", 118 | label: __("Passphrase"), 119 | fieldtype: "Password", 120 | reqd: true, 121 | }, 122 | ], 123 | (values) => { 124 | resolve(values.passphrase); 125 | }, 126 | __("Enter Passphrase"), 127 | __("Continue") 128 | ); 129 | }); 130 | } 131 | 132 | async function get_bank_keys(ebics_user, passphrase) { 133 | try { 134 | return await frappe.xcall( 135 | "banking.ebics.doctype.ebics_user.ebics_user.download_bank_keys", 136 | { ebics_user: ebics_user, passphrase: passphrase } 137 | ); 138 | } catch (e) { 139 | frappe.show_alert({ 140 | message: e || __("An error occurred"), 141 | indicator: "red", 142 | }); 143 | } 144 | } 145 | 146 | async function confirm_bank_keys(ebics_user, passphrase) { 147 | try { 148 | await frappe.xcall( 149 | "banking.ebics.doctype.ebics_user.ebics_user.confirm_bank_keys", 150 | { ebics_user: ebics_user, passphrase: passphrase } 151 | ); 152 | frappe.show_alert({ 153 | message: __("Bank keys confirmed"), 154 | indicator: "green", 155 | }); 156 | } catch (e) { 157 | frappe.show_alert({ 158 | message: e || __("An error occurred"), 159 | indicator: "red", 160 | }); 161 | } 162 | } 163 | 164 | function download_bank_statements(ebics_user, needs_passphrase) { 165 | const dialog = frappe.prompt( 166 | [ 167 | { 168 | fieldname: "from_date", 169 | label: __("From Date"), 170 | fieldtype: "Date", 171 | default: frappe.datetime.now_date(), 172 | onchange: () => { 173 | const from_date = dialog.get_value("from_date"); 174 | const empty_disclaimer = __( 175 | "If no Bank Transactions are created, please check the Error Logs. If there are no errors, the bank likely did not provide any (new) bank statements for this period." 176 | ); 177 | if (from_date == frappe.datetime.now_date()) { 178 | dialog.set_df_property( 179 | "note", 180 | "options", 181 | __( 182 | "We'll try to download new transactions from today, using camt.052." 183 | ) + `

${empty_disclaimer}` 184 | ); 185 | } else { 186 | dialog.set_df_property( 187 | "note", 188 | "options", 189 | __( 190 | "We'll try to download all transactions of completed days in the selected period, using camt.053." 191 | ) + `

${empty_disclaimer}` 192 | ); 193 | } 194 | }, 195 | }, 196 | { 197 | fieldname: "to_date", 198 | label: __("To Date"), 199 | fieldtype: "Date", 200 | default: frappe.datetime.now_date(), 201 | }, 202 | ...(needs_passphrase 203 | ? [ 204 | { 205 | fieldname: "passphrase", 206 | label: __("Passphrase"), 207 | fieldtype: "Password", 208 | reqd: true, 209 | }, 210 | ] 211 | : []), 212 | { 213 | fieldname: "note", 214 | fieldtype: "HTML", 215 | }, 216 | ], 217 | async (values) => { 218 | try { 219 | await frappe.xcall( 220 | "banking.ebics.doctype.ebics_user.ebics_user.download_bank_statements", 221 | { 222 | ebics_user: ebics_user, 223 | from_date: values.from_date, 224 | to_date: values.to_date, 225 | passphrase: values.passphrase, 226 | } 227 | ); 228 | frappe.show_alert({ 229 | message: __( 230 | "Bank statements are being downloaded in the background." 231 | ), 232 | indicator: "blue", 233 | }); 234 | } catch (e) { 235 | frappe.show_alert({ 236 | message: e || __("An error occurred"), 237 | indicator: "red", 238 | }); 239 | } 240 | }, 241 | __("Download Bank Statements"), 242 | __("Download") 243 | ); 244 | } 245 | -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_user/ebics_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2024-09-06 16:28:04.926015", 5 | "doctype": "DocType", 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "full_name", 9 | "start_date", 10 | "initialized", 11 | "bank_keys_activated", 12 | "column_break_hlxa", 13 | "company", 14 | "country", 15 | "bank", 16 | "split_batch_transactions", 17 | "intraday_sync", 18 | "section_break_qjlc", 19 | "partner_id", 20 | "user_id", 21 | "needs_certificates", 22 | "section_break_juzm", 23 | "passphrase", 24 | "keyring" 25 | ], 26 | "fields": [ 27 | { 28 | "fieldname": "full_name", 29 | "fieldtype": "Data", 30 | "in_list_view": 1, 31 | "label": "Full Name", 32 | "mandatory_depends_on": "needs_certificates" 33 | }, 34 | { 35 | "fieldname": "column_break_hlxa", 36 | "fieldtype": "Column Break" 37 | }, 38 | { 39 | "fieldname": "company", 40 | "fieldtype": "Link", 41 | "in_list_view": 1, 42 | "label": "Company", 43 | "mandatory_depends_on": "needs_certificates", 44 | "options": "Company" 45 | }, 46 | { 47 | "fieldname": "country", 48 | "fieldtype": "Link", 49 | "label": "Country", 50 | "mandatory_depends_on": "needs_certificates", 51 | "options": "Country" 52 | }, 53 | { 54 | "description": "Please enter the values provided by your bank.", 55 | "fieldname": "section_break_qjlc", 56 | "fieldtype": "Section Break" 57 | }, 58 | { 59 | "fieldname": "partner_id", 60 | "fieldtype": "Data", 61 | "label": "Partner ID" 62 | }, 63 | { 64 | "fieldname": "user_id", 65 | "fieldtype": "Data", 66 | "label": "User ID" 67 | }, 68 | { 69 | "fieldname": "bank", 70 | "fieldtype": "Link", 71 | "in_list_view": 1, 72 | "label": "Bank", 73 | "mandatory_depends_on": "needs_certificates", 74 | "options": "Bank" 75 | }, 76 | { 77 | "default": "0", 78 | "description": "Enable this for EBICS accounts whose key management is based on certificates (eg. French banks).", 79 | "fieldname": "needs_certificates", 80 | "fieldtype": "Check", 81 | "label": "Needs Certificate" 82 | }, 83 | { 84 | "default": "0", 85 | "fieldname": "initialized", 86 | "fieldtype": "Check", 87 | "label": "Initialized", 88 | "no_copy": 1, 89 | "read_only": 1 90 | }, 91 | { 92 | "default": "0", 93 | "fieldname": "bank_keys_activated", 94 | "fieldtype": "Check", 95 | "label": "Bank Keys Activated", 96 | "no_copy": 1, 97 | "read_only": 1 98 | }, 99 | { 100 | "collapsible": 1, 101 | "fieldname": "section_break_juzm", 102 | "fieldtype": "Section Break", 103 | "label": "Credentials" 104 | }, 105 | { 106 | "fieldname": "keyring", 107 | "fieldtype": "Code", 108 | "label": "Keyring", 109 | "no_copy": 1, 110 | "read_only": 1 111 | }, 112 | { 113 | "description": "Enter your password to enable automated, regular syncing. Leave blank if you prefer to sync manually.", 114 | "fieldname": "passphrase", 115 | "fieldtype": "Password", 116 | "label": "Passphrase", 117 | "no_copy": 1 118 | }, 119 | { 120 | "description": "Bank transactions that happened before this date must not be imported.", 121 | "fieldname": "start_date", 122 | "fieldtype": "Date", 123 | "label": "Start Date" 124 | }, 125 | { 126 | "default": "0", 127 | "description": "If enabled, camt.052 transactions are downloaded four times a day. Otherwise camt.053 transactions are downloaded once a day.", 128 | "fieldname": "intraday_sync", 129 | "fieldtype": "Check", 130 | "label": "Intraday Sync" 131 | }, 132 | { 133 | "default": "1", 134 | "description": "If enabled, a separate Bank Transaction will be created for each sub-transaction in a batch.", 135 | "fieldname": "split_batch_transactions", 136 | "fieldtype": "Check", 137 | "label": "Split Batch Transactions" 138 | } 139 | ], 140 | "links": [ 141 | { 142 | "link_doctype": "EBICS Request", 143 | "link_fieldname": "ebics_user" 144 | } 145 | ], 146 | "modified": "2025-03-12 17:59:35.346499", 147 | "modified_by": "Administrator", 148 | "module": "EBICS", 149 | "name": "EBICS User", 150 | "owner": "Administrator", 151 | "permissions": [ 152 | { 153 | "create": 1, 154 | "delete": 1, 155 | "email": 1, 156 | "export": 1, 157 | "print": 1, 158 | "read": 1, 159 | "report": 1, 160 | "role": "System Manager", 161 | "share": 1, 162 | "write": 1 163 | }, 164 | { 165 | "create": 1, 166 | "delete": 1, 167 | "email": 1, 168 | "export": 1, 169 | "print": 1, 170 | "read": 1, 171 | "report": 1, 172 | "role": "Accounts Manager", 173 | "share": 1, 174 | "write": 1 175 | } 176 | ], 177 | "search_fields": "full_name,bank,company", 178 | "sort_field": "modified", 179 | "sort_order": "DESC", 180 | "states": [], 181 | "title_field": "full_name" 182 | } -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_user/ebics_user.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, ALYF GmbH and contributors 2 | # For license information, please see license.txt 3 | import json 4 | 5 | import frappe 6 | from frappe import _ 7 | from frappe.model.document import Document 8 | from frappe.utils import get_link_to_form 9 | from frappe.utils.data import getdate 10 | from requests import HTTPError 11 | 12 | from banking.ebics.utils import get_ebics_manager, sync_ebics_transactions 13 | from banking.klarna_kosma_integration.admin import Admin 14 | 15 | 16 | class EBICSUser(Document): 17 | def validate(self): 18 | if self.country: 19 | self.validate_country_code() 20 | 21 | if self.bank: 22 | self.validate_bank() 23 | 24 | def before_insert(self): 25 | self.register_user() 26 | 27 | def on_update(self): 28 | self.register_user() 29 | 30 | def on_trash(self): 31 | self.remove_user() 32 | 33 | def register_user(self): 34 | """Indempotent method to register the user with the admin backend.""" 35 | host_id = frappe.db.get_value("Bank", self.bank, "ebics_host_id") 36 | try: 37 | r = Admin().request.register_ebics_user(host_id, self.partner_id, self.user_id) 38 | r.raise_for_status() 39 | except HTTPError as e: 40 | if e.response.status_code == 402: 41 | # User already exists for this customer 42 | return 43 | elif e.response.status_code == 403: 44 | title = _("Banking Error") 45 | msg = _("EBICS User limit exceeded.") 46 | frappe.log_error( 47 | title=_("Banking Error"), 48 | message=msg, 49 | reference_doctype="EBICS User", 50 | reference_name=self.name, 51 | ) 52 | frappe.throw(title=title, msg=msg) 53 | elif e.response.status_code == 409: 54 | title = _("Banking Error") 55 | msg = _("User ID not available.") 56 | frappe.log_error( 57 | title=_("Banking Error"), 58 | message=msg, 59 | reference_doctype="EBICS User", 60 | reference_name=self.name, 61 | ) 62 | frappe.throw(title=title, msg=msg) 63 | 64 | def remove_user(self): 65 | """Indempotent method to remove the user from the admin backend.""" 66 | host_id = frappe.db.get_value("Bank", self.bank, "ebics_host_id") 67 | try: 68 | r = Admin().request.register_ebics_user(host_id, self.partner_id, self.user_id, remove=True) 69 | r.raise_for_status() 70 | except HTTPError: 71 | title = _("Failed to remove EBICS user registration.") 72 | frappe.log_error( 73 | title=title, 74 | reference_doctype="EBICS User", 75 | reference_name=self.name, 76 | ) 77 | frappe.throw(title) 78 | 79 | def validate_country_code(self): 80 | country_code = frappe.db.get_value("Country", self.country, "code") 81 | if not country_code or len(country_code) != 2: 82 | frappe.throw( 83 | _("Please add a two-letter country code to country {0}").format( 84 | get_link_to_form("Country", self.country) 85 | ) 86 | ) 87 | 88 | def validate_bank(self): 89 | host_id, url = frappe.db.get_value("Bank", self.bank, ["ebics_host_id", "ebics_url"]) 90 | if not host_id or not url: 91 | frappe.throw( 92 | _("Please add EBICS Host ID and URL to bank {0}").format(get_link_to_form("Bank", self.bank)) 93 | ) 94 | 95 | def attach_ini_letter(self, pdf_bytes: bytes): 96 | file = frappe.new_doc("File") 97 | file.file_name = f"ini_letter_{self.name}.pdf" 98 | file.attached_to_doctype = self.doctype 99 | file.attached_to_name = self.name 100 | file.is_private = 1 101 | file.content = pdf_bytes 102 | file.save() 103 | 104 | def store_keyring(self, keys: dict): 105 | self.db_set("keyring", json.dumps(keys, indent=2)) 106 | 107 | def get_keyring(self) -> dict: 108 | return json.loads(self.keyring) if self.keyring else {} 109 | 110 | 111 | def on_doctype_update(): 112 | frappe.db.add_unique("EBICS User", ["bank", "partner_id", "user_id"], constraint_name="unique_ebics_user") 113 | 114 | 115 | @frappe.whitelist() 116 | def initialize(ebics_user: str, passphrase: str, signature_passphrase: str, store_passphrase: int): 117 | ensure_ebics_is_enabled() 118 | 119 | user = frappe.get_doc("EBICS User", ebics_user) 120 | user.check_permission("write") 121 | 122 | if store_passphrase: 123 | user.passphrase = passphrase 124 | user.save() 125 | 126 | manager = get_ebics_manager(ebics_user=user, passphrase=passphrase, sig_passphrase=signature_passphrase) 127 | 128 | try: 129 | manager.create_user_keys() 130 | except RuntimeError as e: 131 | if e.args[0] != "keys already present": 132 | raise e 133 | 134 | if user.needs_certificates: 135 | country_code = frappe.db.get_value("Country", user.country, "code") 136 | manager.create_user_certificates(user.full_name, user.company, country_code.upper()) 137 | 138 | manager.send_keys_to_bank() 139 | 140 | bank_name = frappe.db.get_value("Bank", user.bank, "bank_name") 141 | ini_bytes = manager.create_ini_letter(bank_name, language=frappe.local.lang) 142 | user.attach_ini_letter(ini_bytes) 143 | user.db_set("initialized", 1) 144 | 145 | 146 | @frappe.whitelist() 147 | def download_bank_keys(ebics_user: str, passphrase: str | None = None): 148 | ensure_ebics_is_enabled() 149 | 150 | user = frappe.get_doc("EBICS User", ebics_user) 151 | user.check_permission("write") 152 | 153 | manager = get_ebics_manager(user, passphrase=passphrase) 154 | 155 | return manager.download_bank_keys() 156 | 157 | 158 | @frappe.whitelist() 159 | def confirm_bank_keys(ebics_user: str, passphrase: str | None = None): 160 | ensure_ebics_is_enabled() 161 | 162 | user = frappe.get_doc("EBICS User", ebics_user) 163 | user.check_permission("write") 164 | 165 | manager = get_ebics_manager(user, passphrase=passphrase) 166 | manager.activate_bank_keys() 167 | user.db_set("bank_keys_activated", 1) 168 | 169 | 170 | @frappe.whitelist() 171 | def download_bank_statements( 172 | ebics_user: str, 173 | from_date: str | None = None, 174 | to_date: str | None = None, 175 | passphrase: str | None = None, 176 | ): 177 | ensure_ebics_is_enabled() 178 | 179 | frappe.has_permission("Bank Transaction", "create", throw=True) 180 | 181 | user = frappe.get_doc("EBICS User", ebics_user) 182 | user.check_permission("read") 183 | 184 | frappe.enqueue( 185 | sync_ebics_transactions, 186 | requested_by="User", 187 | ebics_user=ebics_user, 188 | start_date=from_date, 189 | end_date=to_date, 190 | passphrase=passphrase, 191 | intraday=getdate(from_date) == getdate(), 192 | now=frappe.conf.developer_mode, 193 | ) 194 | 195 | 196 | def ensure_ebics_is_enabled(): 197 | if not frappe.db.get_single_value("Banking Settings", "enabled"): 198 | frappe.throw( 199 | _("Please activate the checkbox 'Enabled' in the {0}.").format( 200 | get_link_to_form("Banking Settings", "Banking Settings") 201 | ) 202 | ) 203 | 204 | if not frappe.db.get_single_value("Banking Settings", "enable_ebics"): 205 | frappe.throw( 206 | _("Please activate the checkbox 'Enable EBICS' in the {0}.").format( 207 | get_link_to_form("Banking Settings", "Banking Settings") 208 | ) 209 | ) 210 | -------------------------------------------------------------------------------- /banking/ebics/doctype/ebics_user/test_ebics_user.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, ALYF GmbH and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestEBICSUser(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /banking/ebics/manager.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from collections.abc import Callable 5 | 6 | from fintech.ebics import ( 7 | EbicsClient, 8 | ) 9 | 10 | 11 | class EBICSManager: 12 | __slots__ = ["bank", "keyring", "user"] 13 | 14 | def set_keyring(self, keys: dict, save_to_db: "Callable", sig_passphrase: str, passphrase: str | None): 15 | from fintech.ebics import EbicsKeyRing 16 | 17 | class CustomKeyRing(EbicsKeyRing): 18 | def _write(self, keydict): 19 | save_to_db(keydict) 20 | 21 | self.keyring = CustomKeyRing( 22 | keys=keys, 23 | passphrase=passphrase, 24 | sig_passphrase=sig_passphrase, 25 | ) 26 | 27 | def set_user(self, partner_id: str, user_id: str): 28 | from fintech.ebics import EbicsUser 29 | 30 | self.user = EbicsUser(keyring=self.keyring, partnerid=partner_id, userid=user_id, transport_only=True) 31 | 32 | def set_bank(self, host_id: str, url: str): 33 | from fintech.ebics import EbicsBank 34 | 35 | self.bank = EbicsBank(keyring=self.keyring, hostid=host_id, url=url) 36 | 37 | def create_user_keys(self): 38 | self.user.create_keys(keyversion="A005", bitlength=2048) 39 | 40 | def create_user_certificates(self, user_name: str, organization_name: str, country_code: str): 41 | self.user.create_certificates( 42 | commonName=user_name, 43 | organizationName=organization_name, 44 | countryName=country_code, 45 | ) 46 | 47 | def get_client(self) -> "EbicsClient": 48 | from fintech.ebics import EbicsClient 49 | 50 | return EbicsClient(self.bank, self.user) 51 | 52 | def send_keys_to_bank(self): 53 | client = self.get_client() 54 | # Send the public electronic signature key to the bank. 55 | client.INI() 56 | # Send the public authentication and encryption keys to the bank. 57 | client.HIA() 58 | 59 | def create_ini_letter(self, bank_name: str, language: str | None = None) -> bytes: 60 | """Return the PDF data as byte string.""" 61 | return self.user.create_ini_letter( 62 | bankname=bank_name, 63 | lang=language, 64 | ) 65 | 66 | def download_bank_keys(self): 67 | client = self.get_client() 68 | return client.HPB() 69 | 70 | def activate_bank_keys(self) -> None: 71 | self.bank.activate_keys() 72 | 73 | def get_permitted_order_types(self, level: str = "T") -> list[str]: 74 | """Return a list of individual order types for the given (or unspecified) authorisation level.""" 75 | client = self.get_client() 76 | user_data = client.HTD(parsed=True) 77 | permissions = user_data.get("HTDResponseOrderData", {}).get("UserInfo", {}).get("Permission", []) 78 | 79 | # Collect all order types for the specified level 80 | level_perms = [] 81 | for permission in permissions: 82 | if permission.get("@AuthorisationLevel", level) == level: 83 | order_types = permission.get("OrderTypes") 84 | if isinstance(order_types, str): 85 | # Split if it's a space-separated string 86 | level_perms.extend(order_types.split()) 87 | 88 | return level_perms 89 | -------------------------------------------------------------------------------- /banking/ebics/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import hashlib 3 | import json 4 | from typing import TYPE_CHECKING, Literal 5 | 6 | import fintech 7 | import frappe 8 | from frappe import _ 9 | from frappe.utils.data import get_link_to_form 10 | 11 | from banking.ebics.manager import EBICSManager 12 | 13 | if TYPE_CHECKING: 14 | from datetime import date 15 | 16 | from fintech.sepa import SEPATransaction 17 | 18 | from banking.ebics.doctype.ebics_user.ebics_user import EBICSUser 19 | 20 | 21 | def get_ebics_manager( 22 | ebics_user: "EBICSUser", 23 | passphrase: str | None = None, 24 | sig_passphrase: str | None = None, 25 | ) -> "EBICSManager": 26 | """Get an EBICSManager instance for the given EBICS User. 27 | 28 | :param ebics_user: The EBICS User record. 29 | :param passphrase: The secret passphrase for uploads to the bank. 30 | """ 31 | register_fintech(needs_license_key=True) 32 | 33 | manager = EBICSManager() 34 | manager.set_keyring( 35 | keys=ebics_user.get_keyring(), 36 | save_to_db=ebics_user.store_keyring, 37 | sig_passphrase=sig_passphrase, 38 | passphrase=passphrase or ebics_user.get_password("passphrase"), 39 | ) 40 | 41 | manager.set_user(ebics_user.partner_id, ebics_user.user_id) 42 | 43 | host_id, url = frappe.db.get_value("Bank", ebics_user.bank, ["ebics_host_id", "ebics_url"]) 44 | manager.set_bank(host_id, url) 45 | 46 | return manager 47 | 48 | 49 | def sync_ebics_transactions( 50 | ebics_user: str, 51 | requested_by: Literal["User", "System"], 52 | start_date: str | None = None, 53 | end_date: str | None = None, 54 | passphrase: str | None = None, 55 | intraday: bool = False, 56 | ): 57 | user = frappe.get_doc("EBICS User", ebics_user) 58 | manager = get_ebics_manager(ebics_user=user, passphrase=passphrase) 59 | 60 | from fintech.sepa import ( 61 | CAMTDocument, 62 | ) # import possible only after manager is initialized 63 | 64 | permitted_types = manager.get_permitted_order_types() 65 | validate_permitted_types(user, permitted_types, intraday) 66 | 67 | with_c54 = user.split_batch_transactions and "C54" in permitted_types 68 | request = log_request( 69 | ebics_user, 70 | "C52" if intraday else "C53", 71 | requested_by, 72 | { 73 | "start_date": start_date, 74 | "end_date": end_date, 75 | }, 76 | ) 77 | 78 | try: 79 | client = manager.get_client() 80 | main_xml = client.C52(start_date, end_date) if intraday else client.C53(start_date, end_date) 81 | batch_xml = client.C54(start_date, end_date) if with_c54 else None 82 | request.db_set( 83 | { 84 | "status": "Successful", 85 | "response": json.dumps( 86 | {file_name: file.decode() for file_name, file in main_xml.items()}, 87 | indent=2, 88 | ), 89 | } 90 | ) 91 | except fintech.ebics.EbicsNoDataAvailable: 92 | request.db_set({"status": "Successful", "response": "No Data Available"}) 93 | return 94 | except Exception as e: 95 | request.db_set({"status": "Failed", "response": str(e)}) 96 | frappe.log_error( 97 | title=_("Banking Error"), 98 | reference_doctype="EBICS User", 99 | reference_name=ebics_user, 100 | ) 101 | return 102 | 103 | # Keep the request log, no matter what happens next. 104 | frappe.db.commit() 105 | 106 | # We want to process either all documents or none. If we fail to process one, we 107 | # want to rollback the entire transaction and report an error. 108 | try: 109 | for name in sorted(main_xml): 110 | camt_document = CAMTDocument(xml=main_xml[name], camt54=batch_xml) 111 | bank_account = get_bank_account(camt_document.iban, user.bank, user.company) 112 | if not bank_account: 113 | frappe.log_error( 114 | title=_("Banking Error"), 115 | message=_("Bank Account not found for IBAN {0}").format(camt_document.iban), 116 | reference_doctype="EBICS User", 117 | reference_name=user.name, 118 | ) 119 | continue 120 | 121 | process_camt_document( 122 | camt_document, 123 | bank_account, 124 | user.company, 125 | user.start_date, 126 | user.split_batch_transactions, 127 | ) 128 | except Exception: 129 | frappe.db.rollback() 130 | frappe.log_error( 131 | title=_("Banking Error"), 132 | reference_doctype="EBICS Request", 133 | reference_name=request.name, 134 | ) 135 | client.confirm_download(success=False) 136 | return 137 | 138 | client.confirm_download(success=True) 139 | 140 | 141 | def validate_permitted_types(user, permitted_types, intraday: bool): 142 | # Not sure yet, how reliable permitted types are. For now, we just log an error 143 | # instead of raising an exception or returning. 144 | if intraday and "C52" not in permitted_types: 145 | frappe.log_error( 146 | title=_("Banking Error"), 147 | message=_( 148 | "It seems like EBICS User {0} lacks permission 'C52' for downloading intraday transactions. The permitted types are: {1}." 149 | ).format(user.name, ", ".join(permitted_types)), 150 | reference_doctype="EBICS User", 151 | reference_name=user.name, 152 | ) 153 | 154 | if not intraday and "C53" not in permitted_types: 155 | frappe.log_error( 156 | title=_("Banking Error"), 157 | message=_( 158 | "It seems like EBICS User {0} lacks permission 'C52' for downloading booked bank statements. The permitted types are: {1}." 159 | ).format(user.name, ", ".join(permitted_types)), 160 | reference_doctype="EBICS User", 161 | reference_name=user.name, 162 | ) 163 | 164 | if not intraday and user.split_batch_transactions and "C54" not in permitted_types: 165 | frappe.log_error( 166 | title=_("Banking Error"), 167 | message=_( 168 | "EBICS User {0} lacks permission 'C54' for splitting batch transactions. The permitted types are: {1}." 169 | ).format(user.name, ", ".join(permitted_types)), 170 | reference_doctype="EBICS User", 171 | reference_name=user.name, 172 | ) 173 | 174 | 175 | def get_bank_account(iban: str, bank: str, company: str) -> str | None: 176 | return frappe.db.get_value( 177 | "Bank Account", 178 | { 179 | "iban": iban, 180 | "disabled": 0, 181 | "bank": bank, 182 | "is_company_account": 1, 183 | "company": company, 184 | }, 185 | ) 186 | 187 | 188 | def process_camt_document( 189 | camt_document, 190 | bank_account: str, 191 | company: "str | None" = None, 192 | earliest_date: "date | None" = None, 193 | split_batch_transactions: bool = False, 194 | ): 195 | if not company: 196 | company = frappe.db.get_value("Bank Account", bank_account, "company") 197 | 198 | for transaction in camt_document: 199 | if transaction.status and transaction.status != "BOOK": 200 | # Skip PDNG and INFO transactions 201 | continue 202 | 203 | if ( 204 | transaction.batch 205 | and (split_batch_transactions or len(transaction) == 1) 206 | and len(transaction) >= 1 207 | ): 208 | # Split batch transactions into sub-transactions, based on info 209 | # from camt.054 that is sometimes available. 210 | # If that's not possible, create a single transaction 211 | for sub_transaction in transaction: 212 | _create_bank_transaction( 213 | bank_account, 214 | company, 215 | sub_transaction, 216 | earliest_date, 217 | ) 218 | else: 219 | _create_bank_transaction( 220 | bank_account, 221 | company, 222 | transaction, 223 | earliest_date, 224 | ) 225 | 226 | 227 | def _create_bank_transaction( 228 | bank_account: str, 229 | company: str, 230 | sepa_transaction: "SEPATransaction", 231 | start_date: "date | None" = None, 232 | ): 233 | """Create an ERPNext Bank Transaction from a given fintech.sepa.SEPATransaction. 234 | 235 | https://www.joonis.de/en/fintech/doc/sepa/#fintech.sepa.SEPATransaction 236 | """ 237 | # sepa_transaction.bank_reference can be None, but we can still find an ID in the XML 238 | # For our test bank, the latter is a timestamp with nanosecond accuracy. 239 | transaction_id = ( 240 | sepa_transaction.bank_reference 241 | or sepa_transaction._xmlobj.Refs.TxId.text 242 | or get_transaction_hash(sepa_transaction) 243 | ) 244 | 245 | # NOTE: This does not work for old data, this ID is different from Kosma's 246 | if transaction_id and frappe.db.exists( 247 | "Bank Transaction", 248 | {"transaction_id": transaction_id, "bank_account": bank_account}, 249 | ): 250 | return 251 | 252 | if start_date and sepa_transaction.date < start_date: 253 | return 254 | 255 | bt = frappe.new_doc("Bank Transaction") 256 | bt.date = sepa_transaction.date 257 | bt.bank_account = bank_account 258 | bt.company = company 259 | 260 | amount = float(sepa_transaction.amount.value) 261 | bt.deposit = max(amount, 0) 262 | bt.withdrawal = abs(min(amount, 0)) 263 | bt.currency = sepa_transaction.amount.currency 264 | 265 | bt.description = "\n".join(sepa_transaction.purpose) or sepa_transaction.info 266 | bt.reference_number = sepa_transaction.eref 267 | bt.transaction_id = transaction_id 268 | bt.bank_party_iban = sepa_transaction.iban 269 | bt.bank_party_name = sepa_transaction.name 270 | 271 | with contextlib.suppress(frappe.exceptions.UniqueValidationError): 272 | bt.insert() 273 | bt.submit() 274 | 275 | 276 | def get_transaction_hash(transaction: "SEPATransaction"): 277 | sha = hashlib.sha256() 278 | for value in ( 279 | transaction.date, 280 | transaction.iban, 281 | transaction.name, 282 | transaction.eref, 283 | transaction.amount.value, 284 | transaction.amount.currency, 285 | transaction.info, 286 | *transaction.purpose, 287 | ): 288 | if value: 289 | sha.update(frappe.safe_encode(str(value))) 290 | 291 | return sha.hexdigest() 292 | 293 | 294 | def log_request( 295 | ebics_user: str, 296 | order_type: str, 297 | requested_by: Literal["User", "System"], 298 | parameters: dict, 299 | ): 300 | request = frappe.new_doc("EBICS Request") 301 | request.ebics_user = ebics_user 302 | request.order_type = order_type 303 | request.requested_by = requested_by 304 | request.parameters = json.dumps(parameters, indent=2) 305 | return request.save(ignore_permissions=True) 306 | 307 | 308 | def get_protocol_versions(ebics_host_id: str, ebics_url: str): 309 | """Return a list of protocol versions supported by the bank.""" 310 | register_fintech() 311 | 312 | from fintech.ebics import EbicsBank, EbicsKeyRing 313 | 314 | keyring = EbicsKeyRing({}) 315 | bank = EbicsBank(keyring, ebics_host_id, ebics_url) 316 | 317 | return bank.get_protocol_versions() 318 | 319 | 320 | @frappe.whitelist() 321 | def upload_camt_file(): 322 | frappe.has_permission("Bank Transaction", "create", throw=True) 323 | 324 | file_bytes = frappe.local.uploaded_file 325 | bank_account = frappe.form_dict.docname 326 | 327 | register_fintech() 328 | 329 | from fintech.sepa import CAMTDocument 330 | 331 | camt_document = CAMTDocument(file_bytes.decode()) 332 | process_camt_document(camt_document, bank_account) 333 | 334 | 335 | def register_fintech(needs_license_key: bool = False): 336 | banking_settings = frappe.get_single("Banking Settings") 337 | 338 | licensee_name = banking_settings.fintech_licensee_name or None 339 | license_key = None 340 | with contextlib.suppress(frappe.AuthenticationError, frappe.ValidationError): 341 | license_key = banking_settings.get_password("fintech_license_key") 342 | 343 | if needs_license_key and not license_key: 344 | frappe.throw( 345 | _( 346 | "License key not found. Please activate the checkbox 'Enable EBICS' in the {0} and ensure that your subscription is active." 347 | ).format(get_link_to_form("Banking Settings", "Banking Settings")) 348 | ) 349 | 350 | try: 351 | fintech.register( 352 | name=licensee_name, 353 | keycode=license_key, 354 | ) 355 | except RuntimeError as e: 356 | if e.args[0] != "'register' can be called only once": 357 | raise e 358 | -------------------------------------------------------------------------------- /banking/hooks.py: -------------------------------------------------------------------------------- 1 | app_name = "banking" 2 | app_title = "ALYF Banking" 3 | app_publisher = "ALYF GmbH" 4 | app_description = "Banking Integration by ALYF GmbH" 5 | app_email = "hallo@alyf.de" 6 | app_license = "GPLv3" 7 | notification_email_logo = "/assets/banking/images/alyf-logo.png" 8 | 9 | # Includes in 10 | # ------------------ 11 | 12 | # include js, css files in header of desk.html 13 | app_include_css = "bank_reconciliation_beta.bundle.css" 14 | # app_include_js = "/assets/banking/js/banking.js" 15 | 16 | # include js, css files in header of web template 17 | # web_include_css = "/assets/banking/css/banking.css" 18 | # web_include_js = "/assets/banking/js/banking.js" 19 | 20 | # include custom scss in every website theme (without file extension ".scss") 21 | # website_theme_scss = "banking/public/scss/website" 22 | 23 | # include js, css files in header of web form 24 | # webform_include_js = {"doctype": "public/js/doctype.js"} 25 | # webform_include_css = {"doctype": "public/css/doctype.css"} 26 | 27 | # include js in page 28 | # page_js = {"page" : "public/js/file.js"} 29 | 30 | # include js in doctype views 31 | doctype_js = {"Bank": "custom/bank.js"} 32 | # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} 33 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 34 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 35 | 36 | # Home Pages 37 | # ---------- 38 | 39 | # application home page (will override Website Settings) 40 | # home_page = "login" 41 | 42 | # website user home page (by Role) 43 | # role_home_page = { 44 | # "Role": "home_page" 45 | # } 46 | 47 | # Generators 48 | # ---------- 49 | 50 | # automatically create page for each record of this doctype 51 | # website_generators = ["Web Page"] 52 | 53 | # Jinja 54 | # ---------- 55 | 56 | # add methods and filters to jinja environment 57 | # jinja = { 58 | # "methods": "banking.utils.jinja_methods", 59 | # "filters": "banking.utils.jinja_filters" 60 | # } 61 | 62 | # Installation 63 | # ------------ 64 | 65 | # before_install = "banking.install.before_install" 66 | after_install = "banking.install.after_install" 67 | 68 | # Uninstallation 69 | # ------------ 70 | 71 | # before_uninstall = "banking.uninstall.before_uninstall" 72 | # after_uninstall = "banking.uninstall.after_uninstall" 73 | 74 | # Desk Notifications 75 | # ------------------ 76 | # See frappe.core.notifications.get_notification_config 77 | 78 | # notification_config = "banking.notifications.get_notification_config" 79 | 80 | # Permissions 81 | # ----------- 82 | # Permissions evaluated in scripted ways 83 | 84 | # permission_query_conditions = { 85 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 86 | # } 87 | # 88 | # has_permission = { 89 | # "Event": "frappe.desk.doctype.event.event.has_permission", 90 | # } 91 | 92 | # DocType Class 93 | # --------------- 94 | # Override standard doctype classes 95 | 96 | override_doctype_class = {"Bank Transaction": "banking.overrides.bank_transaction.CustomBankTransaction"} 97 | 98 | # Document Events 99 | # --------------- 100 | # Hook on document methods and events 101 | 102 | doc_events = { 103 | "Bank Transaction": { 104 | "on_update_after_submit": "banking.overrides.bank_transaction.on_update_after_submit", 105 | } 106 | } 107 | 108 | # Scheduled Tasks 109 | # --------------- 110 | 111 | scheduler_events = { 112 | "cron": { 113 | "7 7-18 * * 1-5": [ 114 | # At minute 7 past every hour from 7 through 18 115 | # on every day-of-week from Monday through Friday. 116 | "banking.klarna_kosma_integration.doctype.banking_settings.banking_settings.intraday_sync_ebics", 117 | ], 118 | "42 4 * * *": [ 119 | # Daily at 4:42 am 120 | "banking.klarna_kosma_integration.doctype.banking_settings.banking_settings.sync_all_accounts_and_transactions", 121 | ], 122 | }, 123 | } 124 | 125 | # Testing 126 | # ------- 127 | 128 | before_tests = "banking.utils.before_tests" 129 | 130 | # Overriding Methods 131 | # ------------------------------ 132 | # 133 | # override_whitelisted_methods = { 134 | # "frappe.desk.doctype.event.event.get_events": "banking.event.get_events" 135 | # } 136 | # 137 | # each overriding function accepts a `data` argument; 138 | # generated from the base implementation of the doctype dashboard, 139 | # along with any modifications made in other Frappe apps 140 | # override_doctype_dashboards = { 141 | # "Task": "banking.task.get_dashboard_data" 142 | # } 143 | 144 | # exempt linked doctypes from being automatically cancelled 145 | # 146 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 147 | 148 | 149 | # User Data Protection 150 | # -------------------- 151 | 152 | # user_data_fields = [ 153 | # { 154 | # "doctype": "{doctype_1}", 155 | # "filter_by": "{filter_by}", 156 | # "redact_fields": ["{field_1}", "{field_2}"], 157 | # "partial": 1, 158 | # }, 159 | # { 160 | # "doctype": "{doctype_2}", 161 | # "filter_by": "{filter_by}", 162 | # "partial": 1, 163 | # }, 164 | # { 165 | # "doctype": "{doctype_3}", 166 | # "strict": False, 167 | # }, 168 | # { 169 | # "doctype": "{doctype_4}" 170 | # } 171 | # ] 172 | 173 | # Authentication and authorization 174 | # -------------------------------- 175 | 176 | # auth_hooks = [ 177 | # "banking.auth.validate" 178 | # ] 179 | 180 | alyf_banking_custom_fields = { 181 | "Bank": [ 182 | dict( 183 | fieldname="ebics_section", 184 | label="EBICS", 185 | fieldtype="Section Break", 186 | insert_after="plaid_access_token", 187 | ), 188 | dict( 189 | fieldname="ebics_host_id", 190 | label="EBICS Host ID", 191 | fieldtype="Data", 192 | insert_after="ebics_section", 193 | translatable=0, 194 | ), 195 | dict( 196 | fieldname="ebics_url", 197 | label="EBICS URL", 198 | fieldtype="Data", 199 | options="URL", 200 | insert_after="ebics_host_id", 201 | translatable=0, 202 | ), 203 | ], 204 | } 205 | 206 | alyf_banking_property_setters = { 207 | "Bank Account": [ 208 | dict( 209 | fieldname="last_integration_date", 210 | property="read_only", 211 | value=1, 212 | property_type="Check", 213 | ), 214 | dict( 215 | fieldname="last_integration_date", 216 | property="description", 217 | value="", 218 | property_type="Small Text", 219 | ), 220 | ] 221 | } 222 | 223 | get_matching_queries = "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.get_matching_queries" 224 | -------------------------------------------------------------------------------- /banking/install.py: -------------------------------------------------------------------------------- 1 | import click 2 | import frappe 3 | from frappe.custom.doctype.custom_field.custom_field import create_custom_fields 4 | from frappe.custom.doctype.property_setter.property_setter import make_property_setter 5 | 6 | 7 | def after_install(): 8 | click.echo("Installing Banking Customizations ...") 9 | 10 | create_custom_fields(frappe.get_hooks("alyf_banking_custom_fields")) 11 | make_property_setters() 12 | 13 | 14 | def make_property_setters(): 15 | for doctypes, property_setters in frappe.get_hooks("alyf_banking_property_setters", {}).items(): 16 | if isinstance(doctypes, str): 17 | doctypes = (doctypes,) 18 | 19 | for doctype in doctypes: 20 | for property_setter in property_setters: 21 | make_property_setter( 22 | doctype, 23 | **property_setter, 24 | validate_fields_for_doctype=False, 25 | for_doctype=not property_setter.get("fieldname"), 26 | ) 27 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/klarna_kosma_integration/__init__.py -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/admin.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, ALYF GmbH and contributors 2 | # For license information, please see license.txt 3 | import frappe 4 | 5 | from banking.connectors.admin_request import AdminRequest 6 | from banking.klarna_kosma_integration.exception_handler import ExceptionHandler 7 | 8 | 9 | class Admin: 10 | """A class that directly communicates with the Banking Admin App.""" 11 | 12 | def __init__(self, settings=None) -> None: 13 | """Initialize the Admin class with the necessary settings. 14 | 15 | :param settings: Banking Settings document. 16 | Enables you to pass the most recent settings that may not be in the database yet. 17 | """ 18 | settings = settings or frappe.get_single("Banking Settings") 19 | self.api_token = settings.get_password("api_token") 20 | self.customer_id = settings.customer_id 21 | self.url = settings.admin_endpoint 22 | 23 | @property 24 | def request(self): 25 | return AdminRequest( 26 | api_token=self.api_token, 27 | url=self.url, 28 | customer_id=self.customer_id, 29 | ) 30 | 31 | def fetch_subscription(self): 32 | try: 33 | subscription = self.request.fetch_subscription() 34 | subscription.raise_for_status() 35 | return subscription.json().get("message", {}) 36 | except Exception as exc: 37 | ExceptionHandler(exc) 38 | 39 | def get_customer_portal_url(self): 40 | try: 41 | url = self.request.get_customer_portal() 42 | url.raise_for_status() 43 | return url.json().get("message") 44 | except Exception as exc: 45 | ExceptionHandler(exc) 46 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/klarna_kosma_integration/doctype/__init__.py -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/__init__.py -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, ALYF GmbH and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("Bank Reconciliation Tool Beta", { 5 | setup: function (frm) { 6 | frm.set_query("bank_account", function (doc) { 7 | return { 8 | filters: { 9 | company: doc.company, 10 | is_company_account: 1, 11 | }, 12 | }; 13 | }); 14 | }, 15 | 16 | onload: function (frm) { 17 | if (!frm.doc.bank_statement_from_date && !frm.doc.bank_statement_to_date) { 18 | // Set default filter dates 19 | let today = frappe.datetime.get_today(); 20 | frm.doc.bank_statement_from_date = frappe.datetime.add_months(today, -1); 21 | frm.doc.bank_statement_to_date = today; 22 | } 23 | 24 | if (!frm.doc.company) { 25 | // set default company 26 | frm.doc.company = frappe.user_defaults.company; 27 | } 28 | }, 29 | 30 | filter_by_reference_date: function (frm) { 31 | if (frm.doc.filter_by_reference_date) { 32 | frm.set_value("bank_statement_from_date", ""); 33 | frm.set_value("bank_statement_to_date", ""); 34 | } else { 35 | frm.set_value("from_reference_date", ""); 36 | frm.set_value("to_reference_date", ""); 37 | } 38 | }, 39 | 40 | refresh: function (frm) { 41 | frm.disable_save(); 42 | frm.fields_dict["filters_section"].collapse(false); 43 | 44 | frm.page.add_action_icon("refresh", () => { 45 | frm.events.get_bank_transactions(frm); 46 | }); 47 | frm.change_custom_button_type(__("Get Bank Transactions"), null, "primary"); 48 | 49 | frm.page.add_menu_item(__("Auto Reconcile"), function () { 50 | frappe.confirm( 51 | __( 52 | "Auto reconcile bank transactions based on matching reference numbers?" 53 | ), 54 | () => { 55 | frappe.call({ 56 | method: 57 | "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.auto_reconcile_vouchers", 58 | args: { 59 | bank_account: frm.doc.bank_account, 60 | from_date: frm.doc.bank_statement_from_date, 61 | to_date: frm.doc.bank_statement_to_date, 62 | filter_by_reference_date: frm.doc.filter_by_reference_date, 63 | from_reference_date: frm.doc.from_reference_date, 64 | to_reference_date: frm.doc.to_reference_date, 65 | }, 66 | freeze: true, 67 | freeze_message: __("Auto Reconciling ..."), 68 | callback: (r) => { 69 | if (!r.exc) { 70 | frm.refresh(); 71 | } 72 | }, 73 | }); 74 | } 75 | ); 76 | }); 77 | 78 | frm.page.add_menu_item(__("Upload CSV / Excel file"), () => 79 | frm.events.route_to_bank_statement_import(frm) 80 | ); 81 | 82 | frm.page.add_menu_item(__("Upload CAMT file"), () => 83 | show_camt_uploader(frm) 84 | ); 85 | 86 | frm.$reconciliation_area = frm.get_field( 87 | "reconciliation_action_area" 88 | ).$wrapper; 89 | frm.events.setup_empty_state(frm); 90 | 91 | frm.events.build_reconciliation_area(frm); 92 | }, 93 | 94 | get_bank_transactions: function (frm) { 95 | if (!frm.doc.bank_account) { 96 | frappe.throw({ 97 | message: __("Please set the 'Bank Account' filter"), 98 | title: __("Filter Required"), 99 | }); 100 | } 101 | 102 | frm.events.build_reconciliation_area(frm); 103 | }, 104 | 105 | route_to_bank_statement_import(frm) { 106 | frappe.open_in_new_tab = true; 107 | 108 | if (!frm.doc.bank_account || !frm.doc.company) { 109 | frappe.new_doc("Bank Statement Import"); 110 | return; 111 | } 112 | 113 | // Route to saved Import Record in new tab 114 | frappe.call({ 115 | method: 116 | "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.upload_bank_statement", 117 | args: { 118 | dt: frm.doc.doctype, 119 | dn: frm.doc.name, 120 | company: frm.doc.company, 121 | bank_account: frm.doc.bank_account, 122 | }, 123 | callback: function (r) { 124 | if (!r.exc) { 125 | var doc = frappe.model.sync(r.message); 126 | frappe.open_in_new_tab = true; 127 | frappe.set_route("Form", doc[0].doctype, doc[0].name); 128 | } 129 | }, 130 | }); 131 | }, 132 | 133 | bank_account: function (frm) { 134 | if (frm.doc.bank_account) { 135 | frappe.db.get_value( 136 | "Bank Account", 137 | frm.doc.bank_account, 138 | "account", 139 | (r) => { 140 | frappe.db.get_value("Account", r.account, "account_currency", (r) => { 141 | frm.doc.account_currency = r.account_currency; 142 | frm.trigger("get_account_opening_balance"); 143 | frm.trigger("get_account_closing_balance"); 144 | frm.trigger("render_summary"); 145 | }); 146 | } 147 | ); 148 | 149 | frm.events.get_bank_transactions(frm); 150 | } else { 151 | frm.events.setup_empty_state(frm); 152 | } 153 | }, 154 | 155 | bank_statement_from_date: function (frm) { 156 | frm.trigger("get_account_opening_balance"); 157 | frm.trigger("get_bank_transactions"); 158 | }, 159 | 160 | bank_statement_to_date: function (frm) { 161 | frm.trigger("get_account_closing_balance"); 162 | frm.trigger("render_summary"); 163 | frm.trigger("get_bank_transactions"); 164 | }, 165 | 166 | bank_statement_closing_balance: function (frm) { 167 | frm.trigger("render_summary"); 168 | }, 169 | 170 | get_account_opening_balance(frm) { 171 | if (frm.doc.bank_account && frm.doc.bank_statement_from_date) { 172 | frappe.call({ 173 | method: 174 | "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance", 175 | args: { 176 | bank_account: frm.doc.bank_account, 177 | till_date: frm.doc.bank_statement_from_date, 178 | company: frm.doc.company, 179 | }, 180 | callback: (response) => { 181 | frm.set_value("account_opening_balance", response.message); 182 | }, 183 | }); 184 | } 185 | }, 186 | 187 | get_account_closing_balance(frm) { 188 | if (frm.doc.bank_account && frm.doc.bank_statement_to_date) { 189 | return frappe.call({ 190 | method: 191 | "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance", 192 | args: { 193 | bank_account: frm.doc.bank_account, 194 | till_date: frm.doc.bank_statement_to_date, 195 | company: frm.doc.company, 196 | }, 197 | callback: (response) => { 198 | frm.cleared_balance = response.message; 199 | }, 200 | }); 201 | } 202 | }, 203 | 204 | setup_empty_state: function (frm) { 205 | frm.$reconciliation_area.empty(); 206 | frm.$reconciliation_area.append(` 207 |
208 |

209 | ${__("Please select a Bank Account to start reconciling.")} 210 |

211 |
212 | `); 213 | }, 214 | 215 | render_summary: function (frm) { 216 | // frm.get_field("reconciliation_tool_cards").$wrapper.empty(); 217 | // frappe.require("bank_reconciliation_beta.bundle.js", () => { 218 | // let difference = flt(frm.doc.bank_statement_closing_balance) - flt(frm.cleared_balance); 219 | // let difference_color = difference >= 0 ? "text-success" : "text-danger"; 220 | // frm.summary_card = new erpnext.accounts.bank_reconciliation.SummaryCard({ 221 | // $wrapper: frm.get_field("reconciliation_tool_cards").$wrapper, 222 | // values: { 223 | // "Bank Closing Balance": [frm.doc.bank_statement_closing_balance], 224 | // "ERP Closing Balance": [frm.cleared_balance], 225 | // "Difference": [difference, difference_color] 226 | // }, 227 | // currency: frm.doc.account_currency, 228 | // }) 229 | // }); 230 | }, 231 | 232 | build_reconciliation_area: function (frm) { 233 | if (!frm.doc.bank_account) return; 234 | 235 | frappe.require("bank_reconciliation_beta.bundle.js", () => { 236 | frm.panel_manager = new erpnext.accounts.bank_reconciliation.PanelManager( 237 | { 238 | doc: frm.doc, 239 | $wrapper: frm.$reconciliation_area, 240 | } 241 | ); 242 | }); 243 | }, 244 | }); 245 | 246 | function show_camt_uploader(frm) { 247 | if (!frm.doc.bank_account) { 248 | frappe.throw({ 249 | message: __("Please set the 'Bank Account' filter"), 250 | title: __("Filter Required"), 251 | }); 252 | } 253 | 254 | const uploader = new frappe.ui.FileUploader({ 255 | dialog_title: __("Upload XML (CAMT.053) file"), 256 | upload_notes: __("to import bank transactions for {0}.", [ 257 | frm.doc.bank_account, 258 | ]), 259 | method: "banking.ebics.utils.upload_camt_file", 260 | doctype: "Bank Account", 261 | docname: frm.doc.bank_account, 262 | allow_toggle_private: false, 263 | allow_take_photo: false, 264 | allow_web_link: false, 265 | allow_multiple: false, 266 | allow_google_drive: false, 267 | disable_file_browser: true, 268 | bank_account: frm.doc.bank_account, 269 | restrictions: { 270 | allowed_file_types: [".xml", ".XML"], 271 | max_number_of_files: 1, 272 | }, 273 | }); 274 | 275 | uploader.dialog.$wrapper.on("hidden.bs.modal", () => { 276 | frm.refresh(); 277 | }); 278 | } 279 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2023-07-17 14:12:01.433161", 4 | "default_view": "List", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "filters_section", 10 | "company", 11 | "bank_account", 12 | "account_currency", 13 | "account_opening_balance", 14 | "bank_statement_closing_balance", 15 | "column_break_1", 16 | "bank_statement_from_date", 17 | "bank_statement_to_date", 18 | "from_reference_date", 19 | "to_reference_date", 20 | "filter_by_reference_date", 21 | "section_break_1", 22 | "reconciliation_tool_cards", 23 | "reconciliation_action_area" 24 | ], 25 | "fields": [ 26 | { 27 | "collapsible": 1, 28 | "fieldname": "filters_section", 29 | "fieldtype": "Section Break", 30 | "label": "Filters" 31 | }, 32 | { 33 | "fieldname": "company", 34 | "fieldtype": "Link", 35 | "label": "Company", 36 | "options": "Company" 37 | }, 38 | { 39 | "fieldname": "bank_account", 40 | "fieldtype": "Link", 41 | "in_list_view": 1, 42 | "label": "Bank Account", 43 | "options": "Bank Account", 44 | "reqd": 1 45 | }, 46 | { 47 | "fieldname": "account_currency", 48 | "fieldtype": "Link", 49 | "hidden": 1, 50 | "label": "Account Currency", 51 | "options": "Currency" 52 | }, 53 | { 54 | "depends_on": "eval: doc.bank_account && doc.bank_statement_from_date && false", 55 | "fieldname": "account_opening_balance", 56 | "fieldtype": "Currency", 57 | "label": "Account Opening Balance", 58 | "options": "account_currency", 59 | "read_only": 1 60 | }, 61 | { 62 | "depends_on": "eval: doc.bank_account && doc.bank_statement_to_date && false", 63 | "fieldname": "bank_statement_closing_balance", 64 | "fieldtype": "Currency", 65 | "label": "Closing Balance", 66 | "options": "account_currency" 67 | }, 68 | { 69 | "fieldname": "column_break_1", 70 | "fieldtype": "Column Break" 71 | }, 72 | { 73 | "depends_on": "eval: doc.bank_account && !doc.filter_by_reference_date", 74 | "fieldname": "bank_statement_from_date", 75 | "fieldtype": "Date", 76 | "label": "From Date" 77 | }, 78 | { 79 | "depends_on": "eval: doc.bank_account && !doc.filter_by_reference_date", 80 | "fieldname": "bank_statement_to_date", 81 | "fieldtype": "Date", 82 | "label": "To Date" 83 | }, 84 | { 85 | "depends_on": "eval:doc.filter_by_reference_date", 86 | "fieldname": "from_reference_date", 87 | "fieldtype": "Date", 88 | "label": "From Reference Date" 89 | }, 90 | { 91 | "depends_on": "eval:doc.filter_by_reference_date", 92 | "fieldname": "to_reference_date", 93 | "fieldtype": "Date", 94 | "label": "To Reference Date" 95 | }, 96 | { 97 | "default": "0", 98 | "depends_on": "bank_account", 99 | "fieldname": "filter_by_reference_date", 100 | "fieldtype": "Check", 101 | "label": "Filter by Reference Date" 102 | }, 103 | { 104 | "fieldname": "section_break_1", 105 | "fieldtype": "Section Break" 106 | }, 107 | { 108 | "fieldname": "reconciliation_tool_cards", 109 | "fieldtype": "HTML" 110 | }, 111 | { 112 | "fieldname": "reconciliation_action_area", 113 | "fieldtype": "HTML" 114 | } 115 | ], 116 | "hide_toolbar": 1, 117 | "index_web_pages_for_search": 1, 118 | "issingle": 1, 119 | "links": [], 120 | "modified": "2024-09-11 19:10:17.108955", 121 | "modified_by": "Administrator", 122 | "module": "Klarna Kosma Integration", 123 | "name": "Bank Reconciliation Tool Beta", 124 | "owner": "Administrator", 125 | "permissions": [ 126 | { 127 | "create": 1, 128 | "delete": 1, 129 | "email": 1, 130 | "print": 1, 131 | "read": 1, 132 | "role": "System Manager", 133 | "share": 1, 134 | "write": 1 135 | }, 136 | { 137 | "create": 1, 138 | "read": 1, 139 | "role": "Accounts Manager", 140 | "write": 1 141 | }, 142 | { 143 | "create": 1, 144 | "read": 1, 145 | "role": "Accounts User", 146 | "write": 1 147 | } 148 | ], 149 | "quick_entry": 1, 150 | "sort_field": "modified", 151 | "sort_order": "DESC", 152 | "states": [] 153 | } -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.md: -------------------------------------------------------------------------------- 1 | # Bank Reconciliation Tool Beta Behaviours 2 | 3 | Cases and expected behaviours for clarity in testing 4 | 5 | 6 | ## Match Tab Visible Vouchers 7 | 8 | ### Withdrawal Bank Transaction 9 | 10 | Depending on multiple checked filters: 11 | - **Purchase Invoice**: 12 | - _Unpaid Invoices_: Unpaid PInvs including returns(outstanding >< 0), Unpaid **return** SInvs 13 | - _Without Unpaid Invoices_: PInvs with `is_paid` checked and no clearance date (among other basic filters) 14 | - **Sales Invoice**: 15 | - _Unpaid Invoices_: Unpaid SInvs including returns(outstanding >< 0), Unpaid **return** PInvs 16 | - _Without Unpaid Invoices_: SInvs with no clearance date and paid via POS (among other basic filters) 17 | - **Expense Claim**: Unpaid expense claims. Visible only if _Unpaid Vouchers_ is checked 18 | 19 | The rest is self explanatory and _only depends on the doctype filter being checked_. -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, ALYF GmbH and Contributors 2 | # See license.txt 3 | import json 4 | 5 | import frappe 6 | from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( 7 | create_payment_entry, 8 | ) 9 | from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import ( 10 | create_sales_invoice, 11 | ) 12 | from erpnext.accounts.test.accounts_mixin import AccountsTestMixin 13 | from frappe.custom.doctype.custom_field.custom_field import create_custom_field 14 | from frappe.tests.utils import FrappeTestCase 15 | from frappe.utils import add_days, getdate 16 | from hrms.hr.doctype.expense_claim.test_expense_claim import make_expense_claim 17 | 18 | from banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta import ( 19 | auto_reconcile_vouchers, 20 | bulk_reconcile_vouchers, 21 | create_journal_entry_bts, 22 | create_payment_entry_bts, 23 | get_linked_payments, 24 | ) 25 | 26 | 27 | class TestBankReconciliationToolBeta(AccountsTestMixin, FrappeTestCase): 28 | @classmethod 29 | def setUpClass(cls) -> None: 30 | super().setUpClass() 31 | 32 | create_custom_field( 33 | "Sales Invoice", dict(fieldname="custom_ref_no", label="Ref No", fieldtype="Data") 34 | ) # commits to db internally 35 | 36 | create_bank() 37 | cls.gl_account = create_bank_gl_account("_Test Bank Reco Tool") 38 | cls.bank_account = create_bank_account(gl_account=cls.gl_account) 39 | cls.customer = create_customer(customer_name="ABC Inc.") 40 | 41 | cls.create_item(cls, item_name="Reco Item", company="_Test Company", warehouse="Finished Goods - _TC") 42 | frappe.db.savepoint(save_point="bank_reco_beta_before_tests") 43 | 44 | def tearDown(self) -> None: 45 | """Runs after each test.""" 46 | # Make sure invoices are rolled back to not affect invoice count assertions 47 | frappe.db.rollback(save_point="bank_reco_beta_before_tests") 48 | 49 | def test_unpaid_invoices_more_than_transaction(self): 50 | """ 51 | Test unpaid invoices fully reconcile. 52 | BT: 150 53 | SI1, SI2: 100, 100 (200) (partial: 150) 54 | """ 55 | doc = create_bank_transaction( 56 | date=add_days(getdate(), -2), deposit=150, bank_account=self.bank_account 57 | ) 58 | customer = create_customer() 59 | si = create_sales_invoice( 60 | rate=100, 61 | warehouse="Finished Goods - _TC", 62 | customer=customer, 63 | cost_center="Main - _TC", 64 | item="Reco Item", 65 | ) 66 | si2 = create_sales_invoice( 67 | rate=100, 68 | warehouse="Finished Goods - _TC", 69 | customer=customer, 70 | cost_center="Main - _TC", 71 | item="Reco Item", 72 | ) 73 | 74 | bulk_reconcile_vouchers( 75 | doc.name, 76 | json.dumps( 77 | [ 78 | {"payment_doctype": "Sales Invoice", "payment_name": si.name}, 79 | {"payment_doctype": "Sales Invoice", "payment_name": si2.name}, 80 | ] 81 | ), 82 | ) 83 | 84 | doc.reload() 85 | self.assertEqual(len(doc.payment_entries), 1) # 1 PE made against 2 invoices 86 | self.assertEqual(doc.payment_entries[0].allocated_amount, 150) 87 | 88 | pe = get_pe_references([si.name, si2.name]) 89 | self.assertEqual(pe[0].allocated_amount, 100) 90 | self.assertEqual(pe[1].allocated_amount, 50) 91 | # Check if the PE is posted on the same date as the BT 92 | self.assertEqual( 93 | doc.date, 94 | frappe.db.get_value("Payment Entry", doc.payment_entries[0].payment_entry, "posting_date"), 95 | ) 96 | 97 | def test_unpaid_invoices_less_than_transaction(self): 98 | """ 99 | Test if unpaid invoices partially reconcile. 100 | BT: 100 101 | SI1, SI2: 50, 20 (70) 102 | """ 103 | doc = create_bank_transaction(deposit=100, bank_account=self.bank_account) 104 | customer = create_customer() 105 | si = create_sales_invoice( 106 | rate=50, 107 | warehouse="Finished Goods - _TC", 108 | customer=customer, 109 | cost_center="Main - _TC", 110 | item="Reco Item", 111 | ) 112 | si2 = create_sales_invoice( 113 | rate=20, 114 | warehouse="Finished Goods - _TC", 115 | customer=customer, 116 | cost_center="Main - _TC", 117 | item="Reco Item", 118 | ) 119 | 120 | bulk_reconcile_vouchers( 121 | doc.name, 122 | json.dumps( 123 | [ 124 | {"payment_doctype": "Sales Invoice", "payment_name": si.name}, 125 | {"payment_doctype": "Sales Invoice", "payment_name": si2.name}, 126 | ] 127 | ), 128 | ) 129 | 130 | doc.reload() 131 | self.assertEqual(doc.payment_entries[0].allocated_amount, 70) 132 | self.assertEqual(doc.unallocated_amount, 30) 133 | 134 | pe = get_pe_references([si.name, si2.name]) 135 | self.assertEqual(pe[0].allocated_amount, 50) 136 | self.assertEqual(pe[1].allocated_amount, 20) 137 | 138 | def test_multiple_transactions_one_unpaid_invoice(self): 139 | """ 140 | Test if multiple transactions reconcile with one unpaid invoice. 141 | """ 142 | bt1 = create_bank_transaction(deposit=100, bank_account=self.bank_account) 143 | bt2 = create_bank_transaction(deposit=100, bank_account=self.bank_account) 144 | 145 | customer = create_customer() 146 | si = create_sales_invoice( 147 | rate=200, 148 | warehouse="Finished Goods - _TC", 149 | customer=customer, 150 | cost_center="Main - _TC", 151 | item="Reco Item", 152 | ) 153 | bulk_reconcile_vouchers( 154 | bt1.name, 155 | json.dumps([{"payment_doctype": "Sales Invoice", "payment_name": si.name}]), 156 | ) 157 | bt1.reload() 158 | si.reload() 159 | self.assertEqual(bt1.payment_entries[0].allocated_amount, 100) 160 | self.assertEqual(si.outstanding_amount, 100) 161 | 162 | bulk_reconcile_vouchers( 163 | bt2.name, 164 | json.dumps([{"payment_doctype": "Sales Invoice", "payment_name": si.name}]), 165 | ) 166 | bt2.reload() 167 | si.reload() 168 | self.assertEqual(bt2.payment_entries[0].allocated_amount, 100) 169 | self.assertEqual(si.outstanding_amount, 0) 170 | 171 | def test_single_transaction_multiple_payment_vouchers(self): 172 | """ 173 | Test if single transaction partially reconciles with multiple payment vouchers. 174 | """ 175 | pe = create_payment_entry( 176 | payment_type="Receive", 177 | party_type="Customer", 178 | party=self.customer, 179 | paid_from="Debtors - _TC", 180 | paid_to=self.gl_account, 181 | paid_amount=50, 182 | save=1, 183 | submit=1, 184 | ) 185 | pe2 = create_payment_entry( 186 | payment_type="Receive", 187 | party_type="Customer", 188 | party=self.customer, 189 | paid_from="Debtors - _TC", 190 | paid_to=self.gl_account, 191 | paid_amount=30, 192 | save=1, 193 | submit=1, 194 | ) 195 | bt = create_bank_transaction(deposit=100, bank_account=self.bank_account) 196 | bulk_reconcile_vouchers( 197 | bt.name, 198 | json.dumps( 199 | [ 200 | {"payment_doctype": "Payment Entry", "payment_name": pe.name}, 201 | {"payment_doctype": "Payment Entry", "payment_name": pe2.name}, 202 | ] 203 | ), 204 | ) 205 | 206 | bt.reload() 207 | self.assertEqual(bt.payment_entries[0].allocated_amount, 50) 208 | self.assertEqual(bt.payment_entries[1].allocated_amount, 30) 209 | self.assertEqual(bt.unallocated_amount, 20) 210 | 211 | def test_multiple_transactions_one_payment_voucher(self): 212 | """ 213 | Test if multiple transactions fully reconcile with one payment voucher. 214 | """ 215 | pe = create_payment_entry( 216 | payment_type="Receive", 217 | party_type="Customer", 218 | party=self.customer, 219 | paid_from="Debtors - _TC", 220 | paid_to=self.gl_account, 221 | paid_amount=200, 222 | save=1, 223 | submit=1, 224 | ) 225 | bt1 = create_bank_transaction(deposit=100, bank_account=self.bank_account) 226 | bt2 = create_bank_transaction(deposit=100, bank_account=self.bank_account) 227 | bulk_reconcile_vouchers( 228 | bt1.name, 229 | json.dumps([{"payment_doctype": "Payment Entry", "payment_name": pe.name}]), 230 | ) 231 | bt1.reload() 232 | pe.reload() 233 | self.assertEqual(bt1.payment_entries[0].allocated_amount, 100) 234 | self.assertEqual(bt1.payment_entries[0].payment_entry, pe.name) 235 | self.assertEqual(bt1.status, "Reconciled") 236 | 237 | bulk_reconcile_vouchers( 238 | bt2.name, 239 | json.dumps([{"payment_doctype": "Payment Entry", "payment_name": pe.name}]), 240 | ) 241 | bt2.reload() 242 | pe.reload() 243 | self.assertEqual(bt2.payment_entries[0].allocated_amount, 100) 244 | self.assertEqual(bt2.payment_entries[0].payment_entry, pe.name) 245 | self.assertEqual(bt2.status, "Reconciled") 246 | 247 | def test_pe_against_transaction(self): 248 | bt = create_bank_transaction(deposit=100, reference_no="abcdef", bank_account=self.bank_account) 249 | create_payment_entry_bts( 250 | bank_transaction_name=bt.name, 251 | party_type="Customer", 252 | party=self.customer, 253 | posting_date=bt.date, 254 | reference_number=bt.reference_number, 255 | reference_date=bt.date, 256 | ) 257 | 258 | bt.reload() 259 | self.assertEqual(bt.payment_entries[0].allocated_amount, 100) 260 | self.assertEqual(len(bt.payment_entries), 1) 261 | self.assertEqual(bt.status, "Reconciled") 262 | 263 | def test_jv_against_transaction(self): 264 | bt = create_bank_transaction(deposit=200, reference_no="abcdef123", bank_account=self.bank_account) 265 | create_journal_entry_bts( 266 | bank_transaction_name=bt.name, 267 | party_type="Customer", 268 | party=self.customer, 269 | posting_date=bt.date, 270 | reference_number=bt.reference_number, 271 | reference_date=bt.date, 272 | entry_type="Bank Entry", 273 | second_account=frappe.db.get_value("Company", bt.company, "default_receivable_account"), 274 | ) 275 | 276 | bt.reload() 277 | self.assertEqual(bt.payment_entries[0].allocated_amount, 200) 278 | self.assertEqual(len(bt.payment_entries), 1) 279 | self.assertEqual(bt.status, "Reconciled") 280 | 281 | def test_unpaid_voucher_and_jv_against_transaction(self): 282 | """ 283 | Partially reconcile a bank transaction with an unpaid invoice and 284 | create a journal entry for the remaining amount. 285 | """ 286 | bt = create_bank_transaction(deposit=200, reference_no="abcdef123456", bank_account=self.bank_account) 287 | si = create_sales_invoice( 288 | rate=50, 289 | warehouse="Finished Goods - _TC", 290 | customer=self.customer, 291 | cost_center="Main - _TC", 292 | item="Reco Item", 293 | ) 294 | 295 | # 50/200 reconciled 296 | bulk_reconcile_vouchers( 297 | bt.name, 298 | json.dumps([{"payment_doctype": "Sales Invoice", "payment_name": si.name}]), 299 | ) 300 | 301 | bt.reload() 302 | self.assertEqual(bt.payment_entries[0].allocated_amount, 50) 303 | self.assertEqual(len(bt.payment_entries), 1) 304 | self.assertEqual(bt.unallocated_amount, 150) 305 | 306 | # reconcile remaining 150 with a journal entry 307 | create_journal_entry_bts( 308 | bank_transaction_name=bt.name, 309 | party_type="Customer", 310 | party=self.customer, 311 | posting_date=bt.date, 312 | reference_number=bt.reference_number, 313 | reference_date=bt.date, 314 | entry_type="Bank Entry", 315 | second_account=frappe.db.get_value("Company", bt.company, "default_receivable_account"), 316 | ) 317 | 318 | bt.reload() 319 | self.assertEqual(bt.payment_entries[1].allocated_amount, 150) 320 | self.assertEqual(len(bt.payment_entries), 2) 321 | self.assertEqual(bt.status, "Reconciled") 322 | self.assertEqual(bt.unallocated_amount, 0) 323 | 324 | def test_unpaid_expense_claims_fully_reconcile(self): 325 | """ 326 | Test if 2 unpaid expense claims fully reconcile against a Bank Transaction. 327 | Test if they are paid and then the PE is reconciled. 328 | """ 329 | bt = create_bank_transaction( 330 | withdrawal=300, reference_no="expense-cl-001234", bank_account=self.bank_account 331 | ) 332 | expense_claim = make_expense_claim( 333 | payable_account=frappe.db.get_value("Company", bt.company, "default_payable_account"), 334 | amount=200, 335 | sanctioned_amount=200, 336 | company=bt.company, 337 | account="Travel Expenses - _TC", 338 | ) 339 | expense_claim_2 = make_expense_claim( 340 | payable_account=frappe.db.get_value("Company", bt.company, "default_payable_account"), 341 | amount=100, 342 | sanctioned_amount=100, 343 | company=bt.company, 344 | account="Travel Expenses - _TC", 345 | ) 346 | bulk_reconcile_vouchers( 347 | bt.name, 348 | json.dumps( 349 | [ 350 | {"payment_doctype": "Expense Claim", "payment_name": expense_claim.name}, 351 | {"payment_doctype": "Expense Claim", "payment_name": expense_claim_2.name}, 352 | ] 353 | ), 354 | ) 355 | 356 | bt.reload() 357 | expense_claim.reload() 358 | expense_claim_2.reload() 359 | self.assertEqual(bt.payment_entries[0].allocated_amount, 300) # one PE against 2 expense claims 360 | self.assertEqual(len(bt.payment_entries), 1) 361 | self.assertEqual(bt.unallocated_amount, 0) 362 | 363 | self.assertEqual(expense_claim.total_amount_reimbursed, 200) 364 | self.assertEqual(expense_claim_2.total_amount_reimbursed, 100) 365 | 366 | pe = get_pe_references([expense_claim.name, expense_claim_2.name]) 367 | self.assertEqual(pe[0].allocated_amount, 200) 368 | self.assertEqual(pe[1].allocated_amount, 100) 369 | 370 | def test_invoice_and_return(self): 371 | """Test invoices and returns paid by one bank transaction. 372 | 373 | BT: 200 374 | SI1, SI2, SI3: -100, -50, 350 375 | """ 376 | doc = create_bank_transaction( 377 | date=add_days(getdate(), -2), deposit=200, bank_account=self.bank_account 378 | ) 379 | customer = create_customer() 380 | return_1 = create_sales_invoice( 381 | rate=100, 382 | qty=-1, 383 | warehouse="Finished Goods - _TC", 384 | customer=customer, 385 | cost_center="Main - _TC", 386 | item="Reco Item", 387 | is_return=1, 388 | ) 389 | return_2 = create_sales_invoice( 390 | rate=50, 391 | qty=-1, 392 | warehouse="Finished Goods - _TC", 393 | customer=customer, 394 | cost_center="Main - _TC", 395 | item="Reco Item", 396 | is_return=1, 397 | ) 398 | invoice_1 = create_sales_invoice( 399 | rate=350, 400 | warehouse="Finished Goods - _TC", 401 | customer=customer, 402 | cost_center="Main - _TC", 403 | item="Reco Item", 404 | ) 405 | 406 | bulk_reconcile_vouchers( 407 | doc.name, 408 | json.dumps( 409 | [ 410 | {"payment_doctype": "Sales Invoice", "payment_name": return_1.name}, 411 | {"payment_doctype": "Sales Invoice", "payment_name": return_2.name}, 412 | {"payment_doctype": "Sales Invoice", "payment_name": invoice_1.name}, 413 | ] 414 | ), 415 | ) 416 | 417 | doc.reload() 418 | self.assertEqual(len(doc.payment_entries), 1) # 1 PE made against 3 invoices 419 | self.assertEqual(doc.payment_entries[0].allocated_amount, 200) 420 | 421 | pe = get_pe_references([return_1.name, return_2.name, invoice_1.name]) 422 | self.assertEqual(pe[0].allocated_amount, -100) 423 | self.assertEqual(pe[1].allocated_amount, -50) 424 | self.assertEqual(pe[2].allocated_amount, 350) 425 | 426 | # Check if the PE is posted on the same date as the BT 427 | self.assertEqual( 428 | doc.date, 429 | frappe.db.get_value("Payment Entry", doc.payment_entries[0].payment_entry, "posting_date"), 430 | ) 431 | 432 | def test_auto_reconciliation(self): 433 | """ 434 | Test auto reconciliation between a bank transaction and a payment entry. 435 | """ 436 | day_before_yesterday = add_days(getdate(), -2) 437 | bt = create_bank_transaction( 438 | date=day_before_yesterday, 439 | deposit=300, 440 | reference_no="Test001", 441 | bank_account=self.bank_account, 442 | ) 443 | create_payment_entry( 444 | payment_type="Receive", 445 | party_type="Customer", 446 | party=self.customer, 447 | paid_from="Debtors - _TC", 448 | paid_to=self.gl_account, 449 | paid_amount=250, 450 | save=1, 451 | submit=1, 452 | ) 453 | 454 | auto_reconcile_vouchers( 455 | bank_account=self.bank_account, 456 | from_date=day_before_yesterday, 457 | to_date=add_days(getdate(), 1), 458 | filter_by_reference_date=False, 459 | ) 460 | bt.reload() 461 | 462 | self.assertEqual(bt.payment_entries[0].allocated_amount, 250) 463 | self.assertEqual(bt.status, "Unreconciled") 464 | self.assertEqual(bt.unallocated_amount, 50) 465 | 466 | def test_multi_party_reconciliation(self): 467 | bt = create_bank_transaction( 468 | deposit=150, 469 | bank_account=self.bank_account, 470 | reference_no="multi-party", 471 | reference_date=getdate(), 472 | ) 473 | customer = create_customer() 474 | si = create_sales_invoice( 475 | rate=50, 476 | warehouse="Finished Goods - _TC", 477 | customer=customer, 478 | cost_center="Main - _TC", 479 | item="Reco Item", 480 | ) 481 | si2 = create_sales_invoice( 482 | rate=200, 483 | warehouse="Finished Goods - _TC", 484 | customer=self.customer, 485 | cost_center="Main - _TC", 486 | item="Reco Item", 487 | ) 488 | bulk_reconcile_vouchers( 489 | bt.name, 490 | json.dumps( 491 | [ 492 | { 493 | "payment_doctype": "Sales Invoice", 494 | "payment_name": si.name, 495 | "party": customer, 496 | }, 497 | { 498 | "payment_doctype": "Sales Invoice", 499 | "payment_name": si2.name, 500 | "party": self.customer, 501 | }, 502 | ] 503 | ), 504 | reconcile_multi_party=True, 505 | ) 506 | bt.reload() 507 | si.reload() 508 | si2.reload() 509 | 510 | je = frappe.get_doc("Journal Entry", bt.payment_entries[0].payment_entry) 511 | 512 | self.assertEqual(len(je.accounts), 3) 513 | self.assertEqual(je.voucher_type, "Bank Entry") 514 | self.assertEqual(je.accounts[0].account, si.debit_to) 515 | self.assertEqual(je.accounts[0].credit, 50) 516 | self.assertEqual(je.accounts[0].party_type, "Customer") 517 | self.assertEqual(je.accounts[0].party, si.customer) 518 | self.assertEqual(je.accounts[0].reference_type, "Sales Invoice") 519 | self.assertEqual(je.accounts[0].reference_name, si.name) 520 | self.assertEqual(je.accounts[1].account, si2.debit_to) 521 | self.assertEqual(je.accounts[1].credit, 100) 522 | self.assertEqual(je.accounts[1].party_type, "Customer") 523 | self.assertEqual(je.accounts[1].party, si2.customer) 524 | self.assertEqual(je.accounts[1].reference_type, "Sales Invoice") 525 | self.assertEqual(je.accounts[1].reference_name, si2.name) 526 | self.assertEqual( 527 | je.accounts[2].account, 528 | frappe.db.get_value("Bank Account", bt.bank_account, "account"), 529 | ) 530 | self.assertEqual(je.accounts[2].debit, 150) 531 | self.assertEqual(je.total_debit, 150) 532 | self.assertEqual(je.total_credit, 150) 533 | 534 | self.assertEqual(len(bt.payment_entries), 1) 535 | self.assertEqual(bt.payment_entries[0].allocated_amount, 150) 536 | self.assertEqual(bt.status, "Reconciled") 537 | self.assertEqual(bt.payment_entries[0].payment_document, "Journal Entry") 538 | 539 | self.assertEqual(si.outstanding_amount, 0) 540 | self.assertEqual(si2.outstanding_amount, 100) 541 | 542 | def test_configurable_reference_field(self): 543 | """Test if configured reference field is considered.""" 544 | settings = frappe.get_single("Banking Settings") 545 | settings.append("reference_fields", {"document_type": "Sales Invoice", "field_name": "custom_ref_no"}) 546 | settings.save() 547 | 548 | bt = create_bank_transaction( 549 | date=getdate(), 550 | deposit=300, 551 | reference_no="ORD-WXL-03456", 552 | bank_account=self.bank_account, 553 | description="Payment for Order: ORD-WXL-03456 | 300 | Thank you", 554 | ) 555 | si = create_sales_invoice( 556 | rate=300, 557 | warehouse="Finished Goods - _TC", 558 | customer=self.customer, 559 | cost_center="Main - _TC", 560 | item="Reco Item", 561 | do_not_submit=True, 562 | ) 563 | si.custom_ref_no = "ORD-WXL-03456" 564 | si.submit() 565 | 566 | si2 = create_sales_invoice( 567 | rate=20, 568 | warehouse="Finished Goods - _TC", 569 | customer=self.customer, 570 | cost_center="Main - _TC", 571 | item="Reco Item", 572 | do_not_submit=True, 573 | ) 574 | si2.custom_ref_no = "ORD-WXL-15467" 575 | si2.submit() 576 | 577 | matched_vouchers = get_linked_payments( 578 | bank_transaction_name=bt.name, 579 | document_types=["sales_invoice", "unpaid_invoices"], 580 | from_date=add_days(getdate(), -1), 581 | to_date=add_days(getdate(), 1), 582 | ) 583 | first_match, second_match = matched_vouchers[0], matched_vouchers[1] 584 | 585 | # Get linked payments and check if the custom field value is present 586 | self.assertEqual(len(matched_vouchers), 2) 587 | self.assertEqual(first_match["reference_no"], si.custom_ref_no) 588 | self.assertEqual(first_match["name"], si.name) 589 | self.assertEqual(first_match["rank"], 4) 590 | self.assertEqual(first_match["ref_in_desc_match"], 1) 591 | self.assertEqual(first_match["reference_number_match"], 1) 592 | self.assertEqual(second_match["ref_in_desc_match"], 0) 593 | self.assertEqual(second_match["reference_number_match"], 0) 594 | # Check if ranking across another SI is correct 595 | self.assertEqual(second_match["reference_no"], si2.custom_ref_no) 596 | self.assertEqual(second_match["name"], si2.name) 597 | self.assertEqual(second_match["rank"], 1) 598 | self.assertEqual(second_match["ref_in_desc_match"], 0) 599 | 600 | def test_no_configurable_reference_field(self): 601 | """Test if Name is considered as the reference field if not configured.""" 602 | bt = create_bank_transaction( 603 | date=getdate(), 604 | deposit=300, 605 | reference_no="Test001", 606 | bank_account=self.bank_account, 607 | description="Payment for Order: ORD-WXL-03456 | 300 | Thank you", 608 | ) 609 | si = create_sales_invoice( 610 | rate=300, 611 | warehouse="Finished Goods - _TC", 612 | customer=self.customer, 613 | cost_center="Main - _TC", 614 | item="Reco Item", 615 | do_not_submit=True, 616 | ) 617 | si.custom_ref_no = "ORD-WXL-03456" 618 | si.submit() 619 | 620 | matched_vouchers = get_linked_payments( 621 | bank_transaction_name=bt.name, 622 | document_types=["sales_invoice", "unpaid_invoices"], 623 | from_date=add_days(getdate(), -1), 624 | to_date=add_days(getdate(), 1), 625 | ) 626 | first_match = matched_vouchers[0] 627 | 628 | # Get linked payments and check if the custom field value is present 629 | self.assertEqual(len(matched_vouchers), 1) 630 | self.assertEqual(first_match["reference_no"], si.name) 631 | self.assertEqual(first_match["name"], si.name) 632 | self.assertEqual(first_match["rank"], 2) 633 | self.assertEqual(first_match["amount_match"], 1) 634 | self.assertEqual(first_match["ref_in_desc_match"], 0) 635 | 636 | def test_split_jv_match_against_transaction(self): 637 | """ 638 | Test if a split JV shows up as a single consolidated row in the tool 639 | and fully reconciles the Bank Transaction. 640 | """ 641 | bt = create_bank_transaction(deposit=200, reference_no="abcdef123", bank_account=self.bank_account) 642 | journal_entry = create_journal_entry_bts( 643 | bank_transaction_name=bt.name, 644 | party_type="Customer", 645 | party=self.customer, 646 | posting_date=bt.date, 647 | reference_number=bt.reference_number, 648 | reference_date=bt.date, 649 | entry_type="Bank Entry", 650 | second_account=frappe.db.get_value("Company", bt.company, "default_receivable_account"), 651 | allow_edit=True, 652 | ) 653 | 654 | # Split the JV Row into two 655 | journal_entry.accounts[1].debit_in_account_currency = 100 656 | journal_entry.append( 657 | "accounts", 658 | { 659 | "account": frappe.get_value("Bank Account", bt.bank_account, "account"), 660 | "bank_account": bt.bank_account, 661 | "credit_in_account_currency": 0.0, 662 | "debit_in_account_currency": 100, 663 | "cost_center": journal_entry.accounts[1].cost_center, 664 | }, 665 | ) 666 | journal_entry.submit() 667 | 668 | matched_vouchers = get_linked_payments( 669 | bank_transaction_name=bt.name, 670 | document_types=["journal_entry"], 671 | from_date=getdate(), 672 | to_date=getdate(), 673 | ) 674 | first_match = matched_vouchers[0] 675 | 676 | self.assertEqual(len(matched_vouchers), 1) 677 | self.assertEqual(first_match["reference_no"], bt.reference_number) 678 | self.assertEqual(first_match["name"], journal_entry.name) 679 | self.assertEqual(first_match["paid_amount"], 200.0) 680 | 681 | def test_usd_jv_against_eur_company(self): 682 | """Test if the tool can create a USD Journal Entry against a EUR company.""" 683 | bank = create_bank("Citi Bank USD", swift_number="CITIUS34") 684 | gl_account = create_bank_gl_account("_Test USD Bank Reco Tool", "USD") 685 | usd_receivable_account = frappe.get_doc( 686 | { 687 | "doctype": "Account", 688 | "company": "_Test Company", 689 | "parent_account": "Accounts Receivable - _TC", 690 | "account_type": "Receivable", 691 | "is_group": 0, 692 | "account_name": "USD Receivable - _TC", 693 | "account_currency": "USD", 694 | } 695 | ).insert() 696 | bank_account = create_bank_account(bank.name, gl_account, "USD Account") 697 | customer = create_customer(customer_name="USD Inc.", currency="USD") 698 | 699 | bt = create_bank_transaction( 700 | date=getdate(), 701 | deposit=200, 702 | reference_no="usd-jv-001", 703 | bank_account=bank_account, 704 | currency="USD", 705 | description="USD Inc.", 706 | ) 707 | 708 | create_journal_entry_bts( 709 | bank_transaction_name=bt.name, 710 | party_type="Customer", 711 | party=customer, 712 | posting_date=bt.date, 713 | reference_number=bt.reference_number, 714 | reference_date=bt.date, 715 | entry_type="Bank Entry", 716 | second_account=usd_receivable_account.name, 717 | ) 718 | 719 | bt.reload() 720 | self.assertEqual(bt.payment_entries[0].payment_document, "Journal Entry") 721 | 722 | journal_entry = frappe.get_doc("Journal Entry", bt.payment_entries[0].payment_entry) 723 | 724 | self.assertTrue(journal_entry.multi_currency) 725 | self.assertEqual(bt.status, "Reconciled") 726 | self.assertEqual(len(bt.payment_entries), 1) 727 | self.assertEqual(bt.payment_entries[0].allocated_amount, 200) 728 | 729 | 730 | def get_pe_references(vouchers: list): 731 | return frappe.get_all( 732 | "Payment Entry Reference", 733 | filters={"reference_name": ["in", vouchers]}, 734 | fields=["parent", "reference_name", "allocated_amount", "outstanding_amount"], 735 | order_by="idx", 736 | ) 737 | 738 | 739 | def create_bank_transaction( 740 | date: str | None = None, 741 | deposit: float | None = None, 742 | withdrawal: float | None = None, 743 | reference_no: str | None = None, 744 | reference_date: str | None = None, 745 | bank_account: str | None = None, 746 | description: str | None = None, 747 | currency: str = "INR", 748 | ): 749 | doc = frappe.get_doc( 750 | { 751 | "doctype": "Bank Transaction", 752 | "company": "_Test Company", 753 | "description": description or "1512567 BG/000002918 OPSKATTUZWXXX AT776000000098709837 Herr G", 754 | "date": date or frappe.utils.nowdate(), 755 | "deposit": deposit or 0.0, 756 | "withdrawal": withdrawal or 0.0, 757 | "currency": currency, 758 | "bank_account": bank_account, 759 | "reference_number": reference_no, 760 | } 761 | ).insert() 762 | return doc.submit() 763 | 764 | 765 | def create_customer(customer_name="_Test Customer", currency=None): 766 | if not frappe.db.exists("Customer", customer_name): 767 | customer = frappe.new_doc("Customer") 768 | customer.customer_name = customer_name 769 | customer.type = "Individual" 770 | customer.customer_group = "Commercial" 771 | customer.territory = "All Territories" 772 | 773 | if currency: 774 | customer.default_currency = currency 775 | customer.save() 776 | customer = customer.name 777 | else: 778 | customer = customer_name 779 | 780 | return customer 781 | 782 | 783 | def create_bank_account( 784 | bank_name="Citi Bank", 785 | gl_account="_Test Bank - _TC", 786 | bank_account_name="Personal Account", 787 | company=None, 788 | ) -> str: 789 | if bank_account := frappe.db.exists( 790 | "Bank Account", 791 | { 792 | "account_name": bank_account_name, 793 | "bank": bank_name, 794 | "account": gl_account, 795 | "company": company or "_Test Company", 796 | "is_company_account": 1, 797 | }, 798 | ): 799 | return bank_account 800 | 801 | bank_account = frappe.get_doc( 802 | { 803 | "doctype": "Bank Account", 804 | "account_name": bank_account_name, 805 | "bank": bank_name, 806 | "account": gl_account, 807 | "company": company or "_Test Company", 808 | "is_company_account": 1, 809 | } 810 | ).insert() 811 | return bank_account.name 812 | 813 | 814 | def create_bank(bank_name: str = "Citi Bank", swift_number: str = "CITIUS33"): 815 | if not frappe.db.exists("Bank", bank_name): 816 | bank = frappe.new_doc("Bank") 817 | bank.bank_name = bank_name 818 | bank.swift_number = swift_number 819 | bank.insert() 820 | else: 821 | bank = frappe.get_doc("Bank", bank_name) 822 | return bank 823 | 824 | 825 | def create_bank_gl_account(account_name: str = "_Test Bank - _TC", currency: str = "INR") -> str: 826 | gl_account = frappe.get_doc( 827 | { 828 | "doctype": "Account", 829 | "company": "_Test Company", 830 | "parent_account": "Current Assets - _TC", 831 | "account_type": "Bank", 832 | "is_group": 0, 833 | "account_name": account_name, 834 | "account_currency": currency, 835 | } 836 | ).insert() 837 | return gl_account.name 838 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/utils.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | from frappe.query_builder.functions import Cast, CustomFunction 4 | from pypika.queries import Table 5 | from pypika.terms import Case, Field 6 | 7 | Instr = CustomFunction("INSTR", ["a", "b"]) 8 | RegExpReplace = CustomFunction("REGEXP_REPLACE", ["a", "b", "c"]) 9 | 10 | # NOTE: 11 | # Ranking min: 1 (nothing matches), max: 7 (everything matches) 12 | 13 | # Types of matches: 14 | # amount_match: if amount in voucher EQ amount in bank statement 15 | # party_match: if party in voucher EQ party in bank statement 16 | # date_match: if date in voucher EQ date in bank statement 17 | # reference_number_match: if ref in voucher EQ ref in bank statement 18 | # name_in_desc_match: if name in voucher IN bank statement description 19 | # ref_in_desc_match: if ref in voucher IN bank statement description 20 | 21 | 22 | def amount_rank_condition(amount: Field, bank_amount: float) -> Case: 23 | """Get the rank query for amount matching.""" 24 | return frappe.qb.terms.Case().when(amount == bank_amount, 1).else_(0) 25 | 26 | 27 | def ref_equality_condition(reference_no: Field, bank_reference_no: str) -> Case: 28 | """Get the rank query for reference number matching.""" 29 | if not bank_reference_no or bank_reference_no == "NOTPROVIDED": 30 | # If bank reference number is not provided, then it is not a match 31 | return Cast(0, "int") 32 | 33 | return frappe.qb.terms.Case().when(reference_no == bank_reference_no, 1).else_(0) 34 | 35 | 36 | def get_description_match_condition(description: str, table: Table, column_name: str = "name") -> Case: 37 | """Get the description match condition for a column. 38 | 39 | Args: 40 | description: The bank transaction description to search in 41 | column_name: The document column to match against (e.g., expense_claim.name, purchase_invoice.bill_no) 42 | 43 | Returns: 44 | A query condition that will be 1 if the description contains the document number 45 | and 0 otherwise. 46 | """ 47 | if not description: 48 | return Cast(0, "int") 49 | 50 | column_name = column_name or "name" 51 | column = table[column_name] 52 | # Perform replace if the column is the name, else the column value is ambiguous 53 | # Eg. column_name = "custom_ref_no" and its value = "tuf5673i" should be untouched 54 | if column_name == "name": 55 | return ( 56 | frappe.qb.terms.Case() 57 | .when( 58 | Instr(description, RegExpReplace(column, r"^[^0-9]*", "")) > 0, 59 | 1, 60 | ) 61 | .else_(0) 62 | ) 63 | else: 64 | return ( 65 | frappe.qb.terms.Case() 66 | .when( 67 | column.notnull() & (column != "") & (Instr(description, column) > 0), 68 | 1, 69 | ) 70 | .else_(0) 71 | ) 72 | 73 | 74 | def get_reference_field_map() -> dict: 75 | """Get the reference field map for the document types from Banking Settings. 76 | Returns: {"sales_invoice": "custom_field_name", ...} 77 | """ 78 | 79 | def _validate_and_get_field(row: dict) -> str: 80 | is_docfield = frappe.db.exists("DocField", {"fieldname": row.field_name, "parent": row.document_type}) 81 | is_custom = frappe.db.exists("Custom Field", {"fieldname": row.field_name, "dt": row.document_type}) 82 | if not (is_docfield or is_custom): 83 | frappe.throw( 84 | title=_("Invalid Field"), 85 | msg=_( 86 | "Field {} does not exist in {}. Please check the configuration in Banking Settings." 87 | ).format(frappe.bold(row.field_name), frappe.bold(row.document_type)), 88 | ) 89 | 90 | return row.field_name 91 | 92 | reference_fields = frappe.get_all( 93 | "Banking Reference Mapping", 94 | filters={ 95 | "parent": "Banking Settings", 96 | }, 97 | fields=["document_type", "field_name"], 98 | ) 99 | 100 | return {frappe.scrub(row.document_type): _validate_and_get_field(row) for row in reference_fields} 101 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/banking_reference_mapping/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/klarna_kosma_integration/doctype/banking_reference_mapping/__init__.py -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/banking_reference_mapping/banking_reference_mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2024-11-20 16:26:57.613210", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "document_type", 10 | "field_name" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "document_type", 15 | "fieldtype": "Select", 16 | "in_list_view": 1, 17 | "label": "Document Type", 18 | "options": "Sales Invoice\nPurchase Invoice\nExpense Claim", 19 | "reqd": 1 20 | }, 21 | { 22 | "fieldname": "field_name", 23 | "fieldtype": "Select", 24 | "in_list_view": 1, 25 | "label": "Field Name", 26 | "reqd": 1 27 | } 28 | ], 29 | "index_web_pages_for_search": 1, 30 | "istable": 1, 31 | "links": [], 32 | "modified": "2024-11-20 17:12:39.739249", 33 | "modified_by": "Administrator", 34 | "module": "Klarna Kosma Integration", 35 | "name": "Banking Reference Mapping", 36 | "owner": "Administrator", 37 | "permissions": [], 38 | "sort_field": "modified", 39 | "sort_order": "DESC", 40 | "states": [] 41 | } -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/banking_reference_mapping/banking_reference_mapping.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, ALYF GmbH and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class BankingReferenceMapping(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/banking_settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/klarna_kosma_integration/doctype/banking_settings/__init__.py -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022, ALYF GmbH and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("Banking Settings", { 5 | refresh: (frm) => { 6 | if (frm.doc.enabled) { 7 | frm.trigger("get_app_health"); 8 | 9 | if (frm.doc.enable_ebics) { 10 | frm.add_custom_button(__("View EBICS Users"), () => { 11 | frappe.set_route("List", "EBICS User"); 12 | }); 13 | } 14 | 15 | if (frm.doc.customer_id && frm.doc.admin_endpoint && frm.doc.api_token) { 16 | frm.trigger("get_subscription"); 17 | } 18 | 19 | frm.add_custom_button(__("Open Billing Portal"), async () => { 20 | const url = await frm.call({ 21 | method: "get_customer_portal_url", 22 | freeze: true, 23 | freeze_message: __("Redirecting to Customer Portal ..."), 24 | }); 25 | if (url.message) { 26 | window.open(url.message, "_blank"); 27 | } 28 | }); 29 | } else { 30 | frm.page.add_inner_button( 31 | __("Signup for Banking"), 32 | () => { 33 | window.open(`${frm.doc.admin_endpoint}/banking-pricing`, "_blank"); 34 | }, 35 | null, 36 | "primary" 37 | ); 38 | } 39 | 40 | frm.doc.reference_fields.map((field) => { 41 | set_field_options(frm, field.doctype, field.name); 42 | }); 43 | }, 44 | 45 | get_subscription: async (frm) => { 46 | const data = await frm.call({ 47 | method: "fetch_subscription_data", 48 | }); 49 | 50 | if (data.message) { 51 | let subscription = data.message[0]; 52 | 53 | frm.get_field("subscription").$wrapper.empty(); 54 | frm.doc.subscription = "subscription"; 55 | frm.get_field("subscription").$wrapper.html(` 56 |
62 |

63 | ${__("Subscription Details")} 64 |

65 |

66 | ${__("Subscriber")}: 67 | ${subscription.full_name} 68 |

69 |

70 | ${__("Status")}: 71 | ${subscription.subscription_status} 72 |

73 |

74 | ${__("Ebics Users")}: 75 | ${subscription.ebics_usage.used} (${__("Usage")}) / ${ 76 | subscription.ebics_usage.allowed 77 | } (${__("Limit")}) 78 |

79 |

80 | ${__("Valid Till")}: 81 | ${frappe.format(subscription.plan_end_date, { fieldtype: "Date" })} 82 |

83 |

84 | ${__("Last Renewed On")}: 85 | ${frappe.format(subscription.last_paid_on, { fieldtype: "Date" })} 86 |

87 |

88 | 93 | ${__("Open Billing Portal")} 94 | ${frappe.utils.icon("link-url", "sm")} 95 | 96 |

97 |
98 | `); 99 | 100 | if (subscription.billing_portal) { 101 | frm.remove_custom_button(__("Open Billing Portal")); 102 | } 103 | 104 | frm.refresh_field("subscription"); 105 | } 106 | }, 107 | 108 | get_app_health: async (frm) => { 109 | const data = await frm.call({ 110 | method: "get_app_health", 111 | }); 112 | 113 | let messages = data.message; 114 | if (messages) { 115 | if (messages["info"]) { 116 | frm.set_intro(messages["info"], "blue"); 117 | } 118 | 119 | if (messages["warning"]) { 120 | $(frm.$wrapper.find(".form-layout")[0]).prepend(` 121 |
122 | ${messages["warning"]} 123 |
124 | `); 125 | } 126 | } 127 | }, 128 | }); 129 | 130 | frappe.ui.form.on("Banking Reference Mapping", { 131 | reference_fields_add: (frm, cdt, cdn) => { 132 | set_field_options(frm, cdt, cdn); 133 | }, 134 | 135 | document_type: (frm, cdt, cdn) => { 136 | set_field_options(frm, cdt, cdn); 137 | }, 138 | }); 139 | 140 | function set_field_options(frm, cdt, cdn) { 141 | const doc = frappe.get_doc(cdt, cdn); 142 | const document_type = doc.document_type || "Sales Invoice"; 143 | 144 | // set options for `field_name` 145 | frappe.model.with_doctype(document_type, () => { 146 | const meta = frappe.get_meta(document_type); 147 | const fields = meta.fields.filter((field) => { 148 | return ( 149 | ["Link", "Data"].includes(field.fieldtype) && field.is_virtual === 0 150 | ); 151 | }); 152 | 153 | frm.fields_dict.reference_fields.grid.update_docfield_property( 154 | "field_name", 155 | "options", 156 | fields 157 | .map((field) => { 158 | return { 159 | value: field.fieldname, 160 | label: __(field.label), 161 | }; 162 | }) 163 | .sort((a, b) => a.label.localeCompare(b.label)) 164 | ); 165 | frm.refresh_field("reference_fields"); 166 | }); 167 | } 168 | 169 | function get_info_html(message) { 170 | return `
177 | ${frappe.utils.icon("solid-info", "md")} 178 | 179 | ${message} 180 | 181 |
`; 182 | } 183 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2022-12-01 16:21:37.132544", 5 | "default_view": "List", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "enabled", 11 | "section_break_2", 12 | "admin_endpoint", 13 | "customer_id", 14 | "api_token", 15 | "column_break_4", 16 | "subscription", 17 | "ebics_section", 18 | "enable_ebics", 19 | "fintech_licensee_name", 20 | "fintech_license_key", 21 | "bank_reconciliation_tab", 22 | "advanced_section", 23 | "reference_fields" 24 | ], 25 | "fields": [ 26 | { 27 | "default": "0", 28 | "fieldname": "enabled", 29 | "fieldtype": "Check", 30 | "label": "Enabled" 31 | }, 32 | { 33 | "fieldname": "section_break_2", 34 | "fieldtype": "Section Break", 35 | "label": "Subscription" 36 | }, 37 | { 38 | "fieldname": "column_break_4", 39 | "fieldtype": "Column Break" 40 | }, 41 | { 42 | "fieldname": "api_token", 43 | "fieldtype": "Password", 44 | "label": "Portal API Token", 45 | "length": 619, 46 | "mandatory_depends_on": "enabled" 47 | }, 48 | { 49 | "fieldname": "customer_id", 50 | "fieldtype": "Data", 51 | "label": "Customer ID", 52 | "mandatory_depends_on": "enabled" 53 | }, 54 | { 55 | "default": "https://banking.alyf.de", 56 | "description": "Eg: https://banking.alyf.de", 57 | "fieldname": "admin_endpoint", 58 | "fieldtype": "Data", 59 | "in_list_view": 1, 60 | "label": "Admin URL", 61 | "mandatory_depends_on": "enabled" 62 | }, 63 | { 64 | "depends_on": "subscription", 65 | "fieldname": "subscription", 66 | "fieldtype": "HTML", 67 | "read_only": 1 68 | }, 69 | { 70 | "fieldname": "ebics_section", 71 | "fieldtype": "Section Break", 72 | "label": "EBICS" 73 | }, 74 | { 75 | "depends_on": "enable_ebics", 76 | "fieldname": "fintech_license_key", 77 | "fieldtype": "Password", 78 | "label": "Fintech License Key", 79 | "read_only": 1 80 | }, 81 | { 82 | "depends_on": "enable_ebics", 83 | "fieldname": "fintech_licensee_name", 84 | "fieldtype": "Data", 85 | "label": "Fintech Licensee Name", 86 | "read_only": 1 87 | }, 88 | { 89 | "default": "0", 90 | "fieldname": "enable_ebics", 91 | "fieldtype": "Check", 92 | "label": "Enable EBICS (New)" 93 | }, 94 | { 95 | "fieldname": "bank_reconciliation_tab", 96 | "fieldtype": "Tab Break", 97 | "label": "Bank Reconciliation" 98 | }, 99 | { 100 | "description": "", 101 | "fieldname": "reference_fields", 102 | "fieldtype": "Table", 103 | "label": "Reference Fields", 104 | "options": "Banking Reference Mapping" 105 | }, 106 | { 107 | "fieldname": "advanced_section", 108 | "fieldtype": "Section Break", 109 | "label": "Advanced" 110 | } 111 | ], 112 | "index_web_pages_for_search": 1, 113 | "issingle": 1, 114 | "links": [], 115 | "modified": "2025-01-29 17:04:19.776579", 116 | "modified_by": "Administrator", 117 | "module": "Klarna Kosma Integration", 118 | "name": "Banking Settings", 119 | "owner": "Administrator", 120 | "permissions": [ 121 | { 122 | "create": 1, 123 | "delete": 1, 124 | "email": 1, 125 | "print": 1, 126 | "read": 1, 127 | "role": "System Manager", 128 | "share": 1, 129 | "write": 1 130 | } 131 | ], 132 | "sort_field": "modified", 133 | "sort_order": "DESC", 134 | "states": [] 135 | } -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, ALYF GmbH and contributors 2 | # For license information, please see license.txt 3 | 4 | from datetime import timedelta 5 | 6 | import frappe 7 | from frappe import _ 8 | from frappe.model.document import Document 9 | from frappe.utils.data import now_datetime 10 | from requests import HTTPError 11 | from semantic_version import Version 12 | 13 | from banking.klarna_kosma_integration.admin import Admin 14 | 15 | 16 | class BankingSettings(Document): 17 | def before_validate(self): 18 | self.update_fintech_license() 19 | 20 | def update_fintech_license(self): 21 | if not self.enabled: 22 | return self.reset_fintech_license() 23 | 24 | try: 25 | response = Admin(self).request.get_fintech_license() 26 | response.raise_for_status() 27 | except HTTPError: 28 | return self.reset_fintech_license() 29 | except Exception: 30 | return 31 | 32 | data = response.json().get("message", {}) 33 | self.fintech_licensee_name = data.get("licensee_name") 34 | self.fintech_license_key = data.get("license_key") 35 | 36 | def reset_fintech_license(self): 37 | self.fintech_licensee_name = None 38 | self.fintech_license_key = None 39 | 40 | 41 | @frappe.whitelist() 42 | def sync_all_accounts_and_transactions(): 43 | """ 44 | Refresh all Bank accounts and enqueue their transactions sync. 45 | Called via hooks. 46 | """ 47 | banking_settings = frappe.get_single("Banking Settings") 48 | if not banking_settings.enabled: 49 | return 50 | 51 | if banking_settings.enable_ebics: 52 | daily_sync_ebics() 53 | 54 | 55 | def daily_sync_ebics(): 56 | from banking.ebics.utils import sync_ebics_transactions 57 | 58 | if frappe.conf.developer_mode: 59 | frappe.throw( 60 | _("Developer mode is enabled. Please disable it to continue auto-syncing bank transactions.") 61 | ) 62 | 63 | yesterday = (now_datetime() - timedelta(days=1)).date().isoformat() 64 | for ebics_user in frappe.get_all( 65 | "EBICS User", 66 | filters={ 67 | "initialized": 1, 68 | "bank_keys_activated": 1, 69 | "passphrase": ("is", "set"), 70 | "keyring": ("is", "set"), 71 | }, 72 | pluck="name", 73 | ): 74 | frappe.enqueue( 75 | sync_ebics_transactions, 76 | requested_by="System", 77 | start_date=yesterday, 78 | end_date=yesterday, 79 | ebics_user=ebics_user, 80 | ) 81 | 82 | 83 | def intraday_sync_ebics(): 84 | from banking.ebics.utils import sync_ebics_transactions 85 | 86 | if frappe.conf.developer_mode: 87 | frappe.throw( 88 | _("Developer mode is enabled. Please disable it to continue auto-syncing bank transactions.") 89 | ) 90 | 91 | banking_settings = frappe.get_single("Banking Settings") 92 | if not banking_settings.enabled or not banking_settings.enable_ebics: 93 | return 94 | 95 | today = now_datetime().date().isoformat() 96 | for ebics_user in frappe.get_all( 97 | "EBICS User", 98 | filters={ 99 | "initialized": 1, 100 | "bank_keys_activated": 1, 101 | "passphrase": ("is", "set"), 102 | "keyring": ("is", "set"), 103 | "intraday_sync": 1, 104 | }, 105 | pluck="name", 106 | ): 107 | frappe.enqueue( 108 | sync_ebics_transactions, 109 | requested_by="System", 110 | start_date=today, 111 | end_date=today, 112 | ebics_user=ebics_user, 113 | intraday=True, 114 | ) 115 | 116 | 117 | @frappe.whitelist() 118 | def fetch_subscription_data() -> dict: 119 | """ 120 | Fetch Accounts via Flow API after XS2A App interaction. 121 | """ 122 | return Admin().fetch_subscription() 123 | 124 | 125 | @frappe.whitelist() 126 | def get_customer_portal_url() -> str: 127 | """ 128 | Returns the customer portal URL. 129 | """ 130 | return Admin().get_customer_portal_url() 131 | 132 | 133 | @frappe.whitelist() 134 | def get_app_health() -> dict: 135 | """ 136 | Returns the app health. 137 | """ 138 | from frappe.utils.scheduler import is_scheduler_inactive 139 | 140 | messages = {} 141 | current_app_version = frappe.get_attr("banking.__version__") 142 | 143 | latest_release = get_latest_release_for_branch("alyf-de", "banking") 144 | if not latest_release: 145 | return None 146 | 147 | if Version(current_app_version) < Version(latest_release): 148 | messages["info"] = _( 149 | "A new version of the Banking app is available ({0}). Please update your instance." 150 | ).format(str(latest_release)) 151 | 152 | if is_scheduler_inactive(): 153 | messages["warning"] = _( 154 | "The scheduler is inactive. Please activate it to continue auto-syncing bank transactions." 155 | ) 156 | 157 | return messages or None 158 | 159 | 160 | def get_latest_release_for_branch(owner: str, repo: str): 161 | """ 162 | Returns the latest release for the current branch. 163 | """ 164 | import requests 165 | from frappe.utils.change_log import get_app_branch 166 | 167 | branch = get_app_branch("banking") 168 | try: 169 | releases = requests.get(f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=10") 170 | releases.raise_for_status() 171 | 172 | for release in releases.json(): 173 | if release.get("target_commitish") == branch: 174 | return release.get("name")[1:] # remove v prefix 175 | except Exception: 176 | frappe.log_error(title=_("Banking Error"), message=_("Error while fetching releases")) 177 | return None 178 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/doctype/banking_settings/test_banking_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, ALYF GmbH and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestBankingSettings(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/exception_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import frappe 4 | import requests 5 | from frappe import _ 6 | 7 | 8 | class BankingError(frappe.ValidationError): 9 | pass 10 | 11 | 12 | class ExceptionHandler: 13 | """ 14 | Log and throw error as received from Admin app. 15 | """ 16 | 17 | def __init__(self, exception): 18 | self.exception = exception 19 | self.handle_error() 20 | 21 | def handle_error(self): 22 | if not isinstance(self.exception, requests.exceptions.HTTPError): 23 | frappe.log_error(title=_("Banking Error"), message=frappe.get_traceback()) 24 | raise 25 | 26 | response = self.exception.response 27 | self.handle_auth_error(response) 28 | self.handle_authorization_error(response) 29 | self.handle_txt_html_error(response) 30 | 31 | content = response.json().get("message", {}) 32 | self.handle_frappe_server_error(content, response) 33 | self.handle_admin_error(content) 34 | 35 | def handle_auth_error(self, response): 36 | if not response.status_code == 401: 37 | return 38 | 39 | frappe.log_error(title=_("Banking Error"), message=response.content) 40 | frappe.throw( 41 | title=_("Banking Error"), 42 | msg=_("Authentication error due to invalid credentials."), 43 | exc=BankingError, 44 | ) 45 | 46 | def handle_authorization_error(self, response): 47 | if not response.status_code == 403: 48 | return 49 | 50 | frappe.log_error(title=_("Banking Error"), message=response.content) 51 | 52 | content = response.json().get("message", {}) 53 | message = content if isinstance(content, str) else "Authorization error due to invalid access." 54 | frappe.throw(title=_("Banking Error"), msg=_(message), exc=BankingError) 55 | 56 | def handle_txt_html_error(self, response): 57 | """ 58 | Handle Gateway Error, etc. that dont have a JSON response. 59 | """ 60 | if "application/json" in response.headers.get("Content-Type", ""): 61 | return 62 | 63 | frappe.log_error(title=_("Banking Error"), message=response.content) 64 | frappe.throw( 65 | title=_("Banking Error"), 66 | msg=_("Something went wrong. Please retry in a while."), 67 | exc=BankingError, 68 | ) 69 | 70 | def handle_frappe_server_error(self, content, response): 71 | response_data = response.json() 72 | 73 | if not content and "exc_type" in response_data: 74 | frappe.log_error(title=_("Banking Error"), message=response.content) 75 | 76 | message = response_data.get("exception") or _( 77 | "The server has errored. Please retry in some time." 78 | ) 79 | frappe.throw(title=_("Banking Error"), msg=message, exc=BankingError) 80 | 81 | def handle_admin_error(self, content): 82 | error_data = content.get("error", {}) or content.get("data", {}) 83 | 84 | # log only loggable errors (not sensitive data) 85 | frappe.log_error(title=_("Banking Error"), message=json.dumps(error_data)) 86 | 87 | # multiple errors 88 | errors = error_data.get("errors") 89 | if errors: 90 | error_list = [f"{err.get('location')} - {self.get_msg(err)}" for err in errors] 91 | message = _("Banking Action has failed due to the following error(s):") 92 | message += "
" 93 | 94 | frappe.throw(title=_("Banking Error"), msg=message, exc=BankingError) 95 | elif error_data.get("message"): 96 | frappe.throw(title=_("Banking Error"), msg=self.get_msg(error_data), exc=BankingError) 97 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/test_kosma.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from erpnext.accounts.doctype.journal_entry.journal_entry import ( 3 | get_default_bank_cash_account, 4 | ) 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | from banking.klarna_kosma_integration.admin import Admin 8 | 9 | 10 | class TestKosma(FrappeTestCase): 11 | @classmethod 12 | def setUpClass(cls): 13 | doc = frappe.get_single("Banking Settings") 14 | doc.enabled = True 15 | doc.api_token = "xabsttcpQr5" 16 | doc.customer_id = "ADCB8A" 17 | doc.admin_endpoint = "http://banking-admin:8000" 18 | doc.save() 19 | 20 | default_bank_account = frappe.db.get_value("Company", "Bolt Trades", "default_bank_account") 21 | if default_bank_account is None: 22 | frappe.db.set_value( 23 | "Company", 24 | "Bolt Trades", 25 | "default_bank_account", 26 | get_default_bank_cash_account("Bolt Trades", "Cash").get("account"), 27 | ) 28 | 29 | return super().setUpClass() 30 | 31 | @classmethod 32 | def tearDownClass(cls): 33 | doc = frappe.get_single("Banking Settings") 34 | doc.enabled = False 35 | doc.save() 36 | 37 | def test_admin_obj(self): 38 | """Test if Admin objects are initialised correctly""" 39 | admin = Admin() 40 | self.assertEqual(admin.api_token, "xabsttcpQr5") 41 | self.assertEqual(admin.customer_id, "ADCB8A") 42 | -------------------------------------------------------------------------------- /banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json: -------------------------------------------------------------------------------- 1 | { 2 | "charts": [], 3 | "content": "[{\"id\":\"rY4kqSgqjV\",\"type\":\"header\",\"data\":{\"text\":\"Banking\",\"col\":12}},{\"id\":\"-xJNZCe4fr\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Bank Transactions\",\"col\":6}},{\"id\":\"ik6keY0zOR\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Bank Reconciliation\",\"col\":6}},{\"id\":\"u9Uhmqo0Qv\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"EBICS Users\",\"col\":6}},{\"id\":\"zve0DHAzCk\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"MRvsFZVnBb\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]", 4 | "creation": "2023-09-01 18:57:28.811898", 5 | "custom_blocks": [], 6 | "docstatus": 0, 7 | "doctype": "Workspace", 8 | "for_user": "", 9 | "hide_custom": 0, 10 | "icon": "income", 11 | "idx": 0, 12 | "is_hidden": 0, 13 | "label": "ALYF Banking", 14 | "links": [ 15 | { 16 | "hidden": 0, 17 | "is_query_report": 0, 18 | "label": "Settings", 19 | "link_count": 1, 20 | "link_type": "DocType", 21 | "onboard": 0, 22 | "type": "Card Break" 23 | }, 24 | { 25 | "hidden": 0, 26 | "is_query_report": 0, 27 | "label": "Banking Settings", 28 | "link_count": 0, 29 | "link_to": "Banking Settings", 30 | "link_type": "DocType", 31 | "onboard": 0, 32 | "type": "Link" 33 | } 34 | ], 35 | "modified": "2025-01-29 18:34:42.028256", 36 | "modified_by": "Administrator", 37 | "module": "Klarna Kosma Integration", 38 | "name": "ALYF Banking", 39 | "number_cards": [], 40 | "owner": "Administrator", 41 | "parent_page": "Accounting", 42 | "public": 1, 43 | "quick_lists": [], 44 | "roles": [ 45 | { 46 | "role": "Accounts User" 47 | }, 48 | { 49 | "role": "Accounts Manager" 50 | }, 51 | { 52 | "role": "Auditor" 53 | } 54 | ], 55 | "sequence_id": 4.0, 56 | "shortcuts": [ 57 | { 58 | "color": "Yellow", 59 | "doc_view": "List", 60 | "format": "{} Unreconciled", 61 | "label": "Bank Transactions", 62 | "link_to": "Bank Transaction", 63 | "stats_filter": "{\"unallocated_amount\":[\">=\",0.01], \"docstatus\": 1}", 64 | "type": "DocType" 65 | }, 66 | { 67 | "color": "Green", 68 | "doc_view": "List", 69 | "format": "{} Active", 70 | "label": "EBICS Users", 71 | "link_to": "EBICS User", 72 | "stats_filter": "[[\"EBICS User\",\"bank_keys_activated\",\"=\",1,false]]", 73 | "type": "DocType" 74 | }, 75 | { 76 | "color": "Grey", 77 | "doc_view": "List", 78 | "label": "Bank Reconciliation", 79 | "link_to": "Bank Reconciliation Tool Beta", 80 | "type": "DocType" 81 | } 82 | ], 83 | "title": "ALYF Banking" 84 | } -------------------------------------------------------------------------------- /banking/modules.txt: -------------------------------------------------------------------------------- 1 | Klarna Kosma Integration 2 | EBICS -------------------------------------------------------------------------------- /banking/overrides/bank_transaction.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | import frappe 4 | from erpnext import get_default_cost_center 5 | from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction 6 | from erpnext.accounts.doctype.payment_entry.payment_entry import ( 7 | get_payment_entry, 8 | split_invoices_based_on_payment_terms, 9 | ) 10 | from frappe import _ 11 | from frappe.core.utils import find 12 | from frappe.model.document import Document 13 | from frappe.utils import flt, getdate 14 | 15 | DOCTYPE, DOCNAME, AMOUNT, PARTY = 0, 1, 2, 3 16 | 17 | 18 | class CustomBankTransaction(BankTransaction): 19 | def add_payment_entries(self, vouchers: list, reconcile_multi_party: bool = False): 20 | "Add the vouchers with zero allocation. Save() will perform the allocations and clearance" 21 | if self.unallocated_amount <= 0.0: 22 | frappe.throw(frappe._("Bank Transaction {0} is already fully reconciled").format(self.name)) 23 | 24 | pe_length_before = len(self.payment_entries) 25 | unpaid_docs = ["Sales Invoice", "Purchase Invoice", "Expense Claim"] 26 | 27 | # Vouchers can either all be paid or all be unpaid 28 | if any(voucher["payment_doctype"] in unpaid_docs for voucher in vouchers): 29 | self.reconcile_invoices(vouchers, reconcile_multi_party) 30 | else: 31 | self.reconcile_paid_vouchers(vouchers) 32 | 33 | if len(self.payment_entries) != pe_length_before: 34 | self.save() # runs on_update_after_submit 35 | 36 | def validate_period_closing(self): 37 | """ 38 | Check if the Bank Transaction date is after the latest period closing date. 39 | We cannot make PEs against this transaction's date (before period closing date). 40 | """ 41 | latest_period_close_date = frappe.db.get_value( 42 | "Period Closing Voucher", 43 | {"company": self.company, "docstatus": 1}, 44 | "period_end_date", 45 | order_by="period_end_date desc", 46 | ) 47 | if latest_period_close_date and getdate(self.date) <= getdate(latest_period_close_date): 48 | frappe.throw( 49 | _( 50 | "Due to Period Closing, you cannot reconcile unpaid vouchers with a Bank Transaction before {0}" 51 | ).format(frappe.format(latest_period_close_date, "Date")) 52 | ) 53 | 54 | def add_to_payment_entry(self, payment_doctype, payment_name): 55 | """Add the payment entry to the bank transaction""" 56 | pe = { 57 | "payment_document": payment_doctype, 58 | "payment_entry": payment_name, 59 | "allocated_amount": 0.0, # Temporary 60 | } 61 | self.append("payment_entries", pe) 62 | 63 | def reconcile_paid_vouchers(self, vouchers): 64 | """Reconcile paid vouchers with the Bank Transaction.""" 65 | for voucher in vouchers: 66 | voucher_type, voucher_name = voucher["payment_doctype"], voucher["payment_name"] 67 | if self.is_duplicate_reference(voucher_type, voucher_name): 68 | continue 69 | 70 | self.add_to_payment_entry(voucher["payment_doctype"], voucher["payment_name"]) 71 | 72 | def reconcile_invoices(self, vouchers: list, reconcile_multi_party: bool = False): 73 | """Reconcile unpaid invoices with the Bank Transaction.""" 74 | invoices_to_bill = [] 75 | for voucher in vouchers: 76 | voucher_type, voucher_name = voucher["payment_doctype"], voucher["payment_name"] 77 | if self.is_duplicate_reference(voucher_type, voucher_name): 78 | continue 79 | 80 | outstanding_amount = get_outstanding_amount(voucher_type, voucher_name) 81 | if ( 82 | voucher_type 83 | not in ( 84 | "Sales Invoice", 85 | "Purchase Invoice", 86 | "Expense Claim", 87 | ) 88 | and outstanding_amount == 0.0 89 | ): 90 | frappe.throw(_("Invalid Voucher Type")) 91 | 92 | # Make PE against the unpaid invoice, link PE to Bank Transaction 93 | invoices_to_bill.append((voucher_type, voucher_name, outstanding_amount, voucher.get("party"))) 94 | 95 | # Make single PE against multiple invoices 96 | if invoices_to_bill: 97 | self.validate_period_closing() 98 | if reconcile_multi_party: 99 | payment_name = self.make_jv_against_invoices(invoices_to_bill) 100 | else: 101 | payment_name = self.make_pe_against_invoices(invoices_to_bill) 102 | 103 | self.add_to_payment_entry( 104 | "Journal Entry" if reconcile_multi_party else "Payment Entry", payment_name 105 | ) 106 | 107 | def make_jv_against_invoices(self, invoices_to_bill: list) -> str: 108 | """Make Journal Entry against multiple invoices.""" 109 | 110 | def _attach_invoice(row: dict, journal_entry: "Document") -> None: 111 | second_account = get_debtor_creditor_account(row) 112 | second_account_currency = frappe.db.get_value("Account", second_account, "account_currency") 113 | if second_account_currency != company_currency: 114 | frappe.throw( 115 | _( 116 | "The currency of the second account ({0}) must be the same as of the bank account ({1})" 117 | ).format(second_account, company_currency) 118 | ) 119 | journal_entry.append( 120 | "accounts", 121 | { 122 | "account": second_account, 123 | "credit_in_account_currency": row.allocated_amount if self.deposit > 0 else 0.0, 124 | "debit_in_account_currency": row.allocated_amount if self.withdrawal > 0 else 0.0, 125 | "party_type": row.get("party_type"), 126 | "party": row.get("party"), 127 | "cost_center": get_default_cost_center(company), 128 | "reference_type": row.voucher_type, 129 | "reference_name": row.voucher_no, 130 | }, 131 | ) 132 | 133 | self.validate_invoices_to_bill(invoices_to_bill, allow_multi_party=True) 134 | 135 | company_account = frappe.get_value("Bank Account", self.bank_account, "account") 136 | company, company_currency = frappe.get_value( 137 | "Account", company_account, ["company", "account_currency"] 138 | ) 139 | 140 | journal_entry = frappe.new_doc("Journal Entry") 141 | journal_entry.voucher_type = "Bank Entry" 142 | journal_entry.company = company 143 | journal_entry.posting_date = self.date 144 | journal_entry.cheque_date = self.date 145 | journal_entry.cheque_no = self.reference_number 146 | journal_entry.title = self.name 147 | 148 | invoices = split_invoices_based_on_payment_terms( 149 | self.prepare_invoices_to_split(invoices_to_bill), self.company 150 | ) 151 | self.adjust_and_allocate_invoices(invoices, journal_entry, action=_attach_invoice) 152 | 153 | total_allocated_amount = sum(row.allocated_amount for row in invoices) 154 | journal_entry.append( 155 | "accounts", 156 | { 157 | "account": company_account, 158 | "bank_account": self.bank_account, 159 | "credit_in_account_currency": (total_allocated_amount if self.withdrawal > 0 else 0.0), 160 | "debit_in_account_currency": total_allocated_amount if self.deposit > 0 else 0.0, 161 | "cost_center": get_default_cost_center(company), 162 | }, 163 | ) 164 | 165 | journal_entry.submit() 166 | return journal_entry.name 167 | 168 | def make_pe_against_invoices(self, invoices_to_bill: list) -> str: 169 | """Make Payment Entry against multiple invoices.""" 170 | 171 | def _attach_invoice(row: dict, payment_entry: "Document") -> None: 172 | row.reference_doctype = row.voucher_type 173 | row.reference_name = row.voucher_no 174 | payment_entry.append("references", row) 175 | 176 | self.validate_invoices_to_bill(invoices_to_bill) 177 | 178 | bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account") 179 | first_invoice = invoices_to_bill[0] 180 | if first_invoice[DOCTYPE] == "Expense Claim": 181 | from hrms.overrides.employee_payment_entry import get_payment_entry_for_employee 182 | 183 | payment_entry = get_payment_entry_for_employee( 184 | first_invoice[DOCTYPE], 185 | first_invoice[DOCNAME], 186 | party_amount=first_invoice[AMOUNT], 187 | bank_account=bank_account, 188 | ) 189 | else: 190 | payment_entry = get_payment_entry( 191 | first_invoice[DOCTYPE], 192 | first_invoice[DOCNAME], 193 | party_amount=first_invoice[AMOUNT], 194 | bank_account=bank_account, 195 | # make sure return invoice does not cause wrong payment type 196 | # return SI against a deposit should be considered as "Receive" (discount) 197 | # return SI against a withdrawal should be considered as "Pay" (refund) 198 | payment_type="Receive" if self.deposit > 0 else "Pay", 199 | ) 200 | payment_entry.posting_date = self.date 201 | payment_entry.reference_no = self.reference_number or first_invoice[DOCNAME] 202 | payment_entry.reference_date = self.date 203 | 204 | # clear references to allocate invoices correctly with splits 205 | payment_entry.references = [] 206 | invoices = split_invoices_based_on_payment_terms( 207 | self.prepare_invoices_to_split(invoices_to_bill), self.company 208 | ) 209 | self.adjust_and_allocate_invoices(invoices, payment_entry, action=_attach_invoice) 210 | 211 | payment_entry.paid_amount = abs( 212 | sum(row.allocated_amount for row in payment_entry.references) 213 | ) # should not be negative 214 | payment_entry.submit() 215 | return payment_entry.name 216 | 217 | def prepare_invoices_to_split(self, invoices): 218 | invoices_to_split = [] 219 | for invoice in invoices: 220 | is_expense_claim = invoice[DOCTYPE] == "Expense Claim" 221 | total_field = "grand_total" if is_expense_claim else "base_grand_total" 222 | due_date_field = "posting_date" if is_expense_claim else "due_date" 223 | 224 | invoice_data = frappe.db.get_value( 225 | invoice[DOCTYPE], 226 | invoice[DOCNAME], 227 | [ 228 | "name as voucher_no", 229 | "posting_date", 230 | f"{total_field} as invoice_amount", 231 | f"{due_date_field} as due_date", 232 | ], 233 | as_dict=True, 234 | ) 235 | invoice_data["outstanding_amount"] = invoice[AMOUNT] 236 | invoice_data["voucher_type"] = invoice[DOCTYPE] 237 | invoice_data["party"] = invoice[PARTY] 238 | invoice_data["party_type"] = ( 239 | "Customer" 240 | if invoice[DOCTYPE] == "Sales Invoice" 241 | else "Supplier" 242 | if invoice[DOCTYPE] == "Purchase Invoice" 243 | else "Employee" 244 | ) 245 | invoices_to_split.append(invoice_data) 246 | 247 | return invoices_to_split 248 | 249 | def get_positive_and_negative_sums(self, invoices): 250 | """ 251 | Calculate a permissible positive and negative upper limit sum for the allocation. 252 | This will ensure that the allocated positive and negative amounts add up to the unallocated amount. 253 | """ 254 | sum_positive = ( 255 | sum(invoice.outstanding_amount for invoice in invoices if invoice.outstanding_amount > 0) or 0.0 256 | ) 257 | sum_negative = ( 258 | abs(sum(invoice.outstanding_amount for invoice in invoices if invoice.outstanding_amount < 0)) 259 | or 0.0 260 | ) 261 | self.validate_sums(sum_positive, sum_negative, invoices) 262 | 263 | # Adjust the positive sum (trim it) if overallocated 264 | allocation = self.unallocated_amount - (sum_positive - sum_negative) 265 | if allocation < 0: 266 | sum_positive += allocation 267 | 268 | return sum_positive, sum_negative 269 | 270 | def adjust_and_allocate_invoices( 271 | self, 272 | invoices: list, 273 | payment_voucher: "Document", 274 | action: Callable[[dict, Document], None], 275 | ) -> None: 276 | """ 277 | Adjust and allocate the invoicees to the payment voucher based on 278 | the unallocated amount. 279 | The `payment_voucher` object is mutated by param:action. 280 | """ 281 | sum_postive, sum_negative = self.get_positive_and_negative_sums(invoices) 282 | for row in invoices: 283 | if row.outstanding_amount > 0: 284 | if sum_postive <= 0: 285 | continue 286 | row_allocated_amount = min(row.outstanding_amount, sum_postive) 287 | sum_postive -= row_allocated_amount 288 | else: 289 | if sum_negative <= 0: 290 | continue 291 | can_allocate = min(abs(row.outstanding_amount), sum_negative) 292 | row_allocated_amount = -1 * can_allocate 293 | sum_negative -= can_allocate 294 | 295 | row.allocated_amount = row_allocated_amount 296 | # Attach the invoice to/Mutate the payment voucher 297 | action(row, payment_voucher) 298 | 299 | def validate_sums(self, sum_positive, sum_negative, invoices): 300 | """Validate if the sum of positive and negative amounts is equal to the unallocated amount.""" 301 | if sum_positive and not sum_negative: 302 | return 303 | 304 | if sum_negative and not sum_positive: 305 | # Only -ve invoices are allowed for opposite transactions 306 | # Eg. A return SINV can be matched with a withdrawal (it is a refund) 307 | invoice_doctype = "Sales Invoice" if self.deposit > 0 else "Purchase Invoice" 308 | if invoices[0]["voucher_type"] != invoice_doctype: 309 | return 310 | 311 | if sum_negative > sum_positive: 312 | frappe.throw( 313 | title=_("Overallocated Returns"), 314 | msg=_("The allocated amount cannot be negative. Please adjust the selected return vouchers."), 315 | ) 316 | 317 | def validate_invoices_to_bill(self, invoices_to_bill: list, allow_multi_party: bool = False): 318 | """Validate if the invoices are of the same doctype and party.""" 319 | unique_doctypes = {invoice[DOCTYPE] for invoice in invoices_to_bill} 320 | if len(unique_doctypes) > 1: 321 | frappe.throw(frappe._("Cannot make Reconciliation Payment Entry against multiple doctypes")) 322 | 323 | if allow_multi_party: 324 | return 325 | 326 | unique_parties = {invoice[PARTY] for invoice in invoices_to_bill} 327 | if len(unique_parties) > 1: 328 | frappe.throw(frappe._("Cannot make Reconciliation Payment Entry against multiple parties")) 329 | 330 | def is_duplicate_reference(self, voucher_type, voucher_name): 331 | """Check if the reference is already added to the Bank Transaction.""" 332 | return find( 333 | self.payment_entries, 334 | lambda x: x.payment_document == voucher_type and x.payment_entry == voucher_name, 335 | ) 336 | 337 | 338 | def get_outstanding_amount(payment_doctype, payment_name) -> float: 339 | if payment_doctype not in ("Sales Invoice", "Purchase Invoice", "Expense Claim"): 340 | return 0.0 341 | 342 | if payment_doctype == "Expense Claim": 343 | ec = frappe.get_doc(payment_doctype, payment_name) 344 | return flt( 345 | ec.total_sanctioned_amount - ec.total_amount_reimbursed, 346 | ec.precision("total_sanctioned_amount"), 347 | ) 348 | 349 | invoice = frappe.get_doc(payment_doctype, payment_name) 350 | return flt(invoice.outstanding_amount, invoice.precision("outstanding_amount")) 351 | 352 | 353 | def get_debtor_creditor_account(invoice: dict) -> str | None: 354 | """Get the debtor or creditor (intermediate) account based on the invoice type.""" 355 | if invoice.get("voucher_type") == "Sales Invoice": 356 | account_field = "debit_to" 357 | elif invoice.get("voucher_type") == "Purchase Invoice": 358 | account_field = "credit_to" 359 | else: 360 | account_field = "payable_account" 361 | return frappe.db.get_value(invoice.get("voucher_type"), invoice.get("voucher_no"), account_field) 362 | 363 | 364 | def on_update_after_submit(doc, event): 365 | """Validate if the Bank Transaction is over-allocated.""" 366 | to_allocate = flt(doc.withdrawal or doc.deposit) 367 | for entry in doc.payment_entries: 368 | to_allocate -= flt(entry.allocated_amount) 369 | if round(to_allocate, 2) < 0.0: 370 | symbol = frappe.db.get_value("Currency", doc.currency, "symbol") 371 | frappe.throw( 372 | msg=_("The Bank Transaction is over-allocated by {0} at row {1}.").format( 373 | frappe.bold(f"{symbol} {abs(to_allocate)!s}"), frappe.bold(entry.idx) 374 | ), 375 | title=_("Over Allocation"), 376 | ) 377 | -------------------------------------------------------------------------------- /banking/patches.txt: -------------------------------------------------------------------------------- 1 | [pre_model_sync] 2 | banking.patches.recreate_custom_fields 3 | 4 | [post_model_sync] 5 | execute:frappe.delete_doc_if_exists("Notification", "Refresh Bank Consent") 6 | execute:frappe.delete_doc_if_exists("DocType", "Bank Consent") 7 | execute:frappe.delete_doc_if_exists("DocType", "Klarna Kosma Session") 8 | execute:frappe.delete_doc_if_exists("Custom Field", "Bank Account-kosma_account_id") 9 | execute:frappe.delete_doc_if_exists("Custom Field", "Bank Transaction-kosma_party_name") 10 | -------------------------------------------------------------------------------- /banking/patches/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/patches/__init__.py -------------------------------------------------------------------------------- /banking/patches/recreate_custom_fields.py: -------------------------------------------------------------------------------- 1 | from frappe import get_hooks 2 | from frappe.custom.doctype.custom_field.custom_field import create_custom_fields 3 | 4 | 5 | def execute(): 6 | create_custom_fields(get_hooks("alyf_banking_custom_fields")) 7 | -------------------------------------------------------------------------------- /banking/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/public/.gitkeep -------------------------------------------------------------------------------- /banking/public/images/alyf-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/public/images/alyf-logo.png -------------------------------------------------------------------------------- /banking/public/images/bank_reco_tool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/public/images/bank_reco_tool.png -------------------------------------------------------------------------------- /banking/public/images/create_voucher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/public/images/create_voucher.png -------------------------------------------------------------------------------- /banking/public/images/match_transaction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/public/images/match_transaction.png -------------------------------------------------------------------------------- /banking/public/images/update_transaction.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/public/images/update_transaction.gif -------------------------------------------------------------------------------- /banking/public/js/bank_reconciliation_beta/actions_panel/actions_panel_manager.js: -------------------------------------------------------------------------------- 1 | frappe.provide("erpnext.accounts.bank_reconciliation"); 2 | 3 | erpnext.accounts.bank_reconciliation.ActionsPanelManager = class ActionsPanelManager { 4 | constructor(opts) { 5 | Object.assign(this, opts); 6 | this.make(); 7 | } 8 | 9 | make() { 10 | this.init_actions_container(); 11 | this.render_tabs(); 12 | 13 | // Default to last selected tab 14 | this.$actions_container 15 | .find("#" + this.panel_manager.actions_tab) 16 | .trigger("click"); 17 | } 18 | 19 | init_actions_container() { 20 | if (this.$wrapper.find(".actions-panel").length > 0) { 21 | this.$actions_container = this.$wrapper.find(".actions-panel"); 22 | this.$actions_container.empty(); 23 | } else { 24 | this.$actions_container = this.$wrapper 25 | .append( 26 | ` 27 |
28 | ` 29 | ) 30 | .find(".actions-panel"); 31 | } 32 | 33 | this.$actions_container.append(` 34 |
35 | 37 |
38 | 39 |
40 | `); 41 | } 42 | 43 | render_tabs() { 44 | this.tabs_list_ul = this.$actions_container.find(".form-tabs"); 45 | this.$tab_content = this.$actions_container.find(".tab-content"); 46 | 47 | // Remove any listeners from previous tabs 48 | frappe.realtime.off("doc_update"); 49 | 50 | const tabs = [ 51 | { 52 | tab_name: "details", 53 | tab_label: __("Details"), 54 | make_tab: () => { 55 | return new erpnext.accounts.bank_reconciliation.DetailsTab({ 56 | actions_panel: this, 57 | transaction: this.transaction, 58 | panel_manager: this.panel_manager, 59 | }); 60 | }, 61 | }, 62 | { 63 | tab_name: "match_voucher", 64 | tab_label: __("Match Voucher"), 65 | make_tab: () => { 66 | return new erpnext.accounts.bank_reconciliation.MatchTab({ 67 | actions_panel: this, 68 | transaction: this.transaction, 69 | panel_manager: this.panel_manager, 70 | doc: this.doc, 71 | }); 72 | }, 73 | }, 74 | { 75 | tab_name: "create_voucher", 76 | tab_label: __("Create Voucher"), 77 | make_tab: () => { 78 | return new erpnext.accounts.bank_reconciliation.CreateTab({ 79 | actions_panel: this, 80 | transaction: this.transaction, 81 | panel_manager: this.panel_manager, 82 | company: this.doc.company, 83 | }); 84 | }, 85 | }, 86 | ]; 87 | 88 | for (const { tab_name, tab_label, make_tab } of tabs) { 89 | this.add_tab(tab_name, tab_label); 90 | 91 | let $tab_link = this.tabs_list_ul.find(`#${tab_name}-tab`); 92 | $tab_link.on("click", () => { 93 | this.$tab_content.empty(); 94 | make_tab(); 95 | }); 96 | } 97 | } 98 | 99 | add_tab(tab_name, tab_label) { 100 | this.tabs_list_ul.append(` 101 | 109 | `); 110 | } 111 | 112 | after_transaction_reconcile( 113 | message, 114 | with_new_voucher = false, 115 | document_type 116 | ) { 117 | // Actions after a transaction is matched with a voucher 118 | // `with_new_voucher`: If a new voucher was created and reconciled with the transaction 119 | let doc = message; 120 | let unallocated_amount = flt(doc.unallocated_amount); 121 | if (unallocated_amount > 0) { 122 | // if partial update this.transaction, re-click on list row 123 | frappe.show_alert({ 124 | message: with_new_voucher 125 | ? __("Bank Transaction {0} partially reconciled.", [ 126 | this.transaction.name, 127 | ]) 128 | : __("Bank Transaction {0} partially matched.", [ 129 | this.transaction.name, 130 | ]), 131 | indicator: "blue", 132 | }); 133 | this.panel_manager.refresh_transaction(unallocated_amount); 134 | } else { 135 | let alert_string = __("Bank Transaction {0} Matched", [ 136 | this.transaction.name, 137 | ]); 138 | if (with_new_voucher) { 139 | alert_string = __("Bank Transaction {0} reconciled with a new {1}", [ 140 | this.transaction.name, 141 | document_type, 142 | ]); 143 | } 144 | frappe.show_alert({ message: alert_string, indicator: "green" }); 145 | this.panel_manager.move_to_next_transaction(); 146 | } 147 | } 148 | }; 149 | -------------------------------------------------------------------------------- /banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js: -------------------------------------------------------------------------------- 1 | frappe.provide("erpnext.accounts.bank_reconciliation"); 2 | 3 | erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { 4 | constructor(opts) { 5 | Object.assign(this, opts); 6 | this.make(); 7 | } 8 | 9 | make() { 10 | this.panel_manager.actions_tab = "create_voucher-tab"; 11 | 12 | this.create_field_group = new frappe.ui.FieldGroup({ 13 | fields: this.get_create_tab_fields(), 14 | body: this.actions_panel.$tab_content, 15 | card_layout: true, 16 | }); 17 | this.create_field_group.make(); 18 | } 19 | 20 | create_voucher() { 21 | var me = this; 22 | let values = this.create_field_group.get_values(); 23 | let document_type = values.document_type; 24 | 25 | // Create new voucher and delete or refresh current BT row depending on reconciliation 26 | this.create_voucher_bts(false, (message) => 27 | me.actions_panel.after_transaction_reconcile(message, true, document_type) 28 | ); 29 | } 30 | 31 | edit_in_full_page() { 32 | this.create_voucher_bts(true, (message) => { 33 | const doc = frappe.model.sync(message); 34 | let doctype = doc[0].doctype, 35 | docname = doc[0].name; 36 | 37 | // Reconcile and update the view 38 | // when the voucher is submitted in another tab 39 | frappe.socketio.doc_subscribe(doctype, docname); 40 | frappe.realtime.off("doc_update"); 41 | frappe.realtime.on("doc_update", (data) => { 42 | if (data.doctype === doctype && data.name === docname) { 43 | this.reconcile_new_voucher(doctype, docname); 44 | } 45 | }); 46 | 47 | frappe.open_in_new_tab = true; 48 | frappe.set_route("Form", doctype, docname); 49 | }); 50 | } 51 | 52 | create_voucher_bts(allow_edit = false, success_callback) { 53 | // Create PE or JV and run `success_callback` 54 | let values = this.create_field_group.get_values(); 55 | let document_type = values.document_type; 56 | let method = 57 | "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta"; 58 | let args = { 59 | bank_transaction_name: this.transaction.name, 60 | reference_number: values.reference_number, 61 | reference_date: values.reference_date, 62 | party_type: values.party_type, 63 | party: values.party, 64 | posting_date: values.posting_date, 65 | mode_of_payment: values.mode_of_payment, 66 | allow_edit: allow_edit, 67 | }; 68 | 69 | if (document_type === "Payment Entry") { 70 | method = method + ".create_payment_entry_bts"; 71 | args = { 72 | ...args, 73 | project: values.project, 74 | cost_center: values.cost_center, 75 | }; 76 | } else { 77 | method = method + ".create_journal_entry_bts"; 78 | args = { 79 | ...args, 80 | entry_type: values.journal_entry_type, 81 | second_account: values.second_account, 82 | }; 83 | } 84 | 85 | frappe.call({ 86 | method: method, 87 | args: args, 88 | callback: (response) => { 89 | if (response.exc) { 90 | frappe.show_alert({ 91 | message: __("Failed to create {0} against {1}", [ 92 | document_type, 93 | this.transaction.name, 94 | ]), 95 | indicator: "red", 96 | }); 97 | return; 98 | } else if (response.message) { 99 | success_callback(response.message); 100 | } 101 | }, 102 | }); 103 | } 104 | 105 | reconcile_new_voucher(doctype, docname) { 106 | // If no response, newly created doc is in draft state 107 | // If deleted in response, newly created doc is deleted 108 | // If doc object in response, newly created doc is submitted (can be reconciled) 109 | var me = this; 110 | frappe.call({ 111 | method: 112 | "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.reconcile_voucher", 113 | args: { 114 | transaction_name: this.transaction.name, 115 | amount: this.transaction.unallocated_amount, 116 | voucher_type: doctype, 117 | voucher_name: docname, 118 | }, 119 | callback: (response) => { 120 | if (response.exc) { 121 | frappe.show_alert({ 122 | message: __("Failed to reconcile new {0} against {1}", [ 123 | doctype, 124 | me.transaction.name, 125 | ]), 126 | indicator: "red", 127 | }); 128 | return; 129 | } else if ( 130 | response.message && 131 | Object.keys(response.message).length > 0 132 | ) { 133 | if (response.message.deleted) { 134 | frappe.realtime.off("doc_update"); 135 | return; 136 | } 137 | 138 | me.actions_panel.after_transaction_reconcile( 139 | response.message, 140 | true, 141 | doctype 142 | ); 143 | } 144 | }, 145 | }); 146 | } 147 | 148 | get_create_tab_fields() { 149 | let party_type = 150 | this.transaction.party_type || 151 | (flt(this.transaction.withdrawal) > 0 ? "Supplier" : "Customer"); 152 | return [ 153 | { 154 | label: __("Document Type"), 155 | fieldname: "document_type", 156 | fieldtype: "Select", 157 | options: `Payment Entry\nJournal Entry`, 158 | default: "Payment Entry", 159 | onchange: () => { 160 | let value = this.create_field_group.get_value("document_type"); 161 | let fields = this.create_field_group; 162 | 163 | fields.get_field("party").df.reqd = value === "Payment Entry"; 164 | fields.get_field("party_type").df.reqd = value === "Payment Entry"; 165 | fields.get_field("journal_entry_type").df.reqd = 166 | value === "Journal Entry"; 167 | fields.get_field("second_account").df.reqd = 168 | value === "Journal Entry"; 169 | 170 | this.create_field_group.refresh(); 171 | }, 172 | }, 173 | { 174 | fieldtype: "Section Break", 175 | fieldname: "details", 176 | label: "Details", 177 | }, 178 | { 179 | fieldname: "reference_number", 180 | fieldtype: "Data", 181 | label: __("Reference Number"), 182 | default: 183 | this.transaction.reference_number || 184 | (this.transaction.description 185 | ? this.transaction.description.slice(0, 140) 186 | : ""), 187 | }, 188 | { 189 | fieldname: "posting_date", 190 | fieldtype: "Date", 191 | label: __("Posting Date"), 192 | reqd: 1, 193 | default: this.transaction.date, 194 | }, 195 | { 196 | fieldname: "reference_date", 197 | fieldtype: "Date", 198 | label: __("Cheque/Reference Date"), 199 | reqd: 1, 200 | default: this.transaction.date, 201 | }, 202 | { 203 | fieldname: "mode_of_payment", 204 | fieldtype: "Link", 205 | label: __("Mode of Payment"), 206 | options: "Mode of Payment", 207 | }, 208 | { 209 | fieldname: "edit_in_full_page", 210 | fieldtype: "Button", 211 | label: __("Edit in Full Page"), 212 | click: () => { 213 | this.edit_in_full_page(); 214 | }, 215 | }, 216 | { 217 | fieldname: "column_break_7", 218 | fieldtype: "Column Break", 219 | }, 220 | { 221 | label: __("Journal Entry Type"), 222 | fieldname: "journal_entry_type", 223 | fieldtype: "Select", 224 | options: `Bank Entry\nJournal Entry\nInter Company Journal Entry\nCash Entry\nCredit Card Entry\nDebit Note\nCredit Note\nContra Entry\nExcise Entry\nWrite Off Entry\nOpening Entry\nDepreciation Entry\nExchange Rate Revaluation\nDeferred Revenue\nDeferred Expense`, 225 | default: "Bank Entry", 226 | depends_on: "eval: doc.document_type == 'Journal Entry'", 227 | }, 228 | { 229 | fieldname: "second_account", 230 | fieldtype: "Link", 231 | label: "Account", 232 | options: "Account", 233 | get_query: () => { 234 | return { 235 | filters: { 236 | is_group: 0, 237 | company: this.company, 238 | }, 239 | }; 240 | }, 241 | depends_on: "eval: doc.document_type == 'Journal Entry'", 242 | }, 243 | { 244 | fieldname: "party_type", 245 | fieldtype: "Link", 246 | label: "Party Type", 247 | options: "DocType", 248 | reqd: 1, 249 | default: party_type, 250 | get_query: function () { 251 | return { 252 | filters: { 253 | name: ["in", Object.keys(frappe.boot.party_account_types)], 254 | }, 255 | }; 256 | }, 257 | onchange: () => { 258 | let value = this.create_field_group.get_value("party_type"); 259 | this.create_field_group.get_field("party").df.options = value; 260 | }, 261 | }, 262 | { 263 | fieldname: "party", 264 | fieldtype: "Link", 265 | label: "Party", 266 | default: this.transaction.party, 267 | options: party_type, 268 | reqd: 1, 269 | }, 270 | { 271 | fieldname: "project", 272 | fieldtype: "Link", 273 | label: "Project", 274 | options: "Project", 275 | depends_on: "eval: doc.document_type == 'Payment Entry'", 276 | }, 277 | { 278 | fieldname: "cost_center", 279 | fieldtype: "Link", 280 | label: "Cost Center", 281 | options: "Cost Center", 282 | depends_on: "eval: doc.document_type == 'Payment Entry'", 283 | }, 284 | { 285 | fieldtype: "Section Break", 286 | }, 287 | { 288 | label: __("Hidden field for alignment"), 289 | fieldname: "hidden_field", 290 | fieldtype: "Data", 291 | hidden: 1, 292 | }, 293 | { 294 | fieldtype: "Column Break", 295 | }, 296 | { 297 | label: __("Create"), 298 | fieldtype: "Button", 299 | primary: true, 300 | click: () => this.create_voucher(), 301 | }, 302 | ]; 303 | } 304 | }; 305 | -------------------------------------------------------------------------------- /banking/public/js/bank_reconciliation_beta/actions_panel/details_tab.js: -------------------------------------------------------------------------------- 1 | frappe.provide("erpnext.accounts.bank_reconciliation"); 2 | 3 | erpnext.accounts.bank_reconciliation.DetailsTab = class DetailsTab { 4 | constructor(opts) { 5 | $.extend(this, opts); 6 | this.make(); 7 | } 8 | 9 | make() { 10 | this.panel_manager.actions_tab = "details-tab"; 11 | 12 | this.details_field_group = new frappe.ui.FieldGroup({ 13 | fields: this.get_detail_tab_fields(), 14 | body: this.actions_panel.$tab_content, 15 | card_layout: true, 16 | }); 17 | this.details_field_group.make(); 18 | } 19 | 20 | update_bank_transaction() { 21 | var me = this; 22 | const reference_number = 23 | this.details_field_group.get_value("reference_number"); 24 | const party = this.details_field_group.get_value("party"); 25 | const party_type = this.details_field_group.get_value("party_type"); 26 | 27 | let diff = ["reference_number", "party", "party_type"].some((field) => { 28 | return me.details_field_group.get_value(field) !== me.transaction[field]; 29 | }); 30 | if (!diff) { 31 | frappe.show_alert({ 32 | message: __("No changes to update"), 33 | indicator: "yellow", 34 | }); 35 | return; 36 | } 37 | 38 | frappe.call({ 39 | method: 40 | "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.update_bank_transaction", 41 | args: { 42 | bank_transaction_name: me.transaction.name, 43 | reference_number: reference_number, 44 | party_type: party_type, 45 | party: party, 46 | }, 47 | freeze: true, 48 | freeze_message: __("Updating ..."), 49 | callback: (response) => { 50 | if (response.exc) { 51 | frappe.show_alert(__("Failed to update {0}", [me.transaction.name])); 52 | return; 53 | } 54 | 55 | // Update transaction 56 | me.panel_manager.refresh_transaction( 57 | null, 58 | reference_number, 59 | party_type, 60 | party 61 | ); 62 | 63 | frappe.show_alert( 64 | __("Bank Transaction {0} updated", [me.transaction.name]) 65 | ); 66 | }, 67 | }); 68 | } 69 | 70 | get_detail_tab_fields() { 71 | return [ 72 | { 73 | label: __("ID"), 74 | fieldname: "name", 75 | fieldtype: "Link", 76 | options: "Bank Transaction", 77 | default: this.transaction.name, 78 | read_only: 1, 79 | }, 80 | { 81 | label: __("Date"), 82 | fieldname: "date", 83 | fieldtype: "Date", 84 | default: this.transaction.date, 85 | read_only: 1, 86 | }, 87 | { 88 | label: __("Deposit"), 89 | fieldname: "deposit", 90 | fieldtype: "Currency", 91 | options: "account_currency", 92 | default: this.transaction.deposit, 93 | read_only: 1, 94 | }, 95 | { 96 | label: __("Withdrawal"), 97 | fieldname: "withdrawal", 98 | fieldtype: "Currency", 99 | options: "account_currency", 100 | default: this.transaction.withdrawal, 101 | read_only: 1, 102 | }, 103 | { 104 | fieldtype: "Column Break", 105 | }, 106 | { 107 | label: __("Description"), 108 | fieldname: "description", 109 | fieldtype: "Small Text", 110 | default: this.transaction.description, 111 | read_only: 1, 112 | }, 113 | { 114 | label: __("To Allocate"), 115 | fieldname: "unallocated_amount", 116 | fieldtype: "Currency", 117 | options: "account_currency", 118 | default: this.transaction.unallocated_amount, 119 | read_only: 1, 120 | }, 121 | { 122 | label: __("Currency"), 123 | fieldname: "account_currency", 124 | fieldtype: "Link", 125 | options: "Currency", 126 | read_only: 1, 127 | default: this.transaction.currency, 128 | hidden: 1, 129 | }, 130 | { 131 | label: __("Account Holder"), 132 | fieldname: "account", 133 | fieldtype: "Data", 134 | default: this.transaction.bank_party_name, 135 | read_only: 1, 136 | hidden: this.transaction.bank_party_name ? 0 : 1, 137 | }, 138 | { 139 | label: __("Party Account Number"), 140 | fieldname: "account_number", 141 | fieldtype: "Data", 142 | default: this.transaction.bank_party_account_number, 143 | read_only: 1, 144 | hidden: this.transaction.bank_party_account_number ? 0 : 1, 145 | }, 146 | { 147 | label: __("Party IBAN"), 148 | fieldname: "iban", 149 | fieldtype: "Data", 150 | default: this.transaction.bank_party_iban, 151 | read_only: 1, 152 | hidden: this.transaction.bank_party_iban ? 0 : 1, 153 | }, 154 | { 155 | label: __("Update"), 156 | fieldtype: "Section Break", 157 | fieldname: "update_section", 158 | }, 159 | { 160 | label: __("Reference Number"), 161 | fieldname: "reference_number", 162 | fieldtype: "Data", 163 | default: this.transaction.reference_number, 164 | }, 165 | { 166 | fieldtype: "Column Break", 167 | }, 168 | { 169 | label: __("Party Type"), 170 | fieldname: "party_type", 171 | fieldtype: "Link", 172 | options: "DocType", 173 | get_query: function () { 174 | return { 175 | filters: { 176 | name: ["in", Object.keys(frappe.boot.party_account_types)], 177 | }, 178 | }; 179 | }, 180 | onchange: () => { 181 | let value = this.details_field_group.get_value("party_type"); 182 | this.details_field_group.get_field("party").df.options = value; 183 | }, 184 | default: this.transaction.party_type || null, 185 | }, 186 | { 187 | label: __("Party"), 188 | fieldname: "party", 189 | fieldtype: "Link", 190 | default: this.transaction.party, 191 | options: this.transaction.party_type || null, 192 | }, 193 | { 194 | fieldtype: "Section Break", 195 | }, 196 | { 197 | label: __("Hidden field for alignment"), 198 | fieldname: "hidden_field", 199 | fieldtype: "Data", 200 | hidden: 1, 201 | }, 202 | { 203 | fieldtype: "Column Break", 204 | }, 205 | { 206 | label: __("Submit"), 207 | fieldname: "submit_transaction", 208 | fieldtype: "Button", 209 | primary: true, 210 | click: () => this.update_bank_transaction(), 211 | }, 212 | ]; 213 | } 214 | }; 215 | -------------------------------------------------------------------------------- /banking/public/js/bank_reconciliation_beta/actions_panel/match_tab.js: -------------------------------------------------------------------------------- 1 | frappe.provide("erpnext.accounts.bank_reconciliation"); 2 | 3 | erpnext.accounts.bank_reconciliation.MatchTab = class MatchTab { 4 | constructor(opts) { 5 | $.extend(this, opts); 6 | this.make(); 7 | } 8 | 9 | async make() { 10 | this.panel_manager.actions_tab = "match_voucher-tab"; 11 | 12 | this.match_field_group = new frappe.ui.FieldGroup({ 13 | fields: this.get_match_tab_fields(), 14 | body: this.actions_panel.$tab_content, 15 | card_layout: true, 16 | }); 17 | this.match_field_group.make(); 18 | 19 | await this.populate_matching_vouchers(); 20 | } 21 | 22 | summary_empty_state() { 23 | this.render_transaction_amount_summary(0, 0, 0, this.transaction.currency); 24 | } 25 | 26 | async populate_matching_vouchers(event_obj) { 27 | if (event_obj && event_obj.type === "input") { 28 | // `bind_change_event` in `data.js` triggers both an input and change event 29 | // This triggers the `populate_matching_vouchers` twice on clicking on filters 30 | // Since the input event is debounced, we can ignore it for a checkbox 31 | return; 32 | } 33 | 34 | this.summary_empty_state(); 35 | this.render_data_table(); 36 | this.actions_table.freeze(); 37 | 38 | let filter_fields = this.match_field_group.get_values(); 39 | let document_types = Object.keys(filter_fields).filter( 40 | (field) => filter_fields[field] === 1 41 | ); 42 | 43 | this.update_filters_in_state(document_types); 44 | 45 | let vouchers = await this.get_matching_vouchers(document_types); 46 | this.set_table_data(vouchers); 47 | this.actions_table.unfreeze(); 48 | 49 | let transaction_amount = 50 | this.transaction.withdrawal || this.transaction.deposit; 51 | this.render_transaction_amount_summary( 52 | flt(transaction_amount), 53 | flt(this.transaction.unallocated_amount), 54 | flt(this.transaction.unallocated_amount), 55 | this.transaction.currency 56 | ); 57 | } 58 | 59 | update_filters_in_state(document_types) { 60 | Object.keys(this.panel_manager.actions_filters).map((key) => { 61 | let value = document_types.includes(key) ? 1 : 0; 62 | this.panel_manager.actions_filters[key] = value; 63 | }); 64 | } 65 | 66 | async get_matching_vouchers(document_types) { 67 | let vouchers = await frappe 68 | .call({ 69 | method: 70 | "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.get_linked_payments", 71 | args: { 72 | bank_transaction_name: this.transaction.name, 73 | document_types: document_types, 74 | from_date: this.doc.bank_statement_from_date, 75 | to_date: this.doc.bank_statement_to_date, 76 | filter_by_reference_date: this.doc.filter_by_reference_date, 77 | from_reference_date: this.doc.from_reference_date, 78 | to_reference_date: this.doc.to_reference_date, 79 | }, 80 | }) 81 | .then((result) => result.message); 82 | return vouchers || []; 83 | } 84 | 85 | render_data_table() { 86 | const datatable_options = { 87 | columns: this.get_data_table_columns(), 88 | data: [], 89 | dynamicRowHeight: true, 90 | checkboxColumn: true, 91 | inlineFilters: true, 92 | layout: "fluid", 93 | serialNoColumn: false, 94 | freezeMessage: __("Loading..."), 95 | }; 96 | 97 | this.actions_table = new frappe.DataTable( 98 | this.match_field_group.get_field("vouchers").$wrapper[0], 99 | datatable_options 100 | ); 101 | 102 | this.bind_row_check_event(); 103 | } 104 | 105 | set_table_data(vouchers) { 106 | this.summary_data = {}; 107 | let table_data = vouchers.map((row) => { 108 | return [ 109 | { 110 | content: row.reference_date || row.posting_date, // Reference Date 111 | format: (value) => { 112 | const formatted_date = frappe.format(value, { 113 | fieldtype: "Date", 114 | }); 115 | return row.date_match ? formatted_date.bold() : formatted_date; 116 | }, 117 | }, 118 | { 119 | content: row.paid_amount, 120 | format: (value) => { 121 | let formatted_value = format_currency(value, row.currency); 122 | let match_condition = 123 | row.amount_match || row.unallocated_amount_match; 124 | return match_condition ? formatted_value.bold() : formatted_value; 125 | }, 126 | }, 127 | { 128 | content: row.party, 129 | format: (value) => { 130 | if (row.party_name) { 131 | frappe.utils.add_link_title( 132 | row.party_type, 133 | row.party, 134 | row.party_name 135 | ); 136 | } 137 | let formatted_value = frappe.format(value, { 138 | fieldtype: "Link", 139 | options: row.party_type, 140 | }); 141 | return row.party_match ? formatted_value.bold() : formatted_value; 142 | }, 143 | }, 144 | { 145 | content: row.name, 146 | format: (value) => { 147 | let formatted_value = frappe.format(value, { 148 | fieldtype: "Link", 149 | options: row.doctype, 150 | }); 151 | return row.name_in_desc_match 152 | ? formatted_value.bold() 153 | : formatted_value; 154 | }, 155 | doctype: row.doctype, 156 | }, 157 | { 158 | content: row.reference_no || "", 159 | format: (value) => { 160 | let reference_match = 161 | row.reference_number_match || row.ref_in_desc_match; 162 | return reference_match ? value.bold() : value; 163 | }, 164 | }, 165 | ]; 166 | }); 167 | 168 | this.actions_table.refresh(table_data, this.get_data_table_columns()); 169 | } 170 | 171 | bind_row_check_event() { 172 | // Resistant to row removal on being out of view in datatable 173 | $(this.actions_table.bodyScrollable).on( 174 | "click", 175 | ".dt-cell__content input", 176 | (e) => { 177 | let idx = $(e.currentTarget).closest(".dt-cell").data().rowIndex; 178 | let voucher_row = this.actions_table.getRows()[idx]; 179 | 180 | this.check_data_table_row(voucher_row); 181 | } 182 | ); 183 | } 184 | 185 | check_data_table_row(row) { 186 | if (!row) return; 187 | 188 | let id = row[this.position_of("Voucher")].content; 189 | let value = this.get_amount_from_row(row); 190 | 191 | // If `id` in summary_data, remove it (row was unchecked), else add it 192 | if (id in this.summary_data) { 193 | delete this.summary_data[id]; 194 | } else { 195 | this.summary_data[id] = value; 196 | } 197 | 198 | // Total of selected row amounts in summary_data 199 | // Cap total_allocated to unallocated amount 200 | let total_allocated = Object.values(this.summary_data).reduce( 201 | (a, b) => a + b, 202 | 0 203 | ); 204 | let max_allocated = Math.min( 205 | total_allocated, 206 | this.transaction.unallocated_amount 207 | ); 208 | 209 | // Deduct allocated amount from transaction's unallocated amount 210 | // to show the final effect on reconciling 211 | let transaction_amount = 212 | this.transaction.withdrawal || this.transaction.deposit; 213 | let unallocated = 214 | flt(this.transaction.unallocated_amount) - flt(max_allocated); 215 | let actual_unallocated = 216 | flt(this.transaction.unallocated_amount) - flt(total_allocated); 217 | 218 | this.render_transaction_amount_summary( 219 | flt(transaction_amount), 220 | unallocated, 221 | actual_unallocated, 222 | this.transaction.currency 223 | ); 224 | } 225 | 226 | render_transaction_amount_summary( 227 | total_amount, 228 | unallocated_amount, 229 | actual_unallocated, 230 | currency 231 | ) { 232 | let summary_field = this.match_field_group.get_field( 233 | "transaction_amount_summary" 234 | ).$wrapper; 235 | summary_field.empty(); 236 | 237 | // Show the actual allocated amount 238 | let allocated_amount = flt(total_amount) - flt(unallocated_amount); 239 | 240 | new erpnext.accounts.bank_reconciliation.SummaryCard({ 241 | $wrapper: summary_field, 242 | values: { 243 | Amount: [total_amount], 244 | "Allocated Amount": [allocated_amount, ""], 245 | "To Allocate": [ 246 | unallocated_amount, 247 | unallocated_amount < 0 248 | ? "text-danger" 249 | : unallocated_amount > 0 250 | ? "text-blue" 251 | : "text-success", 252 | actual_unallocated, 253 | ], 254 | }, 255 | currency: currency, 256 | wrapper_class: "reconciliation-summary", 257 | }); 258 | } 259 | 260 | reconcile_selected_vouchers() { 261 | const me = this; 262 | let selected_vouchers = []; 263 | let selected_map = this.actions_table.rowmanager.checkMap; 264 | let voucher_rows = this.actions_table.getRows(); 265 | 266 | selected_map.forEach((value, idx) => { 267 | if (value === 1) { 268 | const row = voucher_rows[idx]; 269 | selected_vouchers.push({ 270 | payment_doctype: row[this.position_of("Voucher")].doctype, 271 | payment_name: row[this.position_of("Voucher")].content, 272 | amount: this.get_amount_from_row(row), 273 | party: row[this.position_of("Party")].content, 274 | }); 275 | } 276 | }); 277 | 278 | if (!selected_vouchers.length > 0) { 279 | frappe.show_alert({ 280 | message: __("Please select at least one voucher to reconcile"), 281 | indicator: "red", 282 | }); 283 | return; 284 | } 285 | 286 | let voucher_types = new Set( 287 | selected_vouchers.map((voucher) => voucher.payment_doctype) 288 | ); 289 | if (voucher_types.size > 1) { 290 | frappe.show_alert({ 291 | message: __("Please select vouchers of the same type to reconcile"), 292 | indicator: "red", 293 | }); 294 | return; 295 | } 296 | 297 | // If the vouchers have different parties prepare a prompt to reconcile multi-party 298 | let parties = new Set(selected_vouchers.map((voucher) => voucher.party)); 299 | if (parties.size > 1) { 300 | this.show_multiple_party_reconcile_prompt(selected_vouchers); 301 | } else { 302 | this.bulk_reconcile_vouchers(selected_vouchers, false); 303 | } 304 | } 305 | 306 | bulk_reconcile_vouchers(selected_vouchers, reconcile_multi_party) { 307 | let me = this; 308 | frappe.call({ 309 | method: 310 | "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.bulk_reconcile_vouchers", 311 | args: { 312 | bank_transaction_name: this.transaction.name, 313 | vouchers: selected_vouchers, 314 | reconcile_multi_party: reconcile_multi_party, 315 | }, 316 | freeze: true, 317 | freeze_message: __("Reconciling ..."), 318 | callback: (response) => { 319 | if (response.exc) { 320 | frappe.show_alert({ 321 | message: __("Failed to reconcile {0}", [this.transaction.name]), 322 | indicator: "red", 323 | }); 324 | return; 325 | } 326 | 327 | me.actions_panel.after_transaction_reconcile(response.message, false); 328 | }, 329 | }); 330 | } 331 | 332 | show_multiple_party_reconcile_prompt(selected_vouchers) { 333 | frappe.confirm( 334 | __( 335 | "Are you trying to reconcile vouchers of different parties? This action will reconcile vouchers using a Journal Entry." 336 | ), 337 | () => { 338 | this.bulk_reconcile_vouchers(selected_vouchers, true); 339 | } 340 | ); 341 | } 342 | 343 | get_match_tab_fields() { 344 | const filters_state = this.panel_manager.actions_filters; 345 | return [ 346 | { 347 | label: __("Payment Entry"), 348 | fieldname: "payment_entry", 349 | fieldtype: "Check", 350 | default: filters_state.payment_entry, 351 | onchange: (e) => { 352 | this.populate_matching_vouchers(e); 353 | }, 354 | }, 355 | { 356 | label: __("Journal Entry"), 357 | fieldname: "journal_entry", 358 | fieldtype: "Check", 359 | default: filters_state.journal_entry, 360 | onchange: (e) => { 361 | this.populate_matching_vouchers(e); 362 | }, 363 | }, 364 | { 365 | fieldtype: "Column Break", 366 | }, 367 | { 368 | label: __("Purchase Invoice"), 369 | fieldname: "purchase_invoice", 370 | fieldtype: "Check", 371 | default: filters_state.purchase_invoice, 372 | onchange: (e) => { 373 | this.populate_matching_vouchers(e); 374 | }, 375 | }, 376 | { 377 | label: __("Sales Invoice"), 378 | fieldname: "sales_invoice", 379 | fieldtype: "Check", 380 | default: filters_state.sales_invoice, 381 | onchange: (e) => { 382 | this.populate_matching_vouchers(e); 383 | }, 384 | }, 385 | { 386 | fieldtype: "Column Break", 387 | }, 388 | { 389 | label: __("Loan Repayment"), 390 | fieldname: "loan_repayment", 391 | fieldtype: "Check", 392 | default: filters_state.loan_repayment, 393 | onchange: (e) => { 394 | this.populate_matching_vouchers(e); 395 | }, 396 | }, 397 | { 398 | label: __("Loan Disbursement"), 399 | fieldname: "loan_disbursement", 400 | fieldtype: "Check", 401 | default: filters_state.loan_disbursement, 402 | onchange: (e) => { 403 | this.populate_matching_vouchers(e); 404 | }, 405 | }, 406 | { 407 | fieldtype: "Column Break", 408 | }, 409 | { 410 | label: __("Expense Claim"), 411 | fieldname: "expense_claim", 412 | fieldtype: "Check", 413 | default: filters_state.expense_claim, 414 | onchange: (e) => { 415 | this.populate_matching_vouchers(e); 416 | }, 417 | }, 418 | { 419 | label: __("Bank Transaction"), 420 | fieldname: "bank_transaction", 421 | fieldtype: "Check", 422 | default: filters_state.bank_transaction, 423 | onchange: (e) => { 424 | this.populate_matching_vouchers(e); 425 | }, 426 | }, 427 | { 428 | fieldtype: "Section Break", 429 | }, 430 | { 431 | label: __("Show Exact Amount"), 432 | fieldname: "exact_match", 433 | fieldtype: "Check", 434 | default: filters_state.exact_match, 435 | onchange: (e) => { 436 | this.populate_matching_vouchers(e); 437 | }, 438 | }, 439 | { 440 | fieldtype: "Column Break", 441 | }, 442 | { 443 | label: __("Show Exact Party"), 444 | fieldname: "exact_party_match", 445 | fieldtype: "Check", 446 | default: this.transaction.party_type && this.transaction.party ? 1 : 0, 447 | onchange: (e) => { 448 | this.populate_matching_vouchers(e); 449 | }, 450 | read_only: !Boolean( 451 | this.transaction.party_type && this.transaction.party 452 | ), 453 | }, 454 | { 455 | fieldtype: "Column Break", 456 | }, 457 | { 458 | label: __("Unpaid Vouchers"), 459 | fieldname: "unpaid_invoices", 460 | fieldtype: "Check", 461 | default: filters_state.unpaid_invoices, 462 | onchange: (e) => { 463 | this.populate_matching_vouchers(e); 464 | }, 465 | depends_on: 466 | "eval: doc.sales_invoice || doc.purchase_invoice || doc.expense_claim", 467 | }, 468 | { 469 | fieldtype: "Column Break", 470 | }, 471 | { 472 | fieldtype: "Section Break", 473 | }, 474 | { 475 | fieldname: "transaction_amount_summary", 476 | fieldtype: "HTML", 477 | }, 478 | { 479 | fieldname: "vouchers", 480 | fieldtype: "HTML", 481 | }, 482 | { 483 | fieldtype: "Section Break", 484 | fieldname: "section_break_reconcile", 485 | hide_border: 1, 486 | }, 487 | { 488 | label: __("Hidden field for alignment"), 489 | fieldname: "hidden_field_2", 490 | fieldtype: "Data", 491 | hidden: 1, 492 | }, 493 | { 494 | fieldtype: "Column Break", 495 | }, 496 | { 497 | label: __("Reconcile"), 498 | fieldname: "bt_reconcile", 499 | fieldtype: "Button", 500 | primary: true, 501 | click: () => { 502 | this.reconcile_selected_vouchers(); 503 | }, 504 | }, 505 | ]; 506 | } 507 | 508 | position_of(label) { 509 | // NOTE: Edit this function if the order of columns in the data table changes 510 | const column_positions = { 511 | Date: 1, 512 | Outstanding: 2, 513 | Party: 3, 514 | Voucher: 4, 515 | Reference: 5, 516 | }; 517 | return column_positions[label]; 518 | } 519 | 520 | get_data_table_columns() { 521 | return [ 522 | { 523 | name: __("Date"), 524 | editable: false, 525 | }, 526 | { 527 | name: __("Outstanding"), 528 | editable: false, 529 | }, 530 | { 531 | name: __("Party"), 532 | editable: false, 533 | align: "left", 534 | }, 535 | { 536 | name: __("Voucher"), 537 | editable: false, 538 | align: "left", 539 | }, 540 | { 541 | name: __("Reference"), 542 | editable: false, 543 | align: "left", 544 | }, 545 | ]; 546 | } 547 | 548 | get_amount_from_row(row) { 549 | const amount_position = this.position_of("Outstanding"); 550 | return row[amount_position].content; // Amount 551 | } 552 | }; 553 | -------------------------------------------------------------------------------- /banking/public/js/bank_reconciliation_beta/bank_reconciliation_beta.bundle.js: -------------------------------------------------------------------------------- 1 | import "./panel_manager"; 2 | 3 | import "./actions_panel/actions_panel_manager"; 4 | import "./actions_panel/create_tab"; 5 | import "./actions_panel/details_tab"; 6 | import "./actions_panel/match_tab"; 7 | 8 | import "./summary_number_card"; 9 | -------------------------------------------------------------------------------- /banking/public/js/bank_reconciliation_beta/panel_manager.js: -------------------------------------------------------------------------------- 1 | frappe.provide("erpnext.accounts.bank_reconciliation"); 2 | 3 | erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { 4 | constructor(opts) { 5 | Object.assign(this, opts); 6 | this.make(); 7 | } 8 | 9 | make() { 10 | this.init_panels(); 11 | } 12 | 13 | async init_panels() { 14 | this.transactions = await this.get_bank_transactions(); 15 | 16 | this.$wrapper.empty(); 17 | this.$panel_wrapper = this.$wrapper 18 | .append( 19 | ` 20 |
21 | ` 22 | ) 23 | .find(".panel-container"); 24 | 25 | this.render_panels(); 26 | } 27 | 28 | async get_bank_transactions() { 29 | let transactions = await frappe 30 | .call({ 31 | method: 32 | "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.get_bank_transactions", 33 | args: { 34 | bank_account: this.doc.bank_account, 35 | from_date: this.doc.bank_statement_from_date, 36 | to_date: this.doc.bank_statement_to_date, 37 | order_by: this.order || "date asc", 38 | }, 39 | freeze: true, 40 | freeze_message: __("Fetching Bank Transactions"), 41 | }) 42 | .then((response) => response.message); 43 | return transactions; 44 | } 45 | 46 | render_panels() { 47 | this.set_actions_panel_default_states(); 48 | 49 | if (!this.transactions || !this.transactions.length) { 50 | this.render_no_transactions(); 51 | } else { 52 | this.render_list_panel(); 53 | 54 | let first_transaction = this.transactions[0]; 55 | this.$list_container.find("#" + first_transaction.name).click(); 56 | } 57 | } 58 | 59 | set_actions_panel_default_states() { 60 | // Init actions panel states to store for persistent views 61 | this.actions_tab = "match_voucher-tab"; 62 | this.actions_filters = { 63 | payment_entry: 0, 64 | journal_entry: 0, 65 | purchase_invoice: 1, 66 | sales_invoice: 1, 67 | loan_repayment: 0, 68 | loan_disbursement: 0, 69 | expense_claim: 0, 70 | bank_transaction: 0, 71 | exact_match: 0, 72 | exact_party_match: 0, 73 | unpaid_invoices: 1, 74 | }; 75 | } 76 | 77 | render_no_transactions() { 78 | this.$panel_wrapper.empty(); 79 | this.$panel_wrapper.append(` 80 |
81 | Empty State 82 |

${__("No Transactions found for the current filters.")}

83 |
84 | `); 85 | } 86 | 87 | render_list_panel() { 88 | this.$panel_wrapper.append(` 89 |
90 |
91 |
92 |
93 | `); 94 | 95 | this.render_sort_area(); 96 | this.render_transactions_list(); 97 | } 98 | 99 | render_actions_panel() { 100 | this.actions_panel = 101 | new erpnext.accounts.bank_reconciliation.ActionsPanelManager({ 102 | $wrapper: this.$panel_wrapper, 103 | transaction: this.active_transaction, 104 | doc: this.doc, 105 | panel_manager: this, 106 | }); 107 | } 108 | 109 | render_sort_area() { 110 | this.$sort_area = this.$panel_wrapper.find(".sort-by"); 111 | this.$sort_area.append(` 112 |
${__("Sort By")}
113 |
114 | `); 115 | 116 | var me = this; 117 | new frappe.ui.SortSelector({ 118 | parent: me.$sort_area.find(".sort-by-selector"), 119 | args: { 120 | sort_by: me.order_by || "date", 121 | sort_order: me.order_direction || "asc", 122 | options: [ 123 | { fieldname: "date", label: __("Date") }, 124 | { fieldname: "withdrawal", label: __("Withdrawal") }, 125 | { fieldname: "deposit", label: __("Deposit") }, 126 | { 127 | fieldname: "unallocated_amount", 128 | label: __("Unallocated Amount"), 129 | }, 130 | ], 131 | }, 132 | change: function (sort_by, sort_order) { 133 | // Globally set the order used in the re-rendering of the list 134 | me.order_by = sort_by || me.order_by || "date"; 135 | me.order_direction = sort_order || me.order_direction || "asc"; 136 | me.order = me.order_by + " " + me.order_direction; 137 | 138 | // Re-render the list 139 | me.init_panels(); 140 | }, 141 | }); 142 | } 143 | 144 | render_transactions_list() { 145 | this.$list_container = this.$panel_wrapper.find(".list-container"); 146 | 147 | this.transactions.map((transaction) => { 148 | let amount = transaction.deposit || transaction.withdrawal; 149 | let symbol = transaction.withdrawal ? "-" : "+"; 150 | 151 | let $row = this.$list_container 152 | .append( 153 | ` 154 |
155 | 156 |
157 |
158 | ${frappe.format(transaction.date, { 159 | fieldtype: "Date", 160 | })} 161 |
162 | 163 |
164 | 168 | ${symbol} ${format_currency(amount, transaction.currency)} 169 | 170 |
171 |
172 | 173 | 174 | 175 | 181 | 182 |
186 | ${transaction.description} 187 |
188 | 189 |
193 | ${transaction.reference_number} 194 |
195 |
196 | ` 197 | ) 198 | .find("#" + transaction.name); 199 | 200 | $row.on("click", () => { 201 | $row.addClass("active").siblings().removeClass("active"); 202 | 203 | // this.transaction's objects get updated, we want the latest values 204 | this.active_transaction = this.transactions.find( 205 | ({ name }) => name === transaction.name 206 | ); 207 | this.render_actions_panel(); 208 | }); 209 | }); 210 | } 211 | 212 | refresh_transaction( 213 | updated_amount = null, 214 | reference_number = null, 215 | party_type = null, 216 | party = null 217 | ) { 218 | // Update the transaction object's & view's unallocated_amount **OR** other details 219 | let id = this.active_transaction.name; 220 | let current_index = this.transactions.findIndex(({ name }) => name === id); 221 | 222 | let $current_transaction = this.$list_container.find("#" + id); 223 | let transaction = this.transactions[current_index]; 224 | 225 | if (updated_amount) { 226 | // update amount is > 0 always [src: `after_transaction_reconcile()`] 227 | this.transactions[current_index]["unallocated_amount"] = updated_amount; 228 | } else { 229 | this.transactions[current_index] = { 230 | ...transaction, 231 | reference_number: reference_number, 232 | party_type: party_type, 233 | party: party, 234 | }; 235 | // Update Reference Number in List 236 | $current_transaction.find(".reference").removeClass("hide"); 237 | $current_transaction 238 | .find(".reference-value") 239 | .text(reference_number || "--"); 240 | } 241 | 242 | $current_transaction.click(); 243 | } 244 | 245 | move_to_next_transaction() { 246 | // Remove the current transaction from the list and move to the next/previous one 247 | let id = this.active_transaction.name; 248 | let $current_transaction = this.$list_container.find("#" + id); 249 | let current_index = this.transactions.findIndex(({ name }) => name === id); 250 | 251 | let next_transaction = this.transactions[current_index + 1]; 252 | let previous_transaction = this.transactions[current_index - 1]; 253 | 254 | if (next_transaction) { 255 | this.active_transaction = next_transaction; 256 | let $next_transaction = $current_transaction.next(); 257 | $next_transaction.click(); 258 | } else if (previous_transaction) { 259 | this.active_transaction = previous_transaction; 260 | let $previous_transaction = $current_transaction.prev(); 261 | $previous_transaction.click(); 262 | } 263 | 264 | this.transactions.splice(current_index, 1); 265 | $current_transaction.remove(); 266 | 267 | if (!next_transaction && !previous_transaction) { 268 | this.active_transaction = null; 269 | this.render_no_transactions(); 270 | } 271 | } 272 | }; 273 | -------------------------------------------------------------------------------- /banking/public/js/bank_reconciliation_beta/summary_number_card.js: -------------------------------------------------------------------------------- 1 | frappe.provide("erpnext.accounts.bank_reconciliation"); 2 | 3 | erpnext.accounts.bank_reconciliation.SummaryCard = class SummaryCard { 4 | /** 5 | * { 6 | * $wrapper: $wrapper, 7 | * values: { 8 | * "Amount": [120, "text-blue"], 9 | * "Allocated Amount": [120, "text-green", 0], 10 | * "To Allocate": [0, "text-blue", -20 (actual unallocated amount)] 11 | * }, 12 | * wrapper_class: "custom-style", 13 | * currency: "USD" 14 | * } 15 | * case: 16 | * - against total amount 120, we could have 140 allocated via an invoice total 17 | * - naturally 120 out of the invoice total is allocated, so 20 is unallocated 18 | * - in this case, "To Allocate" should be 0 (-20), for transparency 19 | */ 20 | constructor(opts) { 21 | Object.assign(this, opts); 22 | this.make(); 23 | } 24 | 25 | make() { 26 | this.$wrapper.empty(); 27 | let $container = null; 28 | 29 | if (this.$wrapper.find(".report-summary").length > 0) { 30 | $container = this.$wrapper.find(".report-summary"); 31 | $container.empty(); 32 | } else { 33 | $container = this.$wrapper 34 | .append( 35 | `
` 36 | ) 37 | .find(".report-summary"); 38 | } 39 | 40 | Object.keys(this.values).map((key) => { 41 | let values = this.values[key]; 42 | let number_card; 43 | if (values[2] && values[2] !== values[0]) { 44 | // handle the case where we have two values to show 45 | let df = { fieldtype: "Currency", options: "currency" }; 46 | let value_1 = frappe.format( 47 | values[0], 48 | df, 49 | { only_value: true }, 50 | { currency: this.currency } 51 | ); 52 | let value_2 = frappe.format( 53 | values[2], 54 | df, 55 | { only_value: true }, 56 | { currency: this.currency } 57 | ); 58 | let visible_value = `${value_1} (${value_2})`; 59 | number_card = $( 60 | `
61 |
${__(key)}
62 |
${visible_value}
63 |
` 64 | ); 65 | } else { 66 | let data = { 67 | value: values[0], 68 | label: __(key), 69 | datatype: "Currency", 70 | currency: this.currency, 71 | }; 72 | number_card = frappe.utils.build_summary_item(data); 73 | } 74 | 75 | $container.append(number_card); 76 | if (values.length > 1) { 77 | let $text = number_card.find(".summary-value"); 78 | $text.addClass(values[1]); 79 | } 80 | }); 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /banking/public/scss/bank_reconciliation_beta.bundle.scss: -------------------------------------------------------------------------------- 1 | @import "./bank_reconciliation_beta.scss"; 2 | -------------------------------------------------------------------------------- /banking/public/scss/bank_reconciliation_beta.scss: -------------------------------------------------------------------------------- 1 | .p-10 { 2 | padding: 10px; 3 | } 4 | 5 | .list-panel { 6 | display: flex; 7 | flex-direction: column; 8 | width: 30%; 9 | height: 100vh; 10 | border: 1px solid var(--gray-200); 11 | 12 | > .sort-by { 13 | display: flex; 14 | justify-content: flex-start; 15 | align-items: center; 16 | border-bottom: 1px solid var(--gray-200); 17 | cursor: pointer; 18 | 19 | > .sort-by-title { 20 | padding: 10px 0 10px 10px; 21 | color: var(--text-muted); 22 | } 23 | } 24 | 25 | > .list-container { 26 | height: -webkit-fill-available; 27 | overflow-y: scroll; 28 | 29 | > .transaction-row { 30 | cursor: pointer; 31 | border-bottom: 1px solid var(--gray-200); 32 | 33 | &.active { 34 | border-left: 6px solid var(--primary); 35 | } 36 | 37 | > div { 38 | padding: 4px 10px; 39 | 40 | > .bt-label { 41 | color: var(--gray-500); 42 | } 43 | } 44 | 45 | * .reference-value, 46 | * .account-holder-value { 47 | font-weight: 600; 48 | } 49 | } 50 | } 51 | 52 | *::-webkit-scrollbar { 53 | width: 3px; 54 | height: 3px; 55 | } 56 | } 57 | 58 | .bt-amount-contianer { 59 | text-align: end; 60 | 61 | > .bt-amount { 62 | font-size: var(--text-base); 63 | } 64 | } 65 | 66 | .actions-panel { 67 | display: flex; 68 | flex-direction: column; 69 | width: 70%; 70 | height: 100vh; 71 | border: 1px solid var(--gray-200); 72 | overflow-y: scroll; 73 | 74 | > .tab-content { 75 | height: -webkit-fill-available; 76 | 77 | * .frappe-control[data-fieldname="submit_transaction"], 78 | * .btn-primary[data-fieldname="bt_reconcile"], 79 | * .btn-primary[data-fieldname="create"] { 80 | float: right; 81 | } 82 | 83 | * .dt-scrollable { 84 | height: calc(100vh - 550px) !important; 85 | } 86 | 87 | * .dt-toast { 88 | display: none !important; 89 | } 90 | } 91 | 92 | *::-webkit-scrollbar { 93 | width: 3px; 94 | height: 3px; 95 | } 96 | } 97 | 98 | .nav-actions-link { 99 | display: block; 100 | padding: var(--padding-md) 0; 101 | margin: 0 var(--margin-md); 102 | color: var(--text-muted); 103 | 104 | &.active { 105 | font-weight: 600; 106 | border-bottom: 1px solid var(--primary); 107 | color: var(--text-color); 108 | } 109 | 110 | &:hover { 111 | text-decoration: none; 112 | } 113 | } 114 | 115 | .report-summary { 116 | margin: 0.5rem 0 calc(var(--margin-sm) + 1rem) 0 !important; 117 | } 118 | 119 | .reconciliation-summary { 120 | gap: 0 !important; 121 | 122 | > .summary-item { 123 | > .summary-label { 124 | font-size: var(--text-base); 125 | } 126 | 127 | > .summary-value { 128 | font-weight: 600; 129 | font-size: 16px; 130 | } 131 | } 132 | } 133 | 134 | .text-blue { 135 | color: var(--blue-500) !important; 136 | } 137 | 138 | .bank-reco-beta-empty-state { 139 | display: flex; 140 | flex-direction: column; 141 | min-height: 30vh; 142 | align-items: center; 143 | justify-content: center; 144 | padding: 2rem; 145 | font-size: 14px; 146 | color: var(--gray-600); 147 | 148 | > .btn-primary { 149 | padding: 0.5rem 1rem !important; 150 | } 151 | } 152 | 153 | .no-transactions { 154 | display: flex; 155 | flex-direction: column; 156 | min-height: 30vh; 157 | align-items: center; 158 | justify-content: center; 159 | padding: 2rem; 160 | font-size: 14px; 161 | width: 100%; 162 | color: var(--gray-600); 163 | 164 | > img { 165 | margin-bottom: var(--margin-md); 166 | max-height: 70px; 167 | } 168 | } 169 | 170 | .match-popover-header { 171 | font-size: var(--text-base); 172 | color: var(--primary); 173 | font-weight: 500; 174 | margin-bottom: 0.5rem; 175 | } 176 | 177 | [data-theme="dark"] .actions-panel { 178 | .dt-cell[data-row-index="0"] { 179 | background-color: #00ff3c0f; 180 | } 181 | } 182 | 183 | [data-theme="light"] .actions-panel { 184 | .dt-cell[data-row-index="0"] { 185 | background-color: #f4faee; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /banking/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/templates/__init__.py -------------------------------------------------------------------------------- /banking/templates/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/templates/pages/__init__.py -------------------------------------------------------------------------------- /banking/utils.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.desk.page.setup_wizard.setup_wizard import setup_complete 3 | from frappe.utils import now_datetime 4 | 5 | 6 | def before_tests(): 7 | # complete setup if missing 8 | year = now_datetime().year 9 | if not frappe.get_list("Company"): 10 | setup_complete( 11 | { 12 | "currency": "EUR", 13 | "full_name": "Test User", 14 | "company_name": "Bolt Trades", 15 | "timezone": "Europe/Berlin", 16 | "company_abbr": "BT", 17 | "industry": "Manufacturing", 18 | "country": "Germany", 19 | "fy_start_date": f"{year}-01-01", 20 | "fy_end_date": f"{year}-12-31", 21 | "language": "english", 22 | "company_tagline": "Testing", 23 | "email": "test@erpnext.com", 24 | "password": "test", 25 | "chart_of_accounts": "Standard", 26 | } 27 | ) 28 | 29 | frappe.db.commit() # nosemgrep 30 | -------------------------------------------------------------------------------- /banking/www/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/banking/www/__init__.py -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "banking" 3 | authors = [ 4 | { name = "ALYF GmbH", email = "hallo@alyf.de"} 5 | ] 6 | description = "Banking Integration by ALYF GmbH" 7 | requires-python = ">=3.10" 8 | readme = "README.md" 9 | dynamic = ["version"] 10 | dependencies = [ 11 | # frappe -- https://github.com/frappe/frappe is installed via 'bench init' 12 | "fintech~=7.7.0" 13 | ] 14 | 15 | [build-system] 16 | requires = ["flit_core >=3.4,<4"] 17 | build-backend = "flit_core.buildapi" 18 | 19 | [tool.bench.frappe-dependencies] 20 | frappe = ">=15.60.0,<16.0.0" 21 | erpnext = ">=15.57.0,<16.0.0" 22 | 23 | [tool.ruff] 24 | line-length = 110 25 | target-version = "py310" 26 | 27 | [tool.ruff.lint] 28 | select = [ 29 | "F", 30 | "E", 31 | "W", 32 | "I", 33 | "UP", 34 | "B", 35 | "RUF", 36 | ] 37 | ignore = [ 38 | "E101", # indentation contains mixed spaces and tabs 39 | "E402", # module level import not at top of file 40 | "E501", # line too long 41 | "W191", # indentation contains tabs 42 | ] 43 | typing-modules = ["frappe.types.DF"] 44 | 45 | [tool.ruff.format] 46 | quote-style = "double" 47 | indent-style = "tab" 48 | docstring-code-format = true 49 | 50 | [project.urls] 51 | Homepage = "https://www.alyf.de/bank-integration" 52 | Repository = "https://github.com/alyf-de/banking.git" 53 | "Bug Reports" = "https://github.com/alyf-de/banking/issues" -------------------------------------------------------------------------------- /ready_for_ebics.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyf-de/banking/eb6e65b82b41c65dfd1994df1d2df664a10c6ffb/ready_for_ebics.jpg --------------------------------------------------------------------------------