├── .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": "- It stores DocType-wise mapping of fields that should be considered as 'reference number' fields in Bank Reconciliation Tool Beta
- Link and Data fields are supported
- The field must have a singular reference number for matching with Bank Transactions
",
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 += "
- " + "
- ".join(error_list) + "
"
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 |
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 |
102 |
106 | ${tab_label}
107 |
108 |
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 |

82 |
${__("No Transactions found for the current filters.")}
83 |
84 | `);
85 | }
86 |
87 | render_list_panel() {
88 | this.$panel_wrapper.append(`
89 |
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 |
179 | ${transaction.bank_party_name}
180 |
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
--------------------------------------------------------------------------------