├── .editorconfig ├── .eslintrc ├── .flake8 ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── codeql.yml │ ├── initiate_release.yml │ ├── linters.yml │ ├── on_release.yml │ └── semantic-commits.yml ├── .gitignore ├── .mergify.yml ├── .pre-commit-config.yaml ├── .releaserc ├── README.md ├── commitlint.config.js ├── docs ├── FAQS.md ├── accounting │ ├── 1_fees_and_tax.md │ └── 2_payout_reversal.md ├── payout │ ├── 1_requirements.md │ ├── 2_Authentication.md │ ├── 3_make_payout.md │ ├── 4_bulk_submit_without_payout.md │ ├── 5_cancel_payout.md │ ├── 6_setup_status_notification.md │ └── 7_payout_tips_and_notes.md ├── reconcile │ └── 1_sync_and_reconcile_transactions.md ├── report │ └── 1_payout_status_report.md └── setup │ ├── 1_setup_test_and_live_mode.md │ └── 2_connect_erpnext_with_razorpayx.md ├── license.txt ├── pyproject.toml ├── razorpayx_integration ├── __init__.py ├── config │ ├── __init__.py │ ├── desktop.py │ └── docs.py ├── constants.py ├── hooks.py ├── install.py ├── modules.txt ├── patches.txt ├── patches │ ├── delete_old_custom_fields.py │ ├── delete_old_property_setters.py │ ├── mark_creation_of_je_on_reversal.py │ ├── post_install │ │ └── __init__.py │ ├── set_default_payouts_from.py │ ├── set_payment_transfer_method.py │ └── update_integration_doctype.py ├── public │ ├── .gitkeep │ ├── images │ │ └── razorpayx-logo.png │ └── js │ │ ├── razorpayx_integration.bundle.js │ │ └── utils.js ├── razorpayx_integration │ ├── __init__.py │ ├── apis │ │ ├── base.py │ │ ├── contact.py │ │ ├── fund_account.py │ │ ├── payout.py │ │ ├── transaction.py │ │ └── validate_razorpayx.py │ ├── client_overrides │ │ └── form │ │ │ ├── bank_reconciliation_tool.js │ │ │ └── payment_entry.js │ ├── constants │ │ ├── __init__.py │ │ ├── custom_fields.py │ │ ├── payouts.py │ │ ├── property_setters.py │ │ ├── roles.py │ │ └── webhooks.py │ ├── doctype │ │ ├── __init__.py │ │ └── razorpayx_configuration │ │ │ ├── __init__.py │ │ │ ├── razorpayx_configuration.js │ │ │ ├── razorpayx_configuration.json │ │ │ ├── razorpayx_configuration.py │ │ │ └── test_razorpayx_configuration.py │ ├── notification │ │ ├── __init__.py │ │ ├── failed_payout │ │ │ ├── __init__.py │ │ │ ├── failed_payout.json │ │ │ ├── failed_payout.md │ │ │ └── failed_payout.py │ │ └── payout_processed │ │ │ ├── __init__.py │ │ │ ├── payout_processed.json │ │ │ ├── payout_processed.md │ │ │ └── payout_processed.py │ ├── report │ │ ├── __init__.py │ │ └── razorpayx_payout_status │ │ │ ├── __init__.py │ │ │ ├── razorpayx_payout_status.js │ │ │ ├── razorpayx_payout_status.json │ │ │ └── razorpayx_payout_status.py │ ├── server_overrides │ │ ├── __init__.py │ │ └── doctype │ │ │ └── payment_entry.py │ └── utils │ │ ├── __init__.py │ │ ├── bank_transaction.py │ │ ├── payout.py │ │ ├── validation.py │ │ └── webhook.py ├── setup.py ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py └── uninstall.py └── requirements.txt /.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 = space 14 | indent_size = 4 15 | max_line_length = 110 16 | 17 | # JSON files - mostly doctype schema files 18 | [{*.json}] 19 | insert_final_newline = false 20 | indent_style = space 21 | indent_size = 1 -------------------------------------------------------------------------------- /.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 | "razorpayx":true, 30 | "payment_integration_utils": true, 31 | "Vue": true, 32 | "SetVueGlobals": true, 33 | "erpnext": true, 34 | "hub": true, 35 | "$": true, 36 | "jQuery": true, 37 | "moment": true, 38 | "hljs": true, 39 | "Awesomplete": true, 40 | "CalHeatMap": true, 41 | "Sortable": true, 42 | "Showdown": true, 43 | "Taggle": true, 44 | "Gantt": true, 45 | "Slick": true, 46 | "PhotoSwipe": true, 47 | "PhotoSwipeUI_Default": true, 48 | "fluxify": true, 49 | "io": true, 50 | "c3": true, 51 | "__": true, 52 | "_p": true, 53 | "_f": true, 54 | "repl": true, 55 | "Class": true, 56 | "locals": true, 57 | "cint": true, 58 | "cstr": true, 59 | "cur_frm": true, 60 | "cur_dialog": true, 61 | "cur_page": true, 62 | "cur_list": true, 63 | "cur_tree": true, 64 | "cur_pos": true, 65 | "msg_dialog": true, 66 | "is_null": true, 67 | "in_list": true, 68 | "has_common": true, 69 | "posthog": true, 70 | "has_words": true, 71 | "validate_email": true, 72 | "open_web_template_values_editor": true, 73 | "get_number_format": true, 74 | "format_number": true, 75 | "format_currency": true, 76 | "round_based_on_smallest_currency_fraction": true, 77 | "roundNumber": true, 78 | "comment_when": true, 79 | "replace_newlines": true, 80 | "open_url_post": true, 81 | "toTitle": true, 82 | "lstrip": true, 83 | "strip": true, 84 | "strip_html": true, 85 | "replace_all": true, 86 | "flt": true, 87 | "precision": true, 88 | "md5": true, 89 | "CREATE": true, 90 | "AMEND": true, 91 | "CANCEL": true, 92 | "copy_dict": true, 93 | "get_number_format_info": true, 94 | "print_table": true, 95 | "Layout": true, 96 | "web_form_settings": true, 97 | "$c": true, 98 | "$a": true, 99 | "$i": true, 100 | "$bg": true, 101 | "$y": true, 102 | "$c_obj": true, 103 | "$c_obj_csv": true, 104 | "refresh_many": true, 105 | "refresh_field": true, 106 | "toggle_field": true, 107 | "get_field_obj": true, 108 | "get_query_params": true, 109 | "unhide_field": true, 110 | "hide_field": true, 111 | "set_field_options": true, 112 | "getCookie": true, 113 | "getCookies": true, 114 | "get_url_arg": true, 115 | "get_server_fields": true, 116 | "set_multiple": true, 117 | "QUnit": true, 118 | "Chart": true, 119 | "Cypress": true, 120 | "cy": true, 121 | "describe": true, 122 | "expect": true, 123 | "it": true, 124 | "context": true, 125 | "before": true, 126 | "beforeEach": true, 127 | "onScan": true, 128 | "extend_cscript": true, 129 | "localforage": true, 130 | "Plaid": true 131 | } 132 | } -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | E121, 4 | E126, 5 | E127, 6 | E128, 7 | E203, 8 | E225, 9 | E226, 10 | E231, 11 | E241, 12 | E251, 13 | E261, 14 | E265, 15 | E302, 16 | E303, 17 | E305, 18 | E402, 19 | E501, 20 | E741, 21 | W291, 22 | W292, 23 | W293, 24 | W391, 25 | W503, 26 | W504, 27 | F403, 28 | B007, 29 | B950, 30 | W191, 31 | E124, # closing bracket, irritating while writing QB code 32 | E131, # continuation line unaligned for hanging indent 33 | E123, # closing bracket does not match indentation of opening bracket's line 34 | E101, # ensured by use of black 35 | 36 | max-line-length = 200 37 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Since version 2.23 (released in August 2019), git-blame has a feature 2 | # to ignore or bypass certain commits. 3 | # 4 | # This file contains a list of commits that are not likely what you 5 | # are looking for in a blame, such as mass reformatting or renaming. 6 | # You can set this file as a default ignore file for blame by running 7 | # the following command. 8 | # 9 | # $ git config blame.ignoreRevsFile .git-blame-ignore-revs 10 | 11 | # Reformatting 12 | b30fc5128304b4f806087d7abc8269aa4b76703d 13 | 14 | # tabs to spaces 15 | 2a0bfb14e15b112db56348a4d5b063f00a431d96 16 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | workflow_call: 5 | pull_request: 6 | paths-ignore: 7 | - "**.css" 8 | - "**.md" 9 | - "**.html" 10 | - "**.csv" 11 | 12 | push: 13 | 14 | schedule: 15 | - cron: "0 0 * * 1" 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | permissions: 22 | actions: read 23 | contents: read 24 | security-events: write 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | language: ["python", "javascript"] 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | 35 | # Initializes the CodeQL tools for scanning. 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v3 38 | with: 39 | languages: ${{ matrix.language }} 40 | 41 | - name: Perform CodeQL Analysis 42 | uses: github/codeql-action/analyze@v3 43 | -------------------------------------------------------------------------------- /.github/workflows/initiate_release.yml: -------------------------------------------------------------------------------- 1 | # This workflow is agnostic to branches. Only maintain on develop branch. 2 | # To add/remove versions just modify the matrix. 3 | 4 | name: Initiate Release 5 | on: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | version: ["15"] 16 | 17 | steps: 18 | - uses: octokit/request-action@v2.x 19 | with: 20 | route: POST /repos/{owner}/{repo}/pulls 21 | owner: resilient-tech 22 | repo: razorpayx-integration 23 | title: |- 24 | "chore: release v${{ matrix.version }}" 25 | body: "Automated Release." 26 | base: version-${{ matrix.version }} 27 | head: version-${{ matrix.version }}-hotfix 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.BOT_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | workflow_call: 5 | pull_request: 6 | paths-ignore: 7 | - "**.md" 8 | - "**.csv" 9 | 10 | jobs: 11 | linters: 12 | name: linters 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python 3.10 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.10" 21 | 22 | - name: Install and Run Pre-commit 23 | uses: pre-commit/action@v3.0.1 24 | 25 | - name: Download Semgrep rules 26 | run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules 27 | 28 | - name: Download semgrep 29 | run: pip install semgrep 30 | 31 | - name: Run Semgrep rules 32 | run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness 33 | -------------------------------------------------------------------------------- /.github/workflows/on_release.yml: -------------------------------------------------------------------------------- 1 | name: Generate Semantic Release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - version-15 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Entire Repository 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | persist-credentials: false 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | 23 | - name: Setup dependencies 24 | run: | 25 | npm install @semantic-release/git @semantic-release/exec --no-save 26 | - name: Create Release 27 | env: 28 | GH_TOKEN: ${{ secrets.BOT_TOKEN }} 29 | GITHUB_TOKEN: ${{ secrets.BOT_TOKEN }} 30 | GIT_AUTHOR_NAME: "Resilient Tech Bot" 31 | GIT_AUTHOR_EMAIL: "bot@resilient.tech" 32 | GIT_COMMITTER_NAME: "Resilient Tech Bot" 33 | GIT_COMMITTER_EMAIL: "bot@resilient.tech" 34 | run: npx semantic-release 35 | -------------------------------------------------------------------------------- /.github/workflows/semantic-commits.yml: -------------------------------------------------------------------------------- 1 | name: Semantic Commits 2 | 3 | on: 4 | pull_request: {} 5 | 6 | permissions: 7 | contents: read 8 | 9 | concurrency: 10 | group: commitcheck-frappe-${{ github.event.number }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | commitlint: 15 | name: Check Commit Titles 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 200 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | check-latest: true 26 | 27 | - name: Check commit titles 28 | run: | 29 | npm install @commitlint/cli @commitlint/config-conventional 30 | npx commitlint --verbose --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | razorpayx_integration/docs/current 7 | node_modules/ 8 | 9 | ## Build Files ### 10 | dist/ 11 | 12 | ### Vs Code ### 13 | .vscode/ 14 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: Auto-close PRs on stable branch 3 | conditions: 4 | - and: 5 | - author!=sagarvora 6 | - author!=vorasmit 7 | - author!=mergify[bot] 8 | - author!=dependabot[bot] 9 | - author!=resilient-tech-bot 10 | - or: 11 | - base=version-15 12 | actions: 13 | close: 14 | comment: 15 | message: | 16 | @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on the `develop` branch. 17 | 18 | - name: Automatic merge on CI success and review 19 | conditions: 20 | - label!=dont-merge 21 | - label!=squash 22 | - "#approved-reviews-by>=1" 23 | actions: 24 | merge: 25 | method: merge 26 | 27 | - name: Automatic squash on CI success and review 28 | conditions: 29 | - label!=dont-merge 30 | - label=squash 31 | - "#approved-reviews-by>=1" 32 | actions: 33 | merge: 34 | method: squash 35 | commit_message_template: | 36 | {{ title }} (#{{ number }}) 37 | {{ body }} 38 | 39 | - name: backport to develop 40 | conditions: 41 | - label="backport develop" 42 | actions: 43 | backport: 44 | branches: 45 | - develop 46 | assignees: 47 | - "{{ author }}" 48 | 49 | - name: backport to version-15-hotfix 50 | conditions: 51 | - label="backport version-15-hotfix" 52 | actions: 53 | backport: 54 | branches: 55 | - version-15-hotfix 56 | assignees: 57 | - "{{ author }}" 58 | 59 | - name: automatically merge backport if they pass tests 60 | conditions: 61 | - author=mergify[bot] 62 | - base~=^version- 63 | - head~=^mergify/bp/ 64 | - label!=conflicts 65 | actions: 66 | merge: 67 | method: merge 68 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: "node_modules|.git" 2 | default_stages: [commit] 3 | fail_fast: false 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: trailing-whitespace 10 | files: "razorpayx_integration/.*" 11 | exclude: ".*txt$|.*csv|.*md" 12 | - id: check-yaml 13 | - id: no-commit-to-branch 14 | args: ["--branch", "version-15"] 15 | - id: check-merge-conflict 16 | - id: check-ast 17 | - id: check-json 18 | - id: check-toml 19 | - id: debug-statements 20 | 21 | - repo: https://github.com/pre-commit/mirrors-prettier 22 | rev: v2.7.1 23 | hooks: 24 | - id: prettier 25 | types_or: [javascript, vue, scss] 26 | # Ignore any files that might contain jinja / bundles 27 | exclude: | 28 | (?x)^( 29 | razorpayx_integration/public/dist/.*| 30 | cypress/.*| 31 | .*node_modules.*| 32 | .*boilerplate.* 33 | )$ 34 | 35 | - repo: https://github.com/pre-commit/mirrors-eslint 36 | rev: v8.44.0 37 | hooks: 38 | - id: eslint 39 | types_or: [javascript] 40 | args: ["--quiet"] 41 | # Ignore any files that might contain jinja / bundles 42 | exclude: | 43 | (?x)^( 44 | razorpayx_integration/public/dist/.*| 45 | cypress/.*| 46 | .*node_modules.*| 47 | .*boilerplate.* 48 | )$ 49 | 50 | - repo: https://github.com/astral-sh/ruff-pre-commit 51 | rev: v0.2.0 52 | hooks: 53 | - id: ruff 54 | name: "Run ruff import sorter" 55 | args: ["--select=I", "--fix"] 56 | 57 | - id: ruff 58 | name: "Run ruff linter" 59 | 60 | - id: ruff-format 61 | name: "Run ruff formatter" 62 | 63 | ci: 64 | autoupdate_schedule: weekly 65 | skip: [] 66 | submodules: false 67 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "version-15" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | { 8 | "preset": "angular", 9 | "releaseRules": [ 10 | { 11 | "breaking": true, 12 | "release": false 13 | } 14 | ] 15 | }, 16 | "@semantic-release/release-notes-generator", 17 | [ 18 | "@semantic-release/exec", 19 | { 20 | "prepareCmd": "sed -i -E 's/\"[0-9]+\\.[0-9]+\\.[0-9]+\"/\"${nextRelease.version}\"/' razorpayx_integration/__init__.py" 21 | } 22 | ], 23 | [ 24 | "@semantic-release/git", 25 | { 26 | "assets": [ 27 | "razorpayx_integration/__init__.py" 28 | ], 29 | "message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}" 30 | } 31 | ], 32 | "@semantic-release/github" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

RazorpayX Integration

4 | 5 | Power your ERPNext payments with RazorpayX – Automate payouts, reconcile transactions, and manage business finances effortlessly 6 | 7 |
8 | 9 |
10 | 11 | ## 💡 Motivation 12 | 13 | Bank integrations in India are usually costly and complex, mainly available to corporate. 14 | 15 | We choose RazorpayX because: 16 | 17 | - It is a tech layer over traditional bank accounts 18 | - Funds remain secure with a regulated bank 19 | - Onboarding process is hassle-free 20 | - No upfront cost, minimal charges per transaction, transparent pricing 21 | - Robust security 22 | 23 | ## ✨ Features 24 | 25 | - Automated bulk payouts for vendors 26 | - Real-time payment status tracking & transaction reconciliation 27 | - Support for multiple payment modes (IMPS/NEFT/RTGS/UPI) 28 | - Can make payment with Link 29 | - Daily sync bank transactions 30 | - Pre-built templates for workflows and notifications 31 | - Configurable to cater to diverse business processes 32 | 33 | **📝 Note:** This integration is designed for **domestic transactions within India 🇮🇳**. Foreign currency transactions are not supported. 34 | 35 | ## 📈 Why Use This Integration? 36 | 37 | - Save Time: Eliminate manual bank transactions from **Net Banking** portals 38 | - Reduce Errors: Auto-sync payment data between ERPNext and Bank 39 | - Financial Control: Approval workflows before initiating payouts 40 | - Secure: Role based access with 2FA to authorize manual payouts 41 | 42 | ## 📦 Installation 43 | 44 | **Prerequisites** 45 | 46 | - [ERPNext](https://github.com/frappe/erpnext) Version-15 or above 47 | - [Payment Integration Utils](https://github.com/resilient-tech/payment_integration_utils) 48 | - [Payments Processor](https://github.com/resilient-tech/payments-processor) (optional: to automate workflows) 49 | 50 | **Recommendations** 51 | 52 | - We recommend you to keep **ERPNext** version latest. 53 | - Before updating `RazorpayX Integration`, update `Payment Integration Utils` first. 54 | 55 | Choose one of the following methods to install RazorpayX Integration to your ERPNext site. 56 | 57 |
58 | ☁️ Frappe Cloud
59 | 60 | Sign up for a [Frappe Cloud](https://frappecloud.com/dashboard/signup?referrer=99df7a8f) free trial, create a new site with Frappe Version-15 or above, and install ERPNext and RazorpayX-Integration from the Apps. 61 | 62 |
63 | 64 |
65 | 🐳 Docker
66 | 67 | Use [this guide](https://github.com/frappe/frappe_docker/blob/main/docs/custom-apps.md) to deploy RazorpayX-Integration by building your custom image. 68 | 69 | Sample Apps JSON 70 | 71 | ```shell 72 | export APPS_JSON='[ 73 | { 74 | "url": "https://github.com/frappe/erpnext", 75 | "branch": "version-15" 76 | }, 77 | { 78 | "url": "https://github.com/resilient-tech/payment_integration_utils", 79 | "branch": "version-15" 80 | }, 81 | { 82 | "url": "https://github.com/resilient-tech/razorpayx-integration", 83 | "branch": "version-15" 84 | } 85 | ]' 86 | 87 | export APPS_JSON_BASE64=$(echo ${APPS_JSON} | base64 -w 0) 88 | ``` 89 | 90 |
91 | 92 |
93 | ⌨️ Manual
94 | 95 | Once you've [set up a Frappe site](https://frappeframework.com/docs/v14/user/en/installation/), install app by executing the following commands: 96 | 97 | Using Bench CLI 98 | 99 | Download the App using the Bench CLI 100 | 101 | ```sh 102 | bench get-app https://github.com/resilient-tech/payment_integration_utils.git --branch version-15 103 | ``` 104 | 105 | ```sh 106 | bench get-app https://github.com/resilient-tech/razorpayx-integration.git --branch version-15 107 | ``` 108 | 109 | Install the App on your site 110 | 111 | ```sh 112 | bench --site [site name] install-app razorpayx_integration 113 | ``` 114 | 115 |
116 | 117 | ## 📚 Documentation 118 | 119 | 1. [Connect ERPNext with RazorpayX](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/setup/2_connect_erpnext_with_razorpayx.md) 120 | 2. [Make payout via RazorpayX within ERPNext](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/payout/3_make_payout.md) 121 | 3. [Reconcile Bank Transactions via RazorpayX API](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/reconcile/1_sync_and_reconcile_transactions.md) 122 | 4. [Analyze Payout Status Report](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/report/1_payout_status_report.md) 123 | 5. [FAQs](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/FAQS.md) 124 | 125 | - Read full documentation [here](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs) 126 | 127 | 🔗 **Google Form for Discount Pricing on RazorpayX Payout Fees**: [Apply Here](http://bit.ly/3FhJOaA) 128 | 129 | ## 🤝 Contributing 130 | 131 | - [Issue Guidelines](https://github.com/frappe/erpnext/wiki/Issue-Guidelines) 132 | - [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines) 133 | 134 | ## 📜 License 135 | 136 | [GNU General Public License (v3)](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/license.txt) 137 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserPreset: "conventional-changelog-conventionalcommits", 3 | rules: { 4 | "subject-empty": [2, "never"], 5 | "type-case": [2, "always", "lower-case"], 6 | "type-empty": [2, "never"], 7 | "type-enum": [ 8 | 2, 9 | "always", 10 | ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test"], 11 | ], 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /docs/FAQS.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions (RazorpayX Integration for Frappe/ERPNext) 2 | 3 | ### 1. Which banks are supported by RazorpayX? 4 | 5 | **Supported Banks:** 6 | RazorpayX currently supports accounts with **RBL Bank, Yes Bank, IDFC First Bank, and Axis Bank** (the latter is selectively available for enterprise customers). 7 | 8 | **Note:** 9 | 10 | - A **new bank account** is required in most cases (existing accounts may not be compatible). 11 | - Check [RazorpayX’s official website](https://razorpay.com/x/current-accounts/) for the latest updates, as supported banks may change. 12 | 13 | --- 14 | 15 | ### 2. How do I sign up for RazorpayX? 16 | 17 | **Steps to Sign Up:** 18 | 19 | 1. Visit the [RazorpayX portal](https://x.razorpay.com/auth/signup) and sign up. 20 | 2. A RazorpayX representative will contact you to guide you through **documentation requirements**, which vary by business type (e.g., sole proprietorship, LLP, private limited). 21 | 3. The approval process typically takes **7–10 business days** after submitting complete documentation. 22 | 23 | **Pro Tip:** Keep your business PAN, GST, and incorporation documents ready to expedite the process. 24 | 25 | --- 26 | 27 | ### 3. What are the pricing and fees? 28 | 29 | **Special Community Pricing:** 30 | 31 | - **Zero setup fees** or SaaS charges. 32 | - **Transaction fees** start at **₹1 per payout** (varies by transaction type and volume). 33 | - **No hidden bank charges** for covered transaction categories (e.g., standard NEFT/IMPS/UPI payouts). 34 | 35 | **Note:** 36 | 37 | - Fill out the [Google Form for Discount Pricing](http://bit.ly/3FhJOaA) to get special pricing. 38 | - A **RazorpayX** representative will contact you with the discounted pricing details. 39 | 40 | --- 41 | 42 | ### 4. How do I switch between Test Mode and Live Mode? 43 | 44 | **Steps:** 45 | 46 | 1. **In RazorpayX Dashboard:** 47 | - Toggle between **Test** (Sandbox) and **Live** modes under *Settings*. 48 | 2. **In ERPNext:** 49 | - Navigate to **RazorpayX Configuration** (via the Frappe/ERPNext dashboard). 50 | - Ensure the **API Key** and **Secret Key** match the mode (Test/Live) selected in RazorpayX. 51 | 52 | **Important:** 53 | 54 | - Test mode requires sandbox credentials (provided by RazorpayX). 55 | - Never use live credentials in test mode, or vice versa. 56 | 57 | - For more details read [Setup Test and Live Mode](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/setup/1_setup_test_and_live_mode.md) 58 | 59 | --- 60 | 61 | ### 5. Can I use multiple RazorpayX accounts? 62 | 63 | **Yes!** Follow these steps: 64 | 65 | 1. **Configure Accounts:** Add each RazorpayX account in ERPNext under *Accounts > RazorpayX Configuration*. 66 | 2. **Select During Transactions:** When creating a **Payment Entry**, choose the desired bank account from the *Company Bank Account* dropdown. 67 | 68 | **Note:** Ensure API keys for all accounts are correctly mapped to avoid payout errors. 69 | 70 | --- 71 | 72 | ### Additional Tips 73 | 74 | - **Reconciliation:** Use the *Bank Reconciliation Tool* in ERPNext to sync RazorpayX transactions automatically. 75 | - **Security:** Rotate API keys quarterly or when team members with access leave. 76 | 77 | --- 78 | 79 | **Still Have Questions?** 80 | Visit [RazorpayX Documentation](https://razorpay.com/docs/x/) or connect with their support representative. 81 | -------------------------------------------------------------------------------- /docs/accounting/1_fees_and_tax.md: -------------------------------------------------------------------------------- 1 | # 💰 Fees and Tax Accounting 2 | 3 | ![Fees Accounting Fields](https://github.com/user-attachments/assets/d37f1d10-9166-4d81-a119-7cd3caa9f2ac) 4 | 5 | For detailed information on charges and deductions, refer to RazorpayX's [Fees and Tax Documentation 🔗](https://razorpay.com/docs/x/manage-teams/billing/). 6 | 7 | ## ⚙️ Configuration Fields 8 | 9 | ### 1. **Automate Fees Accounting** 10 | 11 | - **Enabled by default** (`Checked`). 12 | - If enabled, a **Journal Entry (JE)** is created whenever a fee is deducted on a payout. 13 | - For payouts from a **Current Account**, the JE is recorded when the payout is **Processed**. 14 | - For payouts from **RazorpayX Lite**, the JE is recorded when the payout is in the **Processing** state. 15 | 16 | ### 2. **Creditors Account** 17 | 18 | - Used in Journal Entries to **debit the transaction fees**. 19 | 20 | ![Creditors Account](https://github.com/user-attachments/assets/479d01e2-a704-44cc-896e-ccaaa24d3e6f) 21 | 22 | ### 3. **Supplier** 23 | 24 | - The **Party and Party Type (Supplier)** associated with the Creditors Account. 25 | 26 | ### 4. **Payable Account** 27 | 28 | - Used in Journal Entries to **credit the transaction fees**. 29 | - Only applicable when payouts are made from the **Current Account**. 30 | 31 | ![Payable Account](https://github.com/user-attachments/assets/c34731d1-6745-4dae-86e8-a840a40e2474) 32 | 33 | ## 🔄 Journal Entry (JE) Creation Process 34 | 35 | ### 🏦 **Payouts from Current Account** 36 | 37 | - **Creditors Account** ➝ **Debited** 38 | - **Payable Account** ➝ **Credited** 39 | 40 | **Example Journal Entry**: 41 | ![JE for Current Account Deduction](https://github.com/user-attachments/assets/d9438c6a-1e65-408a-86d1-567a8c037e51) 42 | 43 | 📌 **Important Notes**: 44 | 45 | - When using the **Current Account**, fees are **not deducted immediately**. Instead, a JE is created to reflect the expected deduction. 46 | - At the end of the day, **RazorpayX deducts the accumulated transaction fees** for all payouts made that day. 47 | - For more details, refer to [Payouts from Current Account](https://razorpay.com/docs/x/manage-teams/billing/#payouts-from-current-account). 48 | - Since this is an anticipated fee deduction, this JE **will not be used for bank reconciliation**. 49 | - When RazorpayX deducts the fees, a final JE is created where: 50 | - **Payable Account** ➝ **Debited** 51 | - **Company Account (COA)** ➝ **Credited** 52 | - More details on this will be updated soon. 53 | 54 | ### 💳 **Payouts from RazorpayX Lite** 55 | 56 | - **Creditors Account** ➝ **Debited** 57 | - **Company Account (COA)** ➝ **Credited** 58 | 59 | **Company’s RazorpayX Bank Account**: 60 | ![Company Bank Account](https://github.com/user-attachments/assets/1f81dcb6-da69-4d36-8120-36344a0003e1) 61 | 62 | **Company Account**: 63 | ![Company Account](https://github.com/user-attachments/assets/0d7af968-1eda-4e98-9cfa-15f347909303) 64 | 65 | **Example Journal Entry**: 66 | ![Example JE](https://github.com/user-attachments/assets/d612fd1f-7add-4928-b5db-915c5299b4a6) 67 | 68 | 📌 **Important Notes**: 69 | 70 | - In **RazorpayX Lite**, fees are **deducted immediately when the payout is created**. 71 | - For more details, refer to [Payouts from RazorpayX Lite](https://razorpay.com/docs/x/manage-teams/billing/#payouts-from-razorpayx-lite). 72 | - This JE **will be used for reconciliation** in the Bank Transaction. 73 | 74 | **Example Bank Transaction**: 75 | ![Bank Transaction](https://github.com/user-attachments/assets/48827317-46c4-4a26-a31e-4b1091c2c7db) 76 | -------------------------------------------------------------------------------- /docs/accounting/2_payout_reversal.md: -------------------------------------------------------------------------------- 1 | # 🔄 Payout Reversal Accounting 2 | 3 | ![Configuration Image](https://github.com/user-attachments/assets/1a37825e-ca0c-4a9c-ae20-ff6239f551a0) 4 | 5 | ## ⚙️ Configuration Fields 6 | 7 | ### 1. **Create JE on Payout Reversal** 8 | 9 | - **Enabled by default** (`Checked`). 10 | - If enabled: 11 | - Unreconcile the **Payment Entry** from which the payout was initiated. 12 | - Create a **Reversal Journal Entry** for the Payment Entry, respecting its ledger. 13 | - Reverse the **Fees Journal Entry** (if applicable). 14 | 15 | ## 🔄 Journal Entry (JE) Creation Process 16 | 17 | ### **Payout Reversal JE** 18 | 19 | - Deductions are also reversed if applicable. 20 | - Deductions/Losses are handled automatically in the reversal JE. 21 | 22 | **Payment Entry**: 23 | ![Payment Entry](https://github.com/user-attachments/assets/38ea7162-1385-442e-8e5a-7438421c2df5) 24 | 25 | **Accounting Ledger of Payment Entry**: 26 | ![Accounting Ledger of Payment Entry](https://github.com/user-attachments/assets/f416286c-11f0-4be6-bbbf-2a19c51d872b) 27 | 28 | **Reversal JE**: 29 | ![Reversal JE](https://github.com/user-attachments/assets/368ce82d-bad2-4514-96b2-2c5f30168025) 30 | 31 | ### **Fees Reversal JE** 32 | 33 | - If fees were deducted during the original payout, a **Fees Reversal JE** is created to reverse the fees. 34 | 35 | ![Fees Reversal JE](https://github.com/user-attachments/assets/0feeb6b9-15fc-4877-a3c9-352ce4d57d1f) 36 | 37 | ### **Bank Transaction** 38 | 39 | - For reconciliation, both reversal JEs are used for the **Deposit** (credit) of the reversed amount. 40 | - If the payout was made from **RazorpayX Lite**, the **Fees Reversal JE** will be referenced. 41 | - Otherwise, only the **Payout Reversal JE** will be referenced. 42 | 43 | ![Bank Transaction](https://github.com/user-attachments/assets/d4baa18d-027e-42d1-b5f0-a1fe1232da32) 44 | -------------------------------------------------------------------------------- /docs/payout/1_requirements.md: -------------------------------------------------------------------------------- 1 | # 📋 Requirements Before Making Payout 2 | 3 | ## 🔐 Roles and Permissions 4 | 5 | 1. **Online Payments Authorizer Role**: 6 | - The user must have the `Online Payments Authorizer` role to initiate payouts. 7 | 8 | 2. **Submit Permission for Payment Entry**: 9 | - The user must have permission to **submit** the **Payment Entry**. 10 | 11 | 3. **Read Permission for RazorpayX Configuration**: 12 | - The user must have at least **read** permission for the **RazorpayX Configuration** document. 13 | 14 | ## 🔒 Authentication Setup 15 | 16 | - For detailed steps on setting up authentication, refer to the [Authentication Guide](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/payout/2_Authentication.md). 17 | 18 | ## 💳 Payment Entry Requirements 19 | 20 | 1. **Payment Type**: 21 | - Set the payment type to **Pay**. 22 | 23 | 2. **Company Bank Account**: 24 | - Ensure the selected **Company Bank Account** is linked to the RazorpayX Configuration. 25 | 26 | 3. **Paid from Account Currency**: 27 | - The currency must be set to **INR** (Indian Rupees). 28 | 29 | 4. **Make Online Payment**: 30 | - If **Checked**: 31 | - The payout will be initiated automatically when the Payment Entry is submitted (via `Pay and Submit`). 32 | - If **Unchecked**: 33 | - The payout can be initiated manually after submission, provided the RazorpayX Configuration (via `Company bank Account`) is available. 34 | -------------------------------------------------------------------------------- /docs/payout/2_Authentication.md: -------------------------------------------------------------------------------- 1 | # 🔐 Authentication for Making Payout 2 | 3 | ## ⚙️ Setup 2FA 4 | 5 | 1. Go to **System Settings** > **Login** tab. 6 | 2. Scroll down to the **Payment Integration** section. 7 | 3. Set the **Payment Authentication Method**. 8 | - Currently, only **OTP App** is available. 9 | - In future updates, **SMS** and **Email** will be supported. 10 | 4. Optionally, set up an **OTP Issuer Name** (e.g., Company Name or Site Name). 11 | 12 | ![System Settings](https://github.com/user-attachments/assets/eef415a5-b130-456c-913d-efffa669c783) 13 | 14 | **Note**: 15 | 16 | - A default **Outgoing Email Account** is required to send emails for authentication. 17 | - The **Administrator** role **cannot** make payouts. 18 | - If the **Administrator** impersonates another user, they will see the **Make Payout** | **Pay and Submit** button but **cannot authenticate** to complete the payout. 19 | 20 | ## 🔢 OTP Dialog Box 21 | 22 | ### First-Time OTP Generation 23 | 24 | ![OTP Dialog Box](https://github.com/user-attachments/assets/3fce9443-28ba-4980-976d-fcaea83e3dee) 25 | 26 | ### If OTP App already configured 27 | 28 | ![OTP Dialog Box](https://github.com/user-attachments/assets/242fabc2-009a-4257-86c0-0d266c11a124) 29 | 30 | ## 📧 Sample Emails 31 | 32 | ### Email for QR Code Link 33 | 34 | ![Email for Process](https://github.com/user-attachments/assets/f0d799bd-c55b-4d62-8b19-de4419bf82cf) 35 | 36 | ### After Scanning QR Code 37 | 38 | ![QR Code Page with Steps](https://github.com/user-attachments/assets/f69729e9-fb43-4a6e-adad-6e0320c64507) 39 | 40 | ## 🔄 Reset Payment OTP Secret 41 | 42 | - **Prerequisites**: 43 | - The user must have the **Online Payments Authorizer** role. 44 | - The **Payment Authentication Method** must be set to **OTP App**. 45 | 46 | - **Steps**: 47 | 1. Go to **User** > **Password**. 48 | 2. Click on **Reset Payment OTP Secret**. 49 | 50 | ![Reset OTP Secret](https://github.com/user-attachments/assets/a3cb040c-df71-408d-85f8-c03e8a26b7c4) -------------------------------------------------------------------------------- /docs/payout/3_make_payout.md: -------------------------------------------------------------------------------- 1 | # 💳 Make RazorpayX Payout with Payment Entry 2 | 3 | ## 📋 Prerequisites 4 | 5 | - Ensure all [Payout Requirements](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/payout/1_requirements.md) are met. 6 | - Complete the [Authentication Setup](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/payout/2_Authentication.md) for secure transactions. 7 | 8 | ## 🚀 Make Payout on Payment Entry Submission 9 | 10 | 1. Create a **Payment Entry** with the following details: 11 | - **Payment Type**: Pay 12 | - **Company Bank Account**: Linked to RazorpayX Configuration 13 | - **Paid from Account Currency**: INR 14 | - **Make Online Payment**: Check this option to initiate the payout on submission. 15 | 16 | 2. Submit the Payment Entry to trigger the payout via `Pay and Submit` 17 | 18 | https://github.com/user-attachments/assets/15fca87c-eb1c-4173-b401-2fffa4e10888 19 | 20 | **Note**: 21 | 22 | - If a **Workflow** is active for the Payment Entry, the `Pay and Submit` button will not be available, and `Make Online Payment` will be unchecked if previously checked. 23 | 24 | ## ⏳ Make Payout After Payment Entry Submission 25 | 26 | - If the Payment Entry is submitted without checking `Make Online Payment`, user can still initiate the payout manually. 27 | - A custom button, **Make Payout**, will be available if the Company's Bank Account is valid and linked to RazorpayX. 28 | 29 | https://github.com/user-attachments/assets/53b18844-88aa-403d-aad3-c07478a76a51 30 | 31 | ## 📦 Bulk Payout 32 | 33 | 1. Select multiple **Payment Entries** in **Draft** status with valid payout information. 34 | 2. Use the **Pay and Submit** bulk action to initiate payouts for all selected entries. 35 | - **Make Online Payment** is optional and can be marked during the bulk action. 36 | 37 | **Recommendations for Bulk Payouts**: 38 | 39 | - Ensure each Payment Entry has valid: 40 | - **Company Bank Account** 41 | - **Party Bank Account** 42 | - **Payment Transfer Method** 43 | - **Contact Details** (if using **Link** for payment). 44 | 45 | - If a **Party Bank Account** is selected, the payout will be made via **NEFT** by default. Otherwise, it will be made via **Link**. 46 | 47 | https://github.com/user-attachments/assets/5cf6cb2d-3e06-4042-8295-68caae710050 48 | 49 | **Note:** A maximum of 500 Payment Entries are supported for bulk payouts. 50 | -------------------------------------------------------------------------------- /docs/payout/4_bulk_submit_without_payout.md: -------------------------------------------------------------------------------- 1 | # 📦 Bulk Submit for Marked Online Payment 2 | 3 | ## 🛠️ How Bulk Submission Works 4 | 5 | 1. **Bulk Submit Payment Entries**: 6 | - When users bulk submit **Payment Entries** from the list view using the **Actions** menu (instead of **Pay and Submit**): 7 | - For Payment Entries with **Make Online Payment** checked, this option will be **automatically unchecked**. 8 | - The Payment Entries will be submitted **without initiating payouts**. 9 | 10 | 2. **Make Payout After Submission**: 11 | - After submission, users can still make payouts manually using the **Make Payout** button for each Payment Entry. 12 | 13 | https://github.com/user-attachments/assets/56502a54-8e94-4160-8f7f-4feefcc60852 14 | 15 | **Note**: The example above demonstrates the process for one Payment Entry, but the same applies to any number of entries. 16 | -------------------------------------------------------------------------------- /docs/payout/5_cancel_payout.md: -------------------------------------------------------------------------------- 1 | # 🚫 Workflow to Cancel Payout 2 | 3 | ## 🛠️ Cancellation Conditions 4 | 5 | - A **Payout** can only be canceled in the following states: 6 | 7 | 1. **Not Initiated** (A custom state defined in RazorpayX Integration) 8 | 2. **Queued** 9 | 10 | ## 📝 Steps to Cancel a Payout 11 | 12 | 1. **Cancel the Payment Entry**: 13 | - To cancel a payout, cancel the **Payment Entry** from which the payout was made. 14 | 15 | 2. **Confirmation Dialog**: 16 | - If the payout is in a cancellable state, a confirmation dialog will appear to confirm the cancellation. 17 | 18 | https://github.com/user-attachments/assets/0ea12c0f-6a5e-40c2-bbf5-eb829ba9ea76 19 | 20 | ## 📌 Notes 21 | 22 | - If **Auto Cancellation** is enabled in the `RazorpayX Configuration`, the dialog box will not be shown, and the payout will be **canceled automatically**. 23 | - You can cancel a `Payment Entry` regardless of the `RazorpayX Payout Status`. 24 | -------------------------------------------------------------------------------- /docs/payout/6_setup_status_notification.md: -------------------------------------------------------------------------------- 1 | # 🔔 Notifications 2 | 3 | ![Notification](https://github.com/user-attachments/assets/16efff38-56dd-4bb4-a146-36fdc08a6a23) 4 | 5 | ## 📩 Notification Types 6 | 7 | Two custom notifications are provided: 8 | 9 | 1. **Payout Failed/Reversed/Canceled**: 10 | - Sent to the user who initiated the payout. 11 | - Notifies them about the failure, reversal, or cancellation of the payout. 12 | 13 | 2. **Payout Processed**: 14 | - Sent to the **party** (Supplier/Employee etc.) if their contact email is available. 15 | - Notifies them about the successful processing of the payout. 16 | 17 | ## 🛠️ How to Enable Notifications 18 | 19 | 1. Go to **Notification** in ERPNext Site. 20 | 2. Enable the relevant notifications for **Payout Failed/Reversed/Canceled** and **Payout Processed**. 21 | 22 | ## 📌 Notes 23 | 24 | - Ensure the **party's contact email** is correctly configured to receive **Payout Processed** notifications. 25 | - Customize the notification templates to match your business needs. 26 | - Default `Outgoing EMail Account` is require to send notifications. 27 | -------------------------------------------------------------------------------- /docs/payout/7_payout_tips_and_notes.md: -------------------------------------------------------------------------------- 1 | # 💡 Payout Tips and Notes 2 | 3 | ## 🌐 Payout Link 4 | 5 | - To pay via **Payout Link**, choose the payment transfer method as **Link**. 6 | 7 | - Payouts created via **Payout Link** are managed as payouts, not as payout links. 8 | - **Example**: 9 | - Only **Payout** statuses are maintained as `RazorpayX Payout Status`. 10 | - The status will show as `Not Initiated` or `Queued` until the payout is created. 11 | - The payout is created when the party provides bank details via the link. 12 | - Once the payout is initiated, the status updates based on webhook events. 13 | - If the payout is **Canceled**, **Failed**, **Rejected**, or **Reversed**, the integration system will attempt to cancel the **Payout Link**. 14 | 15 | - **Party's Contact Details**: 16 | - To create a Payout Link, the party's contact details (email or mobile) are mandatory. 17 | - For **Employees**: The preferred email or mobile number must be set. 18 | - For others: Select the **Contact** details. 19 | 20 | ## 📝 General Notes 21 | 22 | - **Make Online Payment Checkbox**: 23 | - This checkbox appears after saving the Payment Entry (PE) for the first time if the integration is found via the Company's Bank Account and the user has the necessary permissions. 24 | 25 | - **Reconfiguring RazorpayX**: 26 | - If RazorpayX is configured after creating the PE, reselect the **Company Bank Account** and save the PE to set up the integration. 27 | 28 | - **Amended Payment Entries**: 29 | - If an **Amended Payment Entry** has its original PE marked for `Make Online Payment`, you cannot make a payout with the amended PE. 30 | - Payment details cannot be changed in such cases. 31 | - In future updates, if the original PE's payout is **Failed/Reversed/Canceled**, the amended PE will allow creating a payout. 32 | 33 | - **Payout or Payout Link Canceled/Failed**: 34 | - If a **Payout** or **Payout Link** is **Canceled/Failed** and the webhook event is captured, the Payment Entry will also be canceled. 35 | 36 | - **Payout Reversed**: 37 | - If a **Payout** is **Reversed**, only the payout status is updated, and the PE is not canceled. 38 | - Reversal Journal Entries (JE) for the Payment Entry and Fees Reversal JE will be created if configured. 39 | - For more details on Reversal Accounting, read [here](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/accounting/2_payout_reversal.md). 40 | 41 | - **Payout Description**: 42 | - Maximum length: 30 characters. 43 | - Allowed characters: `a-z`, `A-Z`, `0-9`, and spaces. 44 | 45 | - **UTR in Payment Entry**: 46 | - The **UTR** will be set after the payout is **Processed**. 47 | - Until then, the default placeholder will be: 48 | 49 | ```bash 50 | *** UTR WILL BE SET AUTOMATICALLY *** 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/reconcile/1_sync_and_reconcile_transactions.md: -------------------------------------------------------------------------------- 1 | # 🔄 Sync and Reconciliation Bank Transactions via RazorpayX API 2 | 3 | ## 🛠️ Manual Transaction Sync 4 | 5 | ### In RazorpayX Configuration 6 | 7 | - Click on **Sync Transaction** and select the desired time period. 8 | ![Sync Transaction](https://github.com/user-attachments/assets/e566444b-ffd4-4d99-9cc8-2e99b0ed6276) 9 | 10 | ### In the Bank Reconciliation Tool 11 | 12 | - Ensure the bank account is linked to a **RazorpayX Configuration** to enable transaction syncing. 13 | 14 | 📌 **Example:** 15 | 16 | https://github.com/user-attachments/assets/559d2d8f-4a07-4da2-bd79-57c85e6b8808 17 | 18 | ## 🔄 Automatic Sync 19 | 20 | - Transactions are automatically synced **daily** via a scheduled cron job. 21 | -------------------------------------------------------------------------------- /docs/report/1_payout_status_report.md: -------------------------------------------------------------------------------- 1 | # 📊 RazorpayX Payout Status Report 2 | 3 | This report provides a quick overview of: 4 | 5 | - **Payout Status** 6 | - **UTR Details** 7 | - **Payout Mode** etc... 8 | 9 | ## 🔍 Filters 10 | 11 | **You can filter the report by:** 12 | 13 | - **Posting Date**: Filter payouts by specific dates. 14 | - **Payout Status**: Filter by status (e.g., Processed, Failed, Queued). 15 | - **Doc Status**: Filter by document status (e.g., Submitted, Canceled). 16 | - **Payout Mode**: Filter by payment method (e.g., NEFT, UPI, Link). 17 | 18 | ## 🔎 Accessing the Report 19 | 20 | - In the **Search Bar (Awesome Bar)**, type `RazorpayX Payout Status` to view the report. 21 | 22 | **Example**: 23 | 24 | ![Payout Status Report](https://github.com/user-attachments/assets/3885bc79-771e-42a8-8ddf-aff6d2937dd7) 25 | -------------------------------------------------------------------------------- /docs/setup/1_setup_test_and_live_mode.md: -------------------------------------------------------------------------------- 1 | # Setup Test and Live Mode for RazorpayX Integration 2 | 3 | ## Live Mode (Production Mode) in RazorpayX Dashboard 4 | 5 | - **Purpose**: In Live Mode, actual money will be debited from your bank account. 6 | - **Use Case**: Use this mode only for real transactions in a production environment. 7 | - **Important**: 8 | - Do **not** share API keys and secrets generated in this mode. 9 | - Ensure proper security measures are in place to protect sensitive credentials. 10 | 11 | ## Test Mode in RazorpayX Dashboard 12 | 13 | - **Purpose**: In Test Mode, you can simulate transactions without debiting real money from your bank account. 14 | - **Test Balance**: Add test balance [here](https://x.razorpay.com/). 15 | ![Test Balance](https://github.com/user-attachments/assets/bb83b7bb-6feb-4a91-b710-24b5a1b4795e) 16 | 17 | - **How to Enable Test Mode**: 18 | 1. Go to [Developer Controls](https://x.razorpay.com/settings/developer-controls). 19 | 2. Enable **Test Mode**. 20 | ![Enable Test Mode](https://github.com/user-attachments/assets/0804ffdb-613b-4766-b7e0-592b90d780be) 21 | 22 | - **Test Mode Dashboard**: 23 | After enabling Test Mode, your dashboard will look like this: 24 | ![Test Mode Dashboard](https://github.com/user-attachments/assets/2308cd31-f587-4b7a-b83e-d7c9a355b8e7) 25 | 26 | - **Manually Change Payout Status in Test Mode**: 27 | - In Test Mode, payout statuses need to be changed manually. 28 | - You can only change the status if it is not in a final state. 29 | - Go to [Payouts](https://x.razorpay.com/payouts). 30 | - Hover over any payout and click on **Change Status**. 31 | ![Change Payout Status](https://github.com/user-attachments/assets/33aca0d8-aec2-49b8-9fd7-6fbb7411c68e) 32 | 33 | ## Setup Key ID and Key Secret 34 | 35 | - **For Both Modes**: 36 | The process to generate **Key ID** and **Key Secret** is the same for both Test and Live modes. 37 | - **Important**: Never share Live Mode API credentials. 38 | 39 | ## Setup Webhook 40 | 41 | - **For Both Modes**: 42 | The process to set up webhooks is the same for both Test and Live modes. 43 | 44 | - **Test Mode Specific**: 45 | When saving the webhook in Test Mode, use the fixed OTP: 46 | 47 | ```bash 48 | 754081 49 | ``` 50 | 51 | ## Setup Test and Live Mode in ERPNext Site 52 | 53 | 1. **Add Credentials**: 54 | - For each mode (Test or Live), add the corresponding **Key ID** and **Key Secret** in the ERPNext RazorpayX Configuration. 55 | 56 | 2. **Setup Webhook**: 57 | - Configure the webhook URL for each mode separately. 58 | 59 | ## Local Testing 60 | 61 | - **Use Test Mode Credentials**: 62 | - For local testing, always use Test Mode credentials to avoid real transactions. 63 | 64 | - **Live Webhook URL for Local Testing**: 65 | To test live webhooks locally, use a tool like **Ngrok**. Replace the URL in the webhook configuration as follows: 66 | 67 | Follow this [Guide](https://discuss.frappe.io/t/guide-for-using-ngrok-for-webhook-testing/141902) to get the Ngrok URL. 68 | 69 | ```bash 70 | NGROK_URL/api/method/razorpayx_integration.razorpayx_integration.utils.webhook.webhook_listener 71 | ``` 72 | 73 | Replace `NGROK_URL` with the URL generated via Ngrok. 74 | -------------------------------------------------------------------------------- /docs/setup/2_connect_erpnext_with_razorpayx.md: -------------------------------------------------------------------------------- 1 | # 🚀 Connect ERPNext with RazorpayX 2 | 3 | ## 📝 Step 1: Create a Company Bank Account with RazorpayX Details 4 | 5 | If you already have a bank account configured, you can skip this step. 6 | 7 | ### Mandatory Fields 8 | 9 | - **Is Company Account**: Enable this option. 10 | - **Bank Account No.**: Add your bank account number associated with RazorpayX. 11 | 12 | ![ERPNext Bank Account Doc](https://github.com/user-attachments/assets/78267bd9-6705-472d-a066-d42a5d170031) 13 | 14 | ### For Test Mode 15 | 16 | - **Bank Account No.**: Use the **Customer Identifier** from your RazorpayX account. [Get it from here](https://x.razorpay.com/settings/banking). 17 | 18 | ![Customer Identifier](https://github.com/user-attachments/assets/9d03d92f-7ca7-4dc9-9641-5f6263d90362) 19 | 20 | ### For Live/Production Mode 21 | 22 | - **Bank Account No.**: Use your **Current Account Number** or **Customer Identifier** as per your requirement. 23 | ![Bank Account No. Type](https://github.com/user-attachments/assets/86a992c6-f30b-4ef3-91e3-264b92b6d4f8) 24 | 25 | ## ⚙️ Step 2: Create a RazorpayX Configuration 26 | 27 | 1. In your ERPNext site, search for `RazorpayX Configuration` in the search bar and open the list view. 28 | 2. Add a new configuration. 29 | 30 | ![RazorpayX Configuration Document](https://github.com/user-attachments/assets/7a64d65f-dc00-41cf-bd70-9daa7a587da0) 31 | 32 | ### Get API Credentials 33 | 34 | - **API Key** and **API Secret**: 35 | - If not available, [generate them from here](https://x.razorpay.com/settings/developer-controls). 36 | - **Note**: For first-time generation, a direct button is available to create the `KEY` and `SECRET`. 37 | 38 | ![Key ID and Secret Generation](https://github.com/user-attachments/assets/ddfc1b0d-24f2-4213-89fd-dbe7ce40fab1) 39 | 40 | - **Account ID**: 41 | - This is your **Business ID** provided by RazorpayX. [Get it from here](https://x.razorpay.com/settings/business). 42 | 43 | ![Account ID](https://github.com/user-attachments/assets/d13001a2-a128-4d91-99ee-07ff08a4c56d) 44 | 45 | ### 🌐 Set Up Webhooks 46 | 47 | Webhooks are used for real-time payout status updates. 48 | 49 | 1. **Copy Webhook URL**: Click the `Copy Webhook URL` button in the RazorpayX Configuration. 50 | ![Copy Webhook URL](https://github.com/user-attachments/assets/c5dbba85-9289-4031-ae63-efaa647d3852) 51 | 52 | 2. **Add Webhook URL to RazorpayX Dashboard**: Paste the URL [here](https://x.razorpay.com/settings/developer-controls). 53 | ![RazorpayX Dashboard for Webhook](https://github.com/user-attachments/assets/08d25a69-87c2-46f7-95d4-27a5c30d4f75) 54 | 55 | - **Note**: Only enable supported webhooks. 56 | 57 | 3. **Supported Webhooks (11 Events)**: 58 | 59 | ```shell 60 | # Payout 61 | - payout.pending 62 | - payout.rejected 63 | - payout.queued 64 | - payout.initiated 65 | - payout.processed 66 | - payout.reversed 67 | - payout.failed 68 | # Payout Link 69 | - payout_link.cancelled 70 | - payout_link.rejected 71 | - payout_link.expired 72 | # Transaction 73 | - transaction.created 74 | ``` 75 | 76 | For more details, visit [RazorpayX Webhook Events](https://razorpay.com/docs/x/apis/subscribe/#webhook-events-and-descriptions). 77 | 78 | 4. **Add Webhook Secret**: Ensure you add the webhook secret; otherwise, ERPNext won’t receive updates. 79 | - A strong webhook secret is recommended for enhanced security. 80 | 81 | **Note**: 82 | 83 | - Test and Live modes require different **API Keys** and **Webhook URLs**. 84 | - For more details on Test and Live modes, [visit here](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/setup/1_setup_test_and_live_mode.md). 85 | 86 | ### 🏦 Set Company Bank Account 87 | 88 | - Set the bank account that is associated with RazorpayX. 89 | 90 | - Set **Payouts From** 91 | 92 | - Two options are available: 93 | 1. **Current Account** 94 | 2. **RazorpayX Lite** 95 | 96 | - **Current Account**: 97 | - If the **Current Account Number** is entered in the company's bank account details, the payout amount is deducted from the bank's current account. 98 | 99 | - **RazorpayX Lite**: 100 | - If the **Customer Identifier** is entered, the payout amount is deducted from the RazorpayX Lite account. 101 | 102 | - **Default Selection**: **Current Account** is selected by default. 103 | 104 | ### 🤖 Configure Automation 105 | 106 | 1. **Automatically Cancel Payout on Payment Entry Cancellation** 107 | - If checked, the payout and payout link will be canceled automatically upon Payment Entry cancellation (helpful for bulk cancellations). 108 | - If unchecked, a confirmation dialog will appear for single Payment Entry cancellations if the payout or payout link is cancellable. 109 | - See [Cancellation Workflow](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/payout/5_cancel_payout.md) for more details. 110 | 111 | 2. **Pay on Auto Submit** 112 | - This feature is only available if the [Payments Processor](https://github.com/resilient-tech/payments-processor) app is installed. 113 | - **Checked** ☑️ by default. 114 | - If a `Payment Entry` is submitted via automation, the payout will be made if `Make Online Payment` is checked in the Payment Entry. 115 | - If unchecked and the Payment Entry is submitted with the `initiated_by_payment_processor` flag, the payout will not be made, and `Make Online Payment` will be unchecked. 116 | - ![Pay On Auto Submit](https://github.com/user-attachments/assets/cf204193-cf55-4715-a9e7-770fa3937dc0) 117 | 118 | ### 🤖 Accounting 119 | 120 | - For detailed information, see [Accounting with RazorpayX Integration](https://github.com/resilient-tech/razorpayx-integration/blob/version-15/docs/accounting). 121 | 122 | --- 123 | 124 | ### ⚙️ Multiple configurations with different bank accounts for the same company associated with RazorpayX are allowed! 125 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "razorpayx_integration" 3 | authors = [ 4 | { name = "Resilient Tech", email = "info@resilient.tech"} 5 | ] 6 | description = "Automat Payments By RazorpayX API For Frappe Apps" 7 | requires-python = ">=3.10" 8 | readme = "README.md" 9 | dynamic = ["version"] 10 | 11 | 12 | [build-system] 13 | requires = ["flit_core >=3.4,<4"] 14 | build-backend = "flit_core.buildapi" 15 | 16 | [tool.ruff.lint] 17 | select = [ 18 | "F", 19 | "E", 20 | "W", 21 | "I", 22 | "UP", 23 | "B", 24 | "RUF", 25 | ] 26 | 27 | ignore = [ 28 | "E501", # line too long 29 | "F401", # module imported but unused 30 | "F403", # can't detect undefined names from * import 31 | "F405", # can't detect undefined names from * import 32 | ] 33 | 34 | typing-modules = ["frappe.types.DF"] 35 | 36 | [tool.ruff.format] 37 | quote-style = "double" 38 | indent-style = "space" 39 | docstring-code-format = true 40 | 41 | # These dependencies are only installed when developer mode is enabled 42 | [tool.bench.dev-dependencies] 43 | # package_name = "~=1.1.0" 44 | 45 | [tool.bench.frappe-dependencies] 46 | frappe = ">=15.0.0,<16.0.0" 47 | erpnext = ">=15.0.0,<16.0.0" 48 | -------------------------------------------------------------------------------- /razorpayx_integration/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "16.0.0-dev" 2 | -------------------------------------------------------------------------------- /razorpayx_integration/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/config/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/config/desktop.py: -------------------------------------------------------------------------------- 1 | from frappe import _ 2 | 3 | 4 | def get_data(): 5 | return [ 6 | { 7 | "module_name": "Razorpayx Integration", 8 | "type": "module", 9 | "label": _("Razorpayx Integration"), 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /razorpayx_integration/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/razorpayx_integration" 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 = "Razorpayx Integration" 12 | -------------------------------------------------------------------------------- /razorpayx_integration/constants.py: -------------------------------------------------------------------------------- 1 | BUG_REPORT_URL = "https://github.com/resilient-tech/razorpayx_integration/issues/new" 2 | 3 | RAZORPAYX_CONFIG = "RazorpayX Configuration" 4 | 5 | PAYMENTS_PROCESSOR_APP = "payments_processor" 6 | -------------------------------------------------------------------------------- /razorpayx_integration/hooks.py: -------------------------------------------------------------------------------- 1 | app_name = "razorpayx_integration" 2 | app_title = "RazorpayX Integration" 3 | app_publisher = "Resilient Tech" 4 | app_description = "Automat Payments By RazorpayX API For Frappe Apps" 5 | app_email = "info@resilient.tech" 6 | app_license = "GNU General Public License (v3)" 7 | required_apps = ["frappe/erpnext", "resilient-tech/payment_integration_utils"] 8 | 9 | after_install = "razorpayx_integration.install.after_install" 10 | before_uninstall = "razorpayx_integration.uninstall.before_uninstall" 11 | 12 | after_app_install = "razorpayx_integration.install.after_app_install" 13 | before_app_uninstall = "razorpayx_integration.uninstall.before_app_uninstall" 14 | 15 | app_include_js = "razorpayx_integration.bundle.js" 16 | 17 | export_python_type_annotations = True 18 | 19 | doctype_js = { 20 | "Payment Entry": "razorpayx_integration/client_overrides/form/payment_entry.js", 21 | "Bank Reconciliation Tool": "razorpayx_integration/client_overrides/form/bank_reconciliation_tool.js", 22 | } 23 | 24 | 25 | doc_events = { 26 | "Payment Entry": { 27 | "onload": "razorpayx_integration.razorpayx_integration.server_overrides.doctype.payment_entry.onload", 28 | "validate": "razorpayx_integration.razorpayx_integration.server_overrides.doctype.payment_entry.validate", 29 | "before_submit": "razorpayx_integration.razorpayx_integration.server_overrides.doctype.payment_entry.before_submit", 30 | "on_submit": "razorpayx_integration.razorpayx_integration.server_overrides.doctype.payment_entry.on_submit", 31 | "before_cancel": "razorpayx_integration.razorpayx_integration.server_overrides.doctype.payment_entry.before_cancel", 32 | }, 33 | } 34 | 35 | scheduler_events = { 36 | "daily": [ 37 | "razorpayx_integration.razorpayx_integration.utils.bank_transaction.sync_transactions_periodically" 38 | ] 39 | } 40 | 41 | payment_integration_fields = [ 42 | "razorpayx_payout_desc", 43 | "razorpayx_payout_status", 44 | "razorpayx_payout_id", 45 | "razorpayx_payout_link_id", 46 | ] 47 | -------------------------------------------------------------------------------- /razorpayx_integration/install.py: -------------------------------------------------------------------------------- 1 | import click 2 | import frappe 3 | 4 | from razorpayx_integration.constants import BUG_REPORT_URL, PAYMENTS_PROCESSOR_APP 5 | from razorpayx_integration.hooks import app_title as APP_NAME 6 | from razorpayx_integration.setup import ( 7 | create_payments_processor_custom_fields, 8 | setup_customizations, 9 | ) 10 | 11 | POST_INSTALL_PATCHES = [] 12 | 13 | 14 | def after_install(): 15 | try: 16 | setup_customizations() 17 | run_post_install_patches() 18 | 19 | except Exception as e: 20 | click.secho( 21 | ( 22 | f"Installation of {APP_NAME} failed due to an error. " 23 | "Please try re-installing the app or " 24 | f"report the issue on {BUG_REPORT_URL} if not resolved." 25 | ), 26 | fg="bright_red", 27 | ) 28 | raise e 29 | 30 | click.secho(f"Thank you for installing {APP_NAME}!!\n", fg="green") 31 | 32 | 33 | def run_post_install_patches(): 34 | if not POST_INSTALL_PATCHES: 35 | return 36 | 37 | click.secho("Running post-install patches...", fg="yellow") 38 | 39 | if not frappe.db.exists("Company", {"country": "India"}): 40 | return 41 | 42 | frappe.flags.in_patch = True 43 | 44 | try: 45 | for patch in POST_INSTALL_PATCHES: 46 | patch_module = f"razorpayx_integration.patches.post_install.{patch}.execute" 47 | frappe.get_attr(patch_module)() 48 | 49 | finally: 50 | frappe.flags.in_patch = False 51 | 52 | 53 | def after_app_install(app_name): 54 | if app_name == PAYMENTS_PROCESSOR_APP: 55 | create_payments_processor_custom_fields() 56 | -------------------------------------------------------------------------------- /razorpayx_integration/modules.txt: -------------------------------------------------------------------------------- 1 | Razorpayx Integration -------------------------------------------------------------------------------- /razorpayx_integration/patches.txt: -------------------------------------------------------------------------------- 1 | [pre_model_sync] 2 | 3 | 4 | [post_model_sync] 5 | execute:from razorpayx_integration.setup import create_custom_fields; create_custom_fields() # 1 6 | execute:from razorpayx_integration.setup import create_property_setters; create_property_setters() # 1 7 | execute:from razorpayx_integration.setup import create_roles_and_permissions; create_roles_and_permissions() 8 | razorpayx_integration.patches.set_payment_transfer_method 9 | razorpayx_integration.patches.delete_old_custom_fields 10 | razorpayx_integration.patches.delete_old_property_setters 11 | razorpayx_integration.patches.update_integration_doctype 12 | razorpayx_integration.patches.set_default_payouts_from 13 | razorpayx_integration.patches.mark_creation_of_je_on_reversal 14 | -------------------------------------------------------------------------------- /razorpayx_integration/patches/delete_old_custom_fields.py: -------------------------------------------------------------------------------- 1 | from payment_integration_utils.payment_integration_utils.setup import ( 2 | delete_custom_fields, 3 | ) 4 | 5 | FIELDS_TO_DELETE = { 6 | "Payment Entry": ["razorpayx_payout_mode", "razorpayx_pay_instantaneously"], 7 | "Bank Account": [ 8 | "online_payment_section", 9 | "online_payment_mode", 10 | "online_payment_cb", 11 | ], 12 | } 13 | 14 | 15 | def execute(): 16 | delete_custom_fields(FIELDS_TO_DELETE) 17 | -------------------------------------------------------------------------------- /razorpayx_integration/patches/delete_old_property_setters.py: -------------------------------------------------------------------------------- 1 | from payment_integration_utils.payment_integration_utils.setup import ( 2 | delete_property_setters, 3 | ) 4 | 5 | PROPERTY_SETTERS_TO_DELETE = [ 6 | ## Payment Entry ## 7 | { 8 | "doctype": "Payment Entry", 9 | "fieldname": "contact_person", 10 | "property": "mandatory_depends_on", 11 | }, 12 | { 13 | "doctype": "Payment Entry", 14 | "fieldname": "party_upi_id", 15 | "property": "mandatory_depends_on", 16 | }, 17 | { 18 | "doctype": "Payment Entry", 19 | "fieldname": "party_upi_id", 20 | "property": "depends_on", 21 | }, 22 | { 23 | "doctype": "Payment Entry", 24 | "fieldname": "party_bank_account_no", 25 | "property": "mandatory_depends_on", 26 | }, 27 | { 28 | "doctype": "Payment Entry", 29 | "fieldname": "party_bank_account_no", 30 | "property": "depends_on", 31 | }, 32 | { 33 | "doctype": "Payment Entry", 34 | "fieldname": "party_bank_ifsc", 35 | "property": "mandatory_depends_on", 36 | }, 37 | { 38 | "doctype": "Payment Entry", 39 | "fieldname": "party_bank_ifsc", 40 | "property": "depends_on", 41 | }, 42 | ## Bank Account ## 43 | { 44 | "doctype": "Bank Account", 45 | "fieldname": "online_payment_mode", 46 | "property": "options", 47 | }, 48 | { 49 | "doctype": "Bank Account", 50 | "fieldname": "online_payment_mode", 51 | "property": "description", 52 | }, 53 | { 54 | "doctype": "Bank Account", 55 | "fieldname": "online_payment_mode", 56 | "property": "depends_on", 57 | }, 58 | { 59 | "doctype": "Bank Account", 60 | "fieldname": "bank_account_no", 61 | "property": "mandatory_depends_on", 62 | }, 63 | { 64 | "doctype": "Bank Account", 65 | "fieldname": "branch_code", 66 | "property": "mandatory_depends_on", 67 | }, 68 | ] 69 | 70 | 71 | def execute(): 72 | delete_property_setters(PROPERTY_SETTERS_TO_DELETE) 73 | -------------------------------------------------------------------------------- /razorpayx_integration/patches/mark_creation_of_je_on_reversal.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from razorpayx_integration.constants import RAZORPAYX_CONFIG 4 | 5 | 6 | def execute(): 7 | frappe.db.set_value(RAZORPAYX_CONFIG, {}, "create_je_on_reversal", 1) 8 | -------------------------------------------------------------------------------- /razorpayx_integration/patches/post_install/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/patches/post_install/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/patches/set_default_payouts_from.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from razorpayx_integration.constants import RAZORPAYX_CONFIG 4 | from razorpayx_integration.razorpayx_integration.constants.payouts import PAYOUT_FROM 5 | 6 | 7 | def execute(): 8 | frappe.db.set_value( 9 | RAZORPAYX_CONFIG, {}, "payouts_from", PAYOUT_FROM.CURRENT_ACCOUNT.value 10 | ) 11 | -------------------------------------------------------------------------------- /razorpayx_integration/patches/set_payment_transfer_method.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from payment_integration_utils.payment_integration_utils.constants.payments import ( 3 | TRANSFER_METHOD, 4 | ) 5 | 6 | 7 | def execute(): 8 | payment_entries = frappe.get_all( 9 | "Payment Entry", 10 | filters={"make_bank_online_payment": 1}, 11 | fields=[ 12 | "name", 13 | "razorpayx_payout_mode", 14 | "razorpayx_pay_instantaneously", 15 | "paid_amount", 16 | ], 17 | ) 18 | 19 | if not payment_entries: 20 | return 21 | 22 | updated_payment_entries = {} 23 | 24 | for payment_entry in payment_entries: 25 | if payment_entry.razorpayx_payout_mode in [ 26 | TRANSFER_METHOD.LINK.value, 27 | TRANSFER_METHOD.UPI.value, 28 | ]: 29 | transfer_method = payment_entry.razorpayx_payout_mode 30 | elif payment_entry.razorpayx_pay_instantaneously: 31 | transfer_method = TRANSFER_METHOD.IMPS.value 32 | elif payment_entry.paid_amount > 2_00_000: 33 | transfer_method = TRANSFER_METHOD.RTGS.value 34 | else: 35 | transfer_method = TRANSFER_METHOD.NEFT.value 36 | 37 | updated_payment_entries[payment_entry.name] = { 38 | "payment_transfer_method": transfer_method, 39 | } 40 | 41 | if updated_payment_entries: 42 | frappe.db.bulk_update("Payment Entry", updated_payment_entries) 43 | -------------------------------------------------------------------------------- /razorpayx_integration/patches/update_integration_doctype.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from razorpayx_integration.constants import RAZORPAYX_CONFIG 4 | 5 | 6 | def execute(): 7 | frappe.db.set_value( 8 | "Payment Entry", 9 | { 10 | "integration_doctype": "RazorpayX Integration Setting", 11 | }, 12 | "integration_doctype", 13 | RAZORPAYX_CONFIG, 14 | ) 15 | -------------------------------------------------------------------------------- /razorpayx_integration/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/public/.gitkeep -------------------------------------------------------------------------------- /razorpayx_integration/public/images/razorpayx-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/public/images/razorpayx-logo.png -------------------------------------------------------------------------------- /razorpayx_integration/public/js/razorpayx_integration.bundle.js: -------------------------------------------------------------------------------- 1 | import "./utils"; 2 | -------------------------------------------------------------------------------- /razorpayx_integration/public/js/utils.js: -------------------------------------------------------------------------------- 1 | frappe.provide("razorpayx"); 2 | 3 | const RAZORPAYX_CONFIG = "RazorpayX Configuration"; 4 | const DESCRIPTION_REGEX = /^[a-zA-Z0-9 ]{1,30}$/; 5 | const PAYOUT_STATUS = { 6 | "Not Initiated": "grey", 7 | Queued: "yellow", 8 | Pending: "yellow", 9 | Scheduled: "yellow", 10 | Processing: "blue", 11 | Processed: "green", 12 | Failed: "red", 13 | Cancelled: "red", 14 | Rejected: "red", 15 | Reversed: "red", 16 | }; 17 | 18 | Object.assign(razorpayx, { 19 | RAZORPAYX_CONFIG, 20 | 21 | PAYOUT_STATUS, 22 | 23 | async get_razorpayx_config(bank_account, fields = "name") { 24 | const response = await frappe.db.get_value( 25 | RAZORPAYX_CONFIG, 26 | { bank_account: bank_account, disabled: 0 }, 27 | fields 28 | ); 29 | 30 | return response.message; 31 | }, 32 | 33 | is_payout_via_razorpayx(doc) { 34 | return ( 35 | doc.make_bank_online_payment && 36 | doc.integration_doctype === RAZORPAYX_CONFIG && 37 | doc.integration_docname 38 | ); 39 | }, 40 | 41 | validate_payout_description(description) { 42 | if (!description || DESCRIPTION_REGEX.test(description)) return; 43 | 44 | frappe.throw({ 45 | message: __( 46 | "Must be alphanumeric and contain spaces only, with a maximum of 30 characters." 47 | ), 48 | title: __("Invalid RazorpayX Payout Description"), 49 | }); 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/apis/base.py: -------------------------------------------------------------------------------- 1 | import re 2 | from urllib.parse import urljoin 3 | 4 | import frappe 5 | import frappe.utils 6 | import requests 7 | from frappe import _ 8 | from frappe.app import UNSAFE_HTTP_METHODS 9 | from payment_integration_utils.payment_integration_utils.constants.enums import BaseEnum 10 | from payment_integration_utils.payment_integration_utils.utils import ( 11 | enqueue_integration_request, 12 | get_end_of_day_epoch, 13 | get_start_of_day_epoch, 14 | ) 15 | 16 | from razorpayx_integration.constants import ( 17 | RAZORPAYX_CONFIG, 18 | ) 19 | from razorpayx_integration.razorpayx_integration.doctype.razorpayx_configuration.razorpayx_configuration import ( 20 | RazorpayXConfiguration, 21 | ) 22 | 23 | RAZORPAYX_BASE_API_URL = "https://api.razorpay.com/v1/" 24 | 25 | 26 | class SUPPORTED_HTTP_METHOD(BaseEnum): 27 | GET = "GET" 28 | DELETE = "DELETE" 29 | POST = "POST" 30 | PUT = "PUT" 31 | PATCH = "PATCH" 32 | 33 | 34 | class BaseRazorpayXAPI: 35 | """ 36 | Base class for RazorpayX APIs. 37 | 38 | Must need `RazorpayX Integration Account` name to initiate API. 39 | 40 | :param config: RazorpayX Configuration name. 41 | """ 42 | 43 | ### CLASS ATTRIBUTES ### 44 | BASE_PATH = "" 45 | 46 | ### SETUP ### 47 | def __init__(self, config: str, *args, **kwargs): 48 | """ 49 | Initialize the RazorpayX API. 50 | 51 | :param config: RazorpayX Configuration name. 52 | """ 53 | self.razorpayx_config: RazorpayXConfiguration = frappe.get_doc( 54 | RAZORPAYX_CONFIG, config 55 | ) 56 | 57 | self.authenticate_razorpayx_config() 58 | 59 | self.auth = ( 60 | self.razorpayx_config.key_id, 61 | self.razorpayx_config.get_password("key_secret"), 62 | ) 63 | self.source_doctype = None # Source doctype for Integration Request Log 64 | self.source_docname = None # Source docname for Integration Request Log 65 | self.default_headers = {} # Default headers for API request 66 | self.default_log_values = {} # Show value in Integration Request Log 67 | self.ir_service_set = False # Service details in IR log has been set or not 68 | self.sensitive_infos = () # Sensitive info to mask in Integration Request Log 69 | self.place_holder = "************" 70 | 71 | self.setup(*args, **kwargs) 72 | 73 | def authenticate_razorpayx_config(self): 74 | """ 75 | Check config is enabled or not? 76 | 77 | Check RazorpayX API credentials `Id` and `Secret` are set or not? 78 | """ 79 | if self.razorpayx_config.disabled: 80 | frappe.throw( 81 | msg=_("To use {0} config, please enable it first!").format( 82 | frappe.bold(self.razorpayx_config.name) 83 | ), 84 | title=_("RazorpayX Configuration Is Disable"), 85 | ) 86 | 87 | if not self.razorpayx_config.key_id or not self.razorpayx_config.key_secret: 88 | frappe.throw( 89 | msg=_("Please set RazorpayX API credentials."), 90 | title=_("API Credentials Are Missing"), 91 | ) 92 | 93 | if not self.razorpayx_config.webhook_secret: 94 | frappe.msgprint( 95 | msg=_( 96 | "RazorpayX Webhook Secret is missing!
You will not receive any updates!" 97 | ), 98 | indicator="yellow", 99 | alert=True, 100 | ) 101 | 102 | def setup(self, *args, **kwargs): 103 | """ 104 | Override this method to setup API specific configurations. 105 | """ 106 | pass 107 | 108 | ### APIs ### 109 | def get(self, *args, **kwargs): 110 | """ 111 | Make `GET` HTTP request. 112 | """ 113 | return self._make_request(SUPPORTED_HTTP_METHOD.GET.value, *args, **kwargs) 114 | 115 | def delete(self, *args, **kwargs): 116 | """ 117 | Make `DELETE` HTTP request. 118 | """ 119 | return self._make_request(SUPPORTED_HTTP_METHOD.DELETE.value, *args, **kwargs) 120 | 121 | def post(self, *args, **kwargs): 122 | """ 123 | Make `POST` HTTP request. 124 | """ 125 | return self._make_request(SUPPORTED_HTTP_METHOD.POST.value, *args, **kwargs) 126 | 127 | def put(self, *args, **kwargs): 128 | """ 129 | Make `PUT` HTTP request. 130 | """ 131 | return self._make_request(SUPPORTED_HTTP_METHOD.PUT.value, *args, **kwargs) 132 | 133 | def patch(self, *args, **kwargs): 134 | """ 135 | Make `PATCH` HTTP request. 136 | """ 137 | return self._make_request(SUPPORTED_HTTP_METHOD.PATCH.value, *args, **kwargs) 138 | 139 | ### API WRAPPERS ### 140 | # TODO: should add `skip` in filters (Handle pagination + if not given fetch all) (Change in sub class) 141 | def get_all( 142 | self, filters: dict | None = None, count: int | None = None 143 | ) -> list[dict] | None: 144 | """ 145 | Fetches all data of given RazorpayX account for specific API. 146 | 147 | :param filters: Filters for fetching filtered response. 148 | :param count: Total number of item to be fetched.If not given fetches all. 149 | """ 150 | MAX_LIMIT = 100 151 | 152 | if filters: 153 | self._clean_request(filters) 154 | self._set_epoch_time_for_date_filters(filters) 155 | self._validate_and_process_filters(filters) 156 | 157 | else: 158 | filters = {} 159 | 160 | if isinstance(count, int) and count <= 0: 161 | frappe.throw( 162 | _("Count can't be {0}").format(frappe.bold(count)), 163 | title=_("Invalid Count To Fetch Data"), 164 | ) 165 | 166 | if count and count <= MAX_LIMIT: 167 | filters["count"] = count 168 | return self._fetch(filters) 169 | 170 | if count is None: 171 | FETCH_ALL_ITEMS = True 172 | else: 173 | FETCH_ALL_ITEMS = False 174 | 175 | result = [] 176 | filters["count"] = MAX_LIMIT 177 | filters["skip"] = 0 178 | 179 | while True: 180 | items = self._fetch(filters) 181 | 182 | if items and isinstance(items, list): 183 | result.extend(items) 184 | else: 185 | break 186 | 187 | if len(items) < MAX_LIMIT: 188 | break 189 | 190 | if not FETCH_ALL_ITEMS: 191 | count -= len(items) 192 | if count <= 0: 193 | break 194 | 195 | filters["skip"] += MAX_LIMIT 196 | 197 | return result 198 | 199 | ### BASES ### 200 | def _make_request( 201 | self, 202 | method: str, 203 | endpoint: str = "", 204 | params: dict | None = None, 205 | headers: dict | None = None, 206 | json: dict | None = None, 207 | ): 208 | """ 209 | Base for making HTTP request. 210 | 211 | Process headers,params and data then make request and return processed response. 212 | """ 213 | method = method.upper() 214 | if method not in SUPPORTED_HTTP_METHOD.values(): 215 | frappe.throw(_("Invalid method {0}").format(method)) 216 | 217 | request_args = frappe._dict( 218 | url=self.get_url(endpoint), 219 | params=params, 220 | headers={ 221 | **self.default_headers, 222 | **(headers or {}), 223 | }, 224 | auth=self.auth, 225 | ) 226 | 227 | # preparing log for Integration Request 228 | self._set_source_to_ir_log() 229 | 230 | ir_log = frappe._dict( 231 | **self.default_log_values, 232 | url=request_args.url, 233 | data=request_args.params, 234 | request_headers=request_args.headers.copy(), 235 | ) 236 | 237 | if method in UNSAFE_HTTP_METHODS and json: 238 | request_args.json = json 239 | 240 | copied_json = json.copy() 241 | 242 | if not request_args.params: 243 | ir_log.data = copied_json 244 | else: 245 | ir_log.data = { 246 | "params": request_args.params, 247 | "body": copied_json, 248 | } 249 | 250 | response_json = None 251 | 252 | try: 253 | self._before_request(request_args) 254 | 255 | response = requests.request(method, **request_args) 256 | response_json = response.json(object_hook=frappe._dict) 257 | 258 | if response.status_code >= 400: 259 | self._handle_failed_api_response(response_json) 260 | 261 | # Raise HTTPError for other HTTP codes 262 | response.raise_for_status() 263 | 264 | return response_json 265 | 266 | except Exception as e: 267 | ir_log.error = str(e) 268 | raise e 269 | finally: 270 | if response_json: 271 | ir_log.output = response_json.copy() 272 | 273 | self._mask_sensitive_info(ir_log) 274 | 275 | if not ir_log.integration_request_service: 276 | ir_log.integration_request_service = "RazorpayX Integration" 277 | 278 | enqueue_integration_request(**ir_log) 279 | 280 | def _fetch(self, params: dict) -> list: 281 | """ 282 | Fetches `items` from the API response based on the given parameters. 283 | """ 284 | response = self.get(params=params) 285 | return response.get("items", []) 286 | 287 | ### API HELPERS ### 288 | def get_url(self, *path_segments): 289 | """ 290 | Generate particular API's URL by combing given path_segments. 291 | 292 | Example: 293 | if path_segments = 'contact/old' then 294 | URL will `RAZORPAYX_BASE_URL/BASE_PATH/contact/old` 295 | """ 296 | 297 | path_segments = list(path_segments) 298 | 299 | if self.BASE_PATH: 300 | path_segments.insert(0, self.BASE_PATH) 301 | 302 | return urljoin( 303 | RAZORPAYX_BASE_API_URL, 304 | "/".join(segment.strip("/") for segment in path_segments), 305 | ) 306 | 307 | def _before_request(self, request_args): 308 | """ 309 | Override in sub class to perform any operation before making the request. 310 | """ 311 | return 312 | 313 | def _clean_request(self, filters: dict): 314 | """ 315 | Cleans the request filters by removing any key-value pairs where 316 | the value is falsy. 317 | """ 318 | keys_to_delete = [key for key, value in filters.items() if not value] 319 | 320 | for key in keys_to_delete: 321 | del filters[key] 322 | 323 | def _set_epoch_time_for_date_filters(self, filters: dict): 324 | """ 325 | Converts the date filters `from` and `to` to epoch time (Unix timestamp). 326 | """ 327 | if from_date := filters.get("from"): 328 | filters["from"] = get_start_of_day_epoch(from_date) 329 | 330 | if to_date := filters.get("to"): 331 | filters["to"] = get_end_of_day_epoch(to_date) 332 | 333 | def _validate_and_process_filters(self, filters: dict): 334 | """ 335 | Override in sub class to validate and process filters, except date filters (from,to). 336 | 337 | Validation happen before `get_all()` to reduce API calls. 338 | """ 339 | pass 340 | 341 | def sanitize_party_name(self, party_name: str) -> str: 342 | """ 343 | Convert the given ERPNext party name to a valid RazorpayX Contact Name. 344 | 345 | - Replace unsupported characters with `-`. 346 | - Remove special characters from the start and end of the name. 347 | - Trim the name to 50 characters. 348 | - If the name is less than 3 characters, append `.` to the name. 349 | 350 | :param contact_name: ERPNext party name. 351 | 352 | --- 353 | - Supported characters: `a-z`, `A-Z`, `0-9`, `space`, `'` , `-` , `_` , `/` , `(` , `)` and `.` 354 | """ 355 | # replace unsupported characters with `-` 356 | party_name = re.sub(r"[^a-zA-Z0-9\s'._/()-]", "-", party_name) 357 | 358 | # remove special characters from the start and end 359 | party_name = re.sub(r"^[^a-zA-Z0-9]+|[^a-zA-Z0-9.]+$", "", party_name.strip()) 360 | 361 | return party_name[:50].ljust(3, ".") 362 | 363 | ### LOGGING ### 364 | def _set_service_details_to_ir_log( 365 | self, service_name: str, service_set: bool = True 366 | ): 367 | """ 368 | Set the service details in the Integration Request Log. 369 | 370 | :param service_name: The service name. 371 | :param service_set: Set flag that service name for Integration request has been set or not. 372 | """ 373 | self.default_log_values.update( 374 | {"integration_request_service": f"RazorpayX - {service_name}"} 375 | ) 376 | 377 | self.ir_service_set = service_set 378 | 379 | def _set_source_to_ir_log(self): 380 | """ 381 | Set the source document details in the Integration Request Log. 382 | """ 383 | if not (self.source_doctype and self.source_docname): 384 | return 385 | 386 | self.default_log_values.update( 387 | { 388 | "reference_doctype": self.source_doctype, 389 | "reference_name": self.source_docname, 390 | } 391 | ) 392 | 393 | def _mask_sensitive_info(self, ir_log: dict): 394 | """ 395 | Mask sensitive information in the Integration Request Log. 396 | """ 397 | pass 398 | 399 | ### ERROR HANDLING ### 400 | def _handle_failed_api_response(self, response_json: dict | None = None): 401 | """ 402 | Handle failed API response from RazorpayX. 403 | 404 | --- 405 | Error response format: 406 | ```py 407 | { 408 | "error": { 409 | "code": "SERVER_ERROR", 410 | "description": "Server Is Down", 411 | "source": "NA", 412 | "step": "NA", 413 | "reason": "NA", 414 | "metadata": {}, 415 | }, 416 | } 417 | ``` 418 | 419 | --- 420 | Reference: https://razorpay.com/docs/errors/#sample-code 421 | """ 422 | error_msg = "There is some error in RazorpayX" 423 | title = _("RazorpayX API Failed") 424 | 425 | if response_json: 426 | error_msg = ( 427 | response_json.get("message") 428 | or response_json.get("error", {}).get("description") 429 | or error_msg 430 | ) 431 | 432 | self._handle_custom_error(error_msg, title=title) 433 | 434 | frappe.throw( 435 | msg=_(error_msg), 436 | title=title, 437 | ) 438 | 439 | def _handle_custom_error(self, error_msg: str, title: str | None = None): 440 | """ 441 | Handle custom error message. 442 | 443 | :param error_msg: RazorpayX API error message. 444 | :param title: Title of the error message. 445 | """ 446 | match error_msg: 447 | case "Different request body sent for the same Idempotency Header": 448 | error_msg = _( 449 | "Please cancel/delete the current document and pay with a new document." 450 | ) 451 | 452 | error_msg += "

" 453 | 454 | error_msg += _( 455 | "You faced this issue because payment details were changed after the first payment attempt." 456 | ) 457 | 458 | title = _("Payment Details Changed") 459 | 460 | case "Authentication failed": 461 | error_msg = _( 462 | "RazorpayX API credentials are invalid. Please set valid Key ID and Key Secret." 463 | ) 464 | 465 | title = _("RazorpayX Authentication Failed") 466 | 467 | case "The RazorpayX Account number is invalid.": 468 | error_msg = _( 469 | "Bank Account number is not matching with the RazorpayX account.
Please set valid Bank Account." 470 | ) 471 | 472 | title = _("Invalid Bank Account Number") 473 | 474 | if not title: 475 | title = _("RazorpayX API Failed") 476 | 477 | frappe.throw(title=title, msg=error_msg) 478 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/apis/contact.py: -------------------------------------------------------------------------------- 1 | from frappe.utils import validate_email_address 2 | 3 | from razorpayx_integration.razorpayx_integration.apis.base import BaseRazorpayXAPI 4 | from razorpayx_integration.razorpayx_integration.constants.payouts import ( 5 | CONTACT_TYPE, 6 | ) 7 | 8 | # ! IMPORTANT: Currently this API is not maintained. 9 | # TODO: this need to be refactor and optimize 10 | # TODO: Add service details to IR log 11 | # TODO: Add source doctype and docname to IR log 12 | 13 | 14 | class RazorpayXContact(BaseRazorpayXAPI): 15 | """ 16 | Handle APIs for RazorpayX Contact. 17 | 18 | :param account_name: RazorpayX account for which this `Contact` is associate. 19 | 20 | --- 21 | Reference: https://razorpay.com/docs/api/x/contacts 22 | """ 23 | 24 | # * utility attributes 25 | BASE_PATH = "contacts" 26 | 27 | # * override base setup 28 | def setup(self, *args, **kwargs): 29 | pass 30 | 31 | ### APIs ### 32 | def create(self, **kwargs) -> dict: 33 | """ 34 | Creates `RazorpayX Contact`. 35 | 36 | :param dict json: Full details of the contact to create. 37 | :param str name: [*] The name of the contact. 38 | :param str type: Contact's ERPNext DocType. (Ex. `Employee`, `Customer`, `Supplier`) 39 | :param str email: Email address of the contact. 40 | :param str contact: Contact number of the contact. 41 | :param str id: Reference Id for contact. 42 | :param dict notes: Additional notes for the contact. 43 | 44 | --- 45 | Example Usage: 46 | ``` 47 | contact = RazorpayXContact(RAZORPAYX_BANK_ACCOUNT) 48 | 49 | # Using args 50 | response = contact.create( 51 | name="Joe Doe", 52 | type="Employee", 53 | email="joe123@gmail.com", 54 | contact="7434870169", 55 | id="empl-02", 56 | notes={ 57 | "source": "ERPNext", 58 | "demo": True, 59 | } 60 | ) 61 | 62 | # Using json 63 | json = { 64 | "name"="Joe Doe", 65 | "type"="Employee", 66 | "email"="joe123@gmail.com", 67 | "contact"="7434870169", 68 | "id"="empl-02", 69 | notes={ 70 | "source": "ERPNext", 71 | "demo": True, 72 | } 73 | } 74 | 75 | response = contact.create(json=json) 76 | ``` 77 | --- 78 | 79 | Note: 80 | - If `json` passed in args, then remaining args will be discarded. 81 | - [*] Required fields. 82 | --- 83 | Reference: https://razorpay.com/docs/api/x/contacts/create 84 | """ 85 | # TODO: ? should sanitize contact name 86 | return self.post(json=self.get_mapped_request(kwargs)) 87 | 88 | def get_by_id(self, id: str) -> dict: 89 | """ 90 | Fetch the details of a specific `Contact` by Id. 91 | 92 | :param id: `Id` of contact to fetch (Ex.`cont_hkj012yuGJ`). 93 | 94 | --- 95 | Reference: https://razorpay.com/docs/api/x/contacts/fetch-with-id 96 | """ 97 | return self.get(endpoint=id) 98 | 99 | def get_all( 100 | self, filters: dict | None = None, count: int | None = None 101 | ) -> list[dict]: 102 | """ 103 | Get all `Contacts` associate with given `RazorpayX` account if limit is not given. 104 | 105 | :param filters: Result will be filtered as given filters. 106 | :param count: The number of contacts to be retrieved. 107 | 108 | :raises ValueError: If `type` is not valid. 109 | 110 | --- 111 | Example Usage: 112 | ``` 113 | contact = RazorpayXContact(RAZORPAYX_BANK_ACCOUNT) 114 | 115 | filters = { 116 | "name":"joe", 117 | "email":"joe@gmail.com", 118 | "contact":"743487045", 119 | "reference_id":"empl_001", 120 | "active":1 | True, 121 | "type":"Employee", 122 | "from":"2024-01-01" 123 | "to":"2024-06-01" 124 | } 125 | 126 | response=contact.get_all(filters) 127 | ``` 128 | 129 | --- 130 | Note: 131 | - `active` can be int or boolean. 132 | - `from` and `to` can be str,date,datetime (in YYYY-MM-DD). 133 | 134 | --- 135 | Reference: https://razorpay.com/docs/api/x/contacts/fetch-all 136 | """ 137 | return super().get_all(filters, count) 138 | 139 | def update(self, id: str, **kwargs): 140 | """ 141 | Updates `RazorpayX Contact`. 142 | 143 | :param id: Contact Id of whom to update details (Ex.`cont_hkj012yuGJ`). 144 | :param dict json: Full details of contact to create. 145 | :param str name: The contact's name. 146 | :param str type: Contact's ERPNext DocType. 147 | :param str email: Email address of the contact. 148 | :param str contact: Contact number of the contact. 149 | :param str id: Reference Id for contact. 150 | :param dict notes: Additional notes for the contact. 151 | 152 | --- 153 | Example Usage: 154 | ``` 155 | contact = RazorpayXContact(RAZORPAYX_BANK_ACCOUNT) 156 | 157 | # Using args 158 | response = contact.update( 159 | name="Joe Doe", 160 | type="employee", 161 | email="joe123@gmail.com", 162 | contact="7434870169", 163 | id="empl-02", 164 | notes = { 165 | "source": "ERPNext", 166 | "demo": True, 167 | } 168 | ) 169 | 170 | # Using json 171 | json = { 172 | "name"="Joe Doe", 173 | "type"="employee", 174 | "email"="joe123@gmail.com", 175 | "contact"="7434870169", 176 | "id"="empl-02", 177 | "notes"={ 178 | "source": "ERPNext", 179 | "demo": True, 180 | } 181 | } 182 | 183 | response = contact.update(id='cont_hkj012yuGJ',json=json) 184 | ``` 185 | 186 | --- 187 | Note: 188 | - If json passed in args, then other args will discarded. 189 | 190 | --- 191 | Reference: https://razorpay.com/docs/api/x/contacts/update 192 | """ 193 | return self.patch(endpoint=id, json=self.get_mapped_request(kwargs)) 194 | 195 | def activate(self, id: str) -> dict: 196 | """ 197 | Activate the contact for the given `Id` if it is deactivated. 198 | 199 | :param id: `Id` of contact to make activate (Ex.`cont_hkj012yuGJ`). 200 | """ 201 | return self._change_state(id=id, active=True) 202 | 203 | def deactivate(self, id: str) -> dict: 204 | """ 205 | Deactivate the contact for the given `Id` if it is activated. 206 | 207 | :param id: `Id` of contact to make deactivate (Ex.`cont_hkj012yuGJ`). 208 | """ 209 | return self._change_state(id=id, active=False) 210 | 211 | ### Bases ### 212 | def _change_state(self, id: str, active: bool | int) -> dict: 213 | """ 214 | Change the state of the `Contact` for the given Id. 215 | 216 | :param id: Id of `Contact` to change state (Ex.`cont_hkj012yuGJ`). 217 | :param active: Represents the state. (`True`:Active,`False`:Inactive) 218 | 219 | --- 220 | Reference: https://razorpay.com/docs/api/x/contacts/activate-or-deactivate 221 | """ 222 | return self.patch(endpoint=id, json={"active": active}) 223 | 224 | ### Helpers ### 225 | def get_mapped_request(self, request: dict) -> dict: 226 | """ 227 | Maps given request data to RazorpayX request data structure. 228 | """ 229 | json = request.get("json") 230 | 231 | if json and isinstance(json, dict): 232 | if id := json.get("id"): 233 | json["reference_id"] = id 234 | del json["id"] 235 | 236 | else: 237 | json = { 238 | "name": request.get("name"), 239 | "type": request.get("type"), 240 | "email": request.get("email"), 241 | "contact": request.get("contact"), 242 | "reference_id": request.get("id"), 243 | "notes": request.get("notes"), 244 | } 245 | 246 | if json.get("type"): 247 | json["type"] = self.get_contact_type(json) 248 | 249 | self._clean_request(json) 250 | self.validate_email(json) 251 | 252 | return json 253 | 254 | def get_contact_type(self, request: dict) -> str | None: 255 | """ 256 | Get the RazorpayX Contact Type for given ERPNext DocType. 257 | 258 | :param request: Request data. 259 | 260 | --- 261 | Note: 262 | - Returns `None` if `type` is not valid. 263 | - Default Contact Type is `Customer`. 264 | """ 265 | doctype = request.get("type", "").upper() 266 | 267 | if not doctype: 268 | return 269 | 270 | if doctype not in CONTACT_TYPE.values(): 271 | return CONTACT_TYPE.CUSTOMER.value 272 | 273 | return CONTACT_TYPE[doctype].value 274 | 275 | def validate_email(self, request: dict): 276 | if email := request.get("email"): 277 | validate_email_address(email, throw=True) 278 | 279 | def _validate_and_process_filters(self, filters: dict): 280 | self.validate_email(filters) 281 | filters["type"] = self.get_contact_type(filters) 282 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/apis/fund_account.py: -------------------------------------------------------------------------------- 1 | from razorpayx_integration.razorpayx_integration.apis.base import BaseRazorpayXAPI 2 | from razorpayx_integration.razorpayx_integration.constants.payouts import ( 3 | FUND_ACCOUNT_TYPE, 4 | ) 5 | from razorpayx_integration.razorpayx_integration.utils.validation import ( 6 | validate_fund_account_type, 7 | ) 8 | 9 | # ! IMPORTANT: Currently this API is not maintained. 10 | # TODO: this need to be refactor and optimize 11 | # TODO: Add service details to IR log 12 | # TODO: Add source doctype and docname to IR log 13 | 14 | 15 | class RazorpayXFundAccount(BaseRazorpayXAPI): 16 | """ 17 | Handle APIs for RazorpayX Fund Account. 18 | 19 | :param account_name: RazorpayX account for which this `Fund Account` is associate. 20 | 21 | --- 22 | Reference: https://razorpay.com/docs/api/x/fund-accounts/ 23 | """ 24 | 25 | # * utility attributes 26 | BASE_PATH = "fund_accounts" 27 | 28 | # * override base setup 29 | def setup(self, *args, **kwargs): 30 | pass 31 | 32 | ### APIs ### 33 | def create_with_bank_account( 34 | self, contact_id: str, contact_name: str, ifsc_code: str, account_number: str 35 | ): 36 | """ 37 | Create RazorpayX `Fund Account` with contact's bank account details. 38 | 39 | :param contact_id: The ID of the contact to which the `fund_account` is linked (Eg. `cont_00HjGh1`). 40 | :param contact_name: The account holder's name. 41 | :param ifsc_code: Unique identifier of a bank branch (Eg. `HDFC0000053`). 42 | :param account_number: The account number (Eg. `765432123456789`). 43 | 44 | --- 45 | Reference: https://razorpay.com/docs/api/x/fund-accounts/create/bank-account 46 | """ 47 | json = { 48 | "contact_id": contact_id, 49 | "account_type": FUND_ACCOUNT_TYPE.BANK_ACCOUNT.value, 50 | "bank_account": { 51 | "name": contact_name, 52 | "ifsc": ifsc_code, 53 | "account_number": account_number, 54 | }, 55 | } 56 | 57 | return self.post(json=json) 58 | 59 | def create_with_vpa(self, contact_id: str, vpa: str): 60 | """ 61 | Create RazorpayX `Fund Account` with contact's Virtual Payment Address. 62 | 63 | :param str contact_id: The ID of the contact to which the `fund_account` is linked (Eg. `cont_00HjGh1`). 64 | :param str vpa: The contact's virtual payment address (VPA) (Eg. `joedoe@exampleupi`) 65 | 66 | --- 67 | Reference: https://razorpay.com/docs/api/x/fund-accounts/create/vpa 68 | """ 69 | json = { 70 | "contact_id": contact_id, 71 | "account_type": FUND_ACCOUNT_TYPE.VPA.value, 72 | "vpa": {"address": vpa}, 73 | } 74 | 75 | return self.post(json=json) 76 | 77 | def get_by_id(self, id: str): 78 | """ 79 | Fetch the details of a specific `Fund Account` by Id. 80 | 81 | :param id: `Id` of fund account to fetch (Ex.`fa_00HjHue1`). 82 | 83 | --- 84 | Reference: https://razorpay.com/docs/api/x/contacts/fetch-with-id 85 | """ 86 | return self.get(endpoint=id) 87 | 88 | def get_all( 89 | self, filters: dict | None = None, count: int | None = None 90 | ) -> list[dict]: 91 | """ 92 | Get all `Fund Account` associate with given `RazorpayX` account if limit is not given. 93 | 94 | :param filters: Result will be filtered as given filters. 95 | :param count: The number of `Fund Account` to be retrieved. 96 | 97 | --- 98 | Example Usage: 99 | ``` 100 | fund_account = RazorpayXFundAccount(RAZORPAYX_BANK_ACCOUNT) 101 | filters = { 102 | "contact_id":"cont_hkj012yuGJ", 103 | "account_type":"bank_account", 104 | "from":"2024-01-01" 105 | "to":"2024-06-01" 106 | } 107 | response=fund_account.get_all(filters) 108 | ``` 109 | 110 | --- 111 | Note: 112 | - Not all filters are require. 113 | - `account_type` can be one of the ['bank_account','vpa'], if not raises an error. 114 | - `from` and `to` can be str,date,datetime (in YYYY-MM-DD). 115 | 116 | --- 117 | Reference: https://razorpay.com/docs/api/x/fund-accounts/fetch-all 118 | """ 119 | return super().get_all(filters, count) 120 | 121 | def activate(self, id: str) -> dict: 122 | """ 123 | Activate the `Fund Account` for the given Id if it is deactivated. 124 | 125 | :param id: Id of the `Fund Account` to make activate (Ex.`fa_00HjHue1`). 126 | """ 127 | return self._change_state(id=id, active=True) 128 | 129 | def deactivate(self, id: str) -> dict: 130 | """ 131 | Deactivate the `Fund Account` for the given Id if it is activated. 132 | 133 | :param id: Id of the `Fund Account` to make deactivate (Ex.`fa_00HjHue1`). 134 | """ 135 | return self._change_state(id=id, active=False) 136 | 137 | ### Bases ### 138 | def _change_state(self, id: str, active: bool | int) -> dict: 139 | """ 140 | Change the state of the `Fund Account` for the given Id. 141 | 142 | :param id: Id of `Fund Account` to change state (Ex.`fa_00HjHue1`). 143 | :param active: Represent state. (`True`:Active,`False`:Inactive) 144 | 145 | --- 146 | Reference: https://razorpay.com/docs/api/x/fund-accounts/activate-or-deactivate 147 | """ 148 | return self.patch(endpoint=id, json={"active": active}) 149 | 150 | ### Helpers ### 151 | def _validate_and_process_filters(self, filters: dict) -> dict: 152 | if account_type := filters.get("account_type"): 153 | validate_fund_account_type(account_type) 154 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/apis/transaction.py: -------------------------------------------------------------------------------- 1 | from frappe.utils import DateTimeLikeObject, today 2 | 3 | from razorpayx_integration.razorpayx_integration.apis.base import BaseRazorpayXAPI 4 | 5 | 6 | class RazorpayXTransaction(BaseRazorpayXAPI): 7 | """ 8 | Handle APIs for `Transaction`. 9 | 10 | :param config: RazorpayX Configuration name 11 | for which this `Transaction` is associate. 12 | 13 | --- 14 | Reference: https://razorpay.com/docs/api/x/transactions/ 15 | """ 16 | 17 | # * utility attributes 18 | BASE_PATH = "transactions" 19 | 20 | # * override base setup 21 | def setup(self, *args, **kwargs): 22 | self.account_number = self.razorpayx_config.account_number 23 | 24 | ### APIs ### 25 | def get_by_id( 26 | self, 27 | transaction_id: str, 28 | *, 29 | source_doctype: str | None = None, 30 | source_docname: str | None = None, 31 | ) -> dict: 32 | """ 33 | Fetch the details of a specific `Transaction` by Id. 34 | 35 | :param id: `Id` of fund account to fetch (Ex.`txn_jkHgLM02`). 36 | :param source_doctype: The source doctype of the transaction. 37 | :param source_docname: The source docname of the transaction 38 | 39 | --- 40 | Reference: https://razorpay.com/docs/api/x/transactions/fetch-with-id 41 | """ 42 | self._set_service_details_to_ir_log("Get Transaction By Id") 43 | self.source_doctype = source_doctype 44 | self.source_docname = source_docname 45 | 46 | return self.get(endpoint=transaction_id) 47 | 48 | def get_all( 49 | self, 50 | *, 51 | from_date: DateTimeLikeObject | None = None, 52 | to_date: DateTimeLikeObject | None = None, 53 | count: int | None = None, 54 | source_doctype: str | None = None, 55 | source_docname: str | None = None, 56 | ) -> list[dict] | None: 57 | """ 58 | Get all `Transaction` associate with given `RazorpayX` account if count is not given. 59 | 60 | :param from_date: The starting date for which transactions are to be fetched. 61 | :param to_date: The ending date for which transactions are to be fetched. 62 | :param count: The number of `Transaction` to be retrieved. 63 | :param source_doctype: The source doctype of the transaction. 64 | :param source_docname: The source docname of the transaction 65 | 66 | --- 67 | Example Usage: 68 | 69 | ``` 70 | transaction = RazorpayXTransaction(RAZORPAYX_CONFIG_NAME) 71 | 72 | response = transaction.get_all( 73 | from_date="2024-01-01", to_date="2024-06-01", count=10 74 | ) 75 | ``` 76 | 77 | --- 78 | Note: 79 | - `from` and `to` can be str,date,datetime (in YYYY-MM-DD). 80 | 81 | --- 82 | Reference: https://razorpay.com/docs/api/x/transactions/fetch-all 83 | """ 84 | filters = {} 85 | 86 | if from_date: 87 | filters["from"] = from_date 88 | 89 | if to_date: 90 | filters["to"] = to_date 91 | 92 | if "from" not in filters: 93 | filters["from"] = self.razorpayx_config.last_sync_on 94 | 95 | # account number is mandatory 96 | filters["account_number"] = self.account_number 97 | 98 | if not self.ir_service_set: 99 | self._set_service_details_to_ir_log("Get All Transactions", False) 100 | 101 | if not (self.source_doctype and self.source_docname): 102 | self.source_doctype = source_doctype 103 | self.source_docname = source_docname 104 | 105 | return super().get_all(filters=filters, count=count) 106 | 107 | def get_transactions_for_today( 108 | self, 109 | count: int | None = None, 110 | *, 111 | source_doctype: str | None = None, 112 | source_docname: str | None = None, 113 | ) -> list[dict] | None: 114 | """ 115 | Get all transactions for today associate with given RazorpayX Config. 116 | 117 | :param count: The number of transactions to be retrieved. 118 | :param source_doctype: The source doctype of the transaction. 119 | :param source_docname: The source docname of the transaction 120 | 121 | --- 122 | Note: If count is not given, it will return all transactions for today. 123 | """ 124 | today_date = today() 125 | self._set_service_details_to_ir_log("Get Transactions For Today") 126 | 127 | return self.get_all( 128 | from_date=today_date, 129 | to_date=today_date, 130 | count=count, 131 | source_doctype=source_doctype, 132 | source_docname=source_docname, 133 | ) 134 | 135 | def get_transactions_for_date( 136 | self, 137 | date: DateTimeLikeObject, 138 | count: int | None = None, 139 | *, 140 | source_doctype: str | None = None, 141 | source_docname: str | None = None, 142 | ) -> list[dict] | None: 143 | """ 144 | Get all transactions for specific date associate with given RazorpayX Config. 145 | 146 | :param date: A date string in "YYYY-MM-DD" format or a (datetime,date) object. 147 | """ 148 | self._set_service_details_to_ir_log("Get Transactions For Date") 149 | 150 | return self.get_all( 151 | from_date=date, 152 | to_date=date, 153 | count=count, 154 | source_doctype=source_doctype, 155 | source_docname=source_docname, 156 | ) 157 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/apis/validate_razorpayx.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for API testing and validation. 3 | """ 4 | 5 | from razorpayx_integration.razorpayx_integration.apis.base import BaseRazorpayXAPI 6 | 7 | 8 | class RazorpayXValidation(BaseRazorpayXAPI): 9 | """ 10 | Validate RazorpayX APIs. 11 | 12 | :param id: RazorpayX API Key ID 13 | :param secret: RazorpayX API Key Secret 14 | :param account_number: RazorpayX Account Number 15 | :param source_doctype: Source Doctype 16 | :param source_docname: Source Docname 17 | """ 18 | 19 | def __init__( 20 | self, 21 | id: str, 22 | secret: str, 23 | account_number: str | None = None, 24 | source_doctype: str | None = None, 25 | source_docname: str | None = None, 26 | ): 27 | """ 28 | Validate RazorpayX APIs. 29 | 30 | :param id: RazorpayX API Key ID 31 | :param secret: RazorpayX API Key Secret 32 | :param account_number: RazorpayX Account Number 33 | :param source_doctype: Source Doctype 34 | :param source_docname: Source Docname 35 | """ 36 | self.auth = (id, secret) 37 | self.account_number = account_number 38 | self.source_doctype = source_doctype 39 | self.source_docname = source_docname 40 | 41 | self.default_headers = {} 42 | self.default_log_values = {} # Show value in Integration Request Log 43 | self.sensitive_infos = () # Sensitive info to mask in Integration Request Log 44 | 45 | def validate_credentials(self): 46 | """ 47 | Validate RazorpayX API credentials. 48 | 49 | - Key ID 50 | - Key Secret 51 | """ 52 | self._set_service_details_to_ir_log("Validate API Credentials") 53 | self.set_base_path("transactions") 54 | 55 | self.get_all(filters={"account_number": self.account_number}, count=1) 56 | 57 | def set_base_path(self, path: str): 58 | """ 59 | Set base API path. 60 | """ 61 | self.BASE_PATH = path 62 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/client_overrides/form/bank_reconciliation_tool.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025, Resilient Tech and contributors 2 | // For license information, please see license.txt 3 | const SYNC_BTN_LABEL = __("Sync via RazorpayX"); 4 | 5 | frappe.ui.form.on("Bank Reconciliation Tool", { 6 | refresh: async function (frm) { 7 | frm.add_custom_button(SYNC_BTN_LABEL, async () => { 8 | await sync_transactions_with_razorpayx(frm.doc.bank_account, frm.__razorpayx_config); 9 | 10 | frappe.show_alert({ 11 | message: __("{0} transactions synced successfully!", [frm.doc.bank_account]), 12 | indicator: "green", 13 | }); 14 | }); 15 | 16 | await toggle_sync_btn(frm); 17 | }, 18 | 19 | bank_account: async function (frm) { 20 | await toggle_sync_btn(frm); 21 | }, 22 | }); 23 | 24 | function sync_transactions_with_razorpayx(bank_account, razorpayx_config) { 25 | return frappe.call({ 26 | method: "razorpayx_integration.razorpayx_integration.utils.bank_transaction.sync_transactions_for_reconcile", 27 | args: { bank_account, razorpayx_config }, 28 | freeze: true, 29 | freeze_message: __("Syncing Transactions. Please wait it may take a while..."), 30 | }); 31 | } 32 | 33 | async function toggle_sync_btn(frm) { 34 | const btn = frm.custom_buttons[SYNC_BTN_LABEL]; 35 | 36 | if (!btn) return; 37 | 38 | if (!frm.doc.bank_account) { 39 | btn.hide(); 40 | return; 41 | } 42 | 43 | const { name } = await razorpayx.get_razorpayx_config(frm.doc.bank_account); 44 | frm.__razorpayx_config = name; 45 | 46 | if (name) btn.show(); 47 | else btn.hide(); 48 | } 49 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/client_overrides/form/payment_entry.js: -------------------------------------------------------------------------------- 1 | // ############ CONSTANTS ############ // 2 | const PE_BASE_PATH = "razorpayx_integration.razorpayx_integration.server_overrides.doctype.payment_entry"; 3 | 4 | const TRANSFER_METHOD = payment_integration_utils.PAYMENT_TRANSFER_METHOD; 5 | 6 | frappe.ui.form.on("Payment Entry", { 7 | refresh: async function (frm) { 8 | // permission checks 9 | const permission = has_payout_permissions(frm); 10 | frm.toggle_display("razorpayx_payout_section", permission); 11 | 12 | if (frm.doc.integration_doctype !== razorpayx.RAZORPAYX_CONFIG || !frm.doc.integration_docname) 13 | return; 14 | 15 | // payout is/will made via RazorpayX 16 | if (frm.doc.make_bank_online_payment) { 17 | set_razorpayx_state_description(frm); 18 | set_reference_no_description(frm); 19 | } 20 | 21 | if (!permission || is_already_paid(frm)) return; 22 | 23 | // making payout manually 24 | if (frm.doc.docstatus === 1 && !frm.doc.make_bank_online_payment && frm.doc.bank_account) { 25 | frm.add_custom_button(__("Make Payout"), () => show_make_payout_dialog(frm)); 26 | } 27 | }, 28 | 29 | validate: function (frm) { 30 | if (!razorpayx.is_payout_via_razorpayx(frm.doc)) return; 31 | 32 | razorpayx.validate_payout_description(frm.doc.razorpayx_payout_desc); 33 | }, 34 | 35 | before_submit: async function (frm) { 36 | if ( 37 | !razorpayx.is_payout_via_razorpayx(frm.doc) || 38 | is_already_paid(frm) || 39 | !has_payout_permissions(frm) 40 | ) { 41 | return; 42 | } 43 | 44 | frappe.validate = false; 45 | 46 | return new Promise((resolve) => { 47 | const continue_submission = (auth_id) => { 48 | frappe.validate = true; 49 | frm.__making_payout = true; 50 | 51 | payment_integration_utils.set_onload(frm, "auth_id", auth_id); 52 | 53 | resolve(); 54 | }; 55 | 56 | return payment_integration_utils.authenticate_payment_entries(frm.docname, continue_submission); 57 | }); 58 | }, 59 | 60 | on_submit: function (frm) { 61 | if (!frm.__making_payout) return; 62 | 63 | frappe.show_alert({ 64 | message: __("Payout has been made successfully."), 65 | indicator: "green", 66 | }); 67 | 68 | delete frm.__making_payout; 69 | }, 70 | 71 | before_cancel: async function (frm) { 72 | if ( 73 | !razorpayx.is_payout_via_razorpayx(frm.doc) || 74 | !["Not Initiated", "Queued"].includes(frm.doc.razorpayx_payout_status) || 75 | !has_payout_permissions(frm) || 76 | payment_integration_utils.get_onload(frm, "auto_cancel_payout_enabled") 77 | ) { 78 | return; 79 | } 80 | 81 | frappe.validate = false; 82 | 83 | return new Promise((resolve) => { 84 | const continue_cancellation = () => { 85 | frappe.validate = true; 86 | resolve(); 87 | }; 88 | 89 | return show_cancel_payout_dialog(frm, continue_cancellation); 90 | }); 91 | }, 92 | }); 93 | 94 | // ############ HELPERS ############ // 95 | function is_already_paid(frm) { 96 | return payment_integration_utils.is_already_paid(frm); 97 | } 98 | 99 | function has_payout_permissions(frm) { 100 | return payment_integration_utils.user_has_payment_permissions(frm); 101 | } 102 | 103 | function get_indicator(status) { 104 | return razorpayx.PAYOUT_STATUS[status] || "grey"; 105 | } 106 | 107 | function get_rpx_img_container(txt, styles = "", classes = "") { 108 | return `
109 | ${__(txt)}   110 | 111 |
`; 112 | } 113 | 114 | function set_razorpayx_state_description(frm) { 115 | if (frm.doc.__islocal) return; 116 | 117 | const status = frm.doc.razorpayx_payout_status; 118 | 119 | // prettier-ignore 120 | // eslint-disable-next-line 121 | const description = `
122 | ${status} 123 | ${get_rpx_img_container("via")} 124 |
`; 125 | 126 | frm.get_field("payment_type").set_new_description(description); 127 | } 128 | 129 | function set_reference_no_description(frm) { 130 | // only payout link available and got cancelled 131 | function is_payout_link_cancelled() { 132 | return ( 133 | frm.doc.razorpayx_payout_link_id && 134 | !frm.doc.razorpayx_payout_id && 135 | frm.doc.razorpayx_payout_status === "Cancelled" 136 | ); 137 | } 138 | 139 | if (!["Reversed", "Processed"].includes(frm.doc.razorpayx_payout_status) || is_payout_link_cancelled()) 140 | return; 141 | 142 | frm.get_field("reference_no").set_new_description( 143 | __("This is UTR of the payout transaction done via RazorpayX") 144 | ); 145 | } 146 | 147 | // ############ MAKING PAYOUT HELPERS ############ // 148 | async function show_make_payout_dialog(frm) { 149 | if (frm.is_dirty()) { 150 | frappe.throw({ 151 | message: __("Please save the document's changes before making payout."), 152 | title: __("Unsaved Changes"), 153 | }); 154 | } 155 | 156 | // depends on conditions 157 | const BANK_MODE = `["${TRANSFER_METHOD.NEFT}", "${TRANSFER_METHOD.RTGS}", "${TRANSFER_METHOD.IMPS}"].includes(doc.payment_transfer_method)`; 158 | const UPI_MODE = `doc.payment_transfer_method === '${TRANSFER_METHOD.UPI}'`; 159 | const LINK_MODE = `doc.payment_transfer_method === '${TRANSFER_METHOD.LINK}'`; 160 | 161 | const dialog = new frappe.ui.Dialog({ 162 | title: __("Enter Payout Details"), 163 | fields: [ 164 | { 165 | fieldname: "party_account_sec_break", 166 | label: __("Party Account Details"), 167 | fieldtype: "Section Break", 168 | }, 169 | { 170 | fieldname: "party_bank_account", 171 | label: __("Party Bank Account"), 172 | fieldtype: "Link", 173 | options: "Bank Account", 174 | default: frm.doc.party_bank_account, 175 | get_query: function () { 176 | return { 177 | filters: { 178 | is_company_account: 0, 179 | party: frm.doc.party, 180 | party_type: frm.doc.party_type, 181 | }, 182 | }; 183 | }, 184 | onchange: async function () { 185 | set_party_bank_details(dialog); 186 | }, 187 | }, 188 | { 189 | fieldname: "party_acc_cb", 190 | fieldtype: "Column Break", 191 | }, 192 | { 193 | fieldname: "party_bank_account_no", 194 | label: "Party Bank Account No", 195 | fieldtype: "Data", 196 | read_only: 1, 197 | depends_on: `eval: ${BANK_MODE}`, 198 | mandatory_depends_on: `eval: ${BANK_MODE}`, 199 | default: frm.doc.party_bank_account_no, 200 | }, 201 | { 202 | fieldname: "party_bank_ifsc", 203 | label: "Party Bank IFSC Code", 204 | fieldtype: "Data", 205 | read_only: 1, 206 | depends_on: `eval: ${BANK_MODE}`, 207 | mandatory_depends_on: `eval: ${BANK_MODE}`, 208 | default: frm.doc.party_bank_ifsc, 209 | }, 210 | { 211 | fieldname: "party_upi_id", 212 | label: "Party UPI ID", 213 | fieldtype: "Data", 214 | read_only: 1, 215 | depends_on: `eval: ${UPI_MODE}`, 216 | mandatory_depends_on: `eval: ${UPI_MODE}`, 217 | default: frm.doc.party_upi_id, 218 | }, 219 | { 220 | fieldname: "party_contact_sec_break", 221 | label: __("Party Contact Details"), 222 | fieldtype: "Section Break", 223 | }, 224 | { 225 | fieldname: "contact_person", 226 | label: __("Contact"), 227 | fieldtype: "Link", 228 | options: "Contact", 229 | default: frm.doc.contact_person, 230 | mandatory_depends_on: `eval: ${LINK_MODE} && ${frm.doc.party_type !== "Employee"}`, 231 | depends_on: `eval: ${frm.doc.party_type !== "Employee"}`, 232 | get_query: function () { 233 | return { 234 | filters: { 235 | link_doctype: frm.doc.party_type, 236 | link_name: frm.doc.party, 237 | }, 238 | }; 239 | }, 240 | onchange: async function () { 241 | set_contact_details(dialog); 242 | }, 243 | }, 244 | { 245 | fieldname: "contact_email", 246 | label: "Email", 247 | fieldtype: "Data", 248 | options: "Email", 249 | depends_on: "eval: doc.contact_email", 250 | read_only: 1, 251 | default: frm.doc.contact_email, 252 | }, 253 | { 254 | fieldname: "party_contact_cb", 255 | fieldtype: "Column Break", 256 | }, 257 | { 258 | fieldname: "contact_mobile", 259 | label: "Mobile", 260 | fieldtype: "Data", 261 | options: "Phone", 262 | depends_on: "eval: doc.contact_mobile", 263 | read_only: 1, 264 | default: frm.doc.contact_mobile, 265 | }, 266 | 267 | { 268 | fieldname: "payout_section_break", 269 | label: __("Payout Details"), 270 | fieldtype: "Section Break", 271 | }, 272 | { 273 | fieldname: "payment_transfer_method", 274 | label: __("Payout Transfer Method"), 275 | fieldtype: "Select", 276 | options: Object.values(TRANSFER_METHOD), 277 | default: frm.doc.payment_transfer_method, 278 | reqd: 1, 279 | description: `
280 | ${get_rpx_img_container("via")} 281 |
`, 282 | }, 283 | { 284 | fieldname: "payout_cb", 285 | fieldtype: "Column Break", 286 | }, 287 | { 288 | fieldname: "razorpayx_payout_desc", 289 | label: __("Payout Description"), 290 | fieldtype: "Data", 291 | length: 30, 292 | mandatory_depends_on: `eval: ${LINK_MODE}`, 293 | default: frm.doc.razorpayx_payout_desc, 294 | }, 295 | ], 296 | primary_action_label: __("{0} Pay", [frappe.utils.icon(payment_integration_utils.PAY_ICON)]), 297 | primary_action: (values) => { 298 | razorpayx.validate_payout_description(values.razorpayx_payout_desc); 299 | payment_integration_utils.validate_payment_transfer_method( 300 | values.payment_transfer_method, 301 | frm.doc.paid_amount 302 | ); 303 | 304 | dialog.hide(); 305 | 306 | payment_integration_utils.authenticate_payment_entries(frm.docname, async (auth_id) => { 307 | await make_payout(auth_id, frm.docname, values); 308 | 309 | frappe.show_alert({ 310 | message: __("Payout has been made successfully."), 311 | indicator: "green", 312 | }); 313 | }); 314 | }, 315 | }); 316 | 317 | dialog.show(); 318 | } 319 | 320 | function make_payout(auth_id, docname, values) { 321 | return frappe.call({ 322 | method: `${PE_BASE_PATH}.make_payout_with_razorpayx`, 323 | args: { 324 | auth_id: auth_id, 325 | docname: docname, 326 | transfer_method: values.payment_transfer_method, 327 | ...values, 328 | }, 329 | freeze: true, 330 | freeze_message: __("Making Payout ..."), 331 | }); 332 | } 333 | 334 | async function set_party_bank_details(dialog) { 335 | const party_bank_account = dialog.get_value("party_bank_account"); 336 | 337 | if (!party_bank_account) { 338 | dialog.set_value("payment_transfer_method", TRANSFER_METHOD.LINK); 339 | return; 340 | } 341 | 342 | dialog.set_value("payment_transfer_method", TRANSFER_METHOD.NEFT); 343 | 344 | const response = await frappe.db.get_value("Bank Account", party_bank_account, [ 345 | "branch_code as party_bank_ifsc", 346 | "bank_account_no as party_bank_account_no", 347 | "upi_id as party_upi_id", 348 | ]); 349 | 350 | dialog.set_values(response.message); 351 | } 352 | 353 | async function set_contact_details(dialog) { 354 | const contact_person = dialog.get_value("contact_person"); 355 | 356 | if (!contact_person) { 357 | dialog.set_values({ 358 | contact_email: "", 359 | contact_mobile: "", 360 | }); 361 | return; 362 | } 363 | 364 | const response = await frappe.call({ 365 | method: "frappe.contacts.doctype.contact.contact.get_contact_details", 366 | args: { contact: contact_person }, 367 | }); 368 | 369 | dialog.set_values({ 370 | contact_email: response.message.contact_email, 371 | contact_mobile: response.message.contact_mobile, 372 | }); 373 | } 374 | 375 | // ############ CANCELING PAYOUT HELPERS ############ // 376 | function show_cancel_payout_dialog(frm, callback) { 377 | const dialog = new frappe.ui.Dialog({ 378 | title: __("Cancel Payout"), 379 | fields: [ 380 | { 381 | fieldname: "cancel_payout", 382 | label: __("Cancel Payout"), 383 | fieldtype: "Check", 384 | default: 1, 385 | description: __("Payout will be cancelled along with Payment Entry if checked."), 386 | }, 387 | ], 388 | primary_action_label: __("Continue"), 389 | primary_action: (values) => { 390 | dialog.hide(); 391 | 392 | frappe.call({ 393 | method: `${PE_BASE_PATH}.mark_payout_for_cancellation`, 394 | args: { 395 | docname: frm.docname, 396 | cancel: values.cancel_payout, 397 | }, 398 | }); 399 | 400 | callback && callback(); 401 | }, 402 | }); 403 | 404 | // Make primary action button Background Red 405 | dialog.get_primary_btn().removeClass("btn-primary").addClass("btn-danger"); 406 | dialog.show(); 407 | } 408 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/constants/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/constants/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/constants/custom_fields.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom fields which are helpful for payments via RazorpayX 3 | 4 | Note: 5 | - Keep sequence like this: 6 | 1. fieldname 7 | 2. label 8 | 3. fieldtype 9 | 4. insert_after 10 | ... 11 | """ 12 | 13 | from payment_integration_utils.payment_integration_utils.constants.payments import ( 14 | TRANSFER_METHOD, 15 | ) 16 | 17 | from razorpayx_integration.constants import RAZORPAYX_CONFIG 18 | from razorpayx_integration.razorpayx_integration.constants.payouts import PAYOUT_STATUS 19 | from razorpayx_integration.razorpayx_integration.constants.roles import PERMISSION_LEVEL 20 | 21 | PAYOUT_VIA_RAZORPAYX = f"doc.make_bank_online_payment && doc.integration_doctype === '{RAZORPAYX_CONFIG}' && doc.integration_docname" 22 | PAYOUT_BASE_CONDITION = f"doc.payment_type=='Pay' && doc.party && doc.party_type && doc.paid_from_account_currency === 'INR' && {PAYOUT_VIA_RAZORPAYX}" 23 | 24 | CUSTOM_FIELDS = { 25 | "Payment Entry": [ 26 | #### PAYOUT SECTION START #### 27 | { 28 | "fieldname": "razorpayx_payout_section", 29 | "label": "RazorpayX Payout Details", 30 | "fieldtype": "Section Break", 31 | "insert_after": "integration_docname", ## Insert After `Integration Docname` field (Payment Utils Custom Field) 32 | "depends_on": f"eval: {PAYOUT_BASE_CONDITION}", 33 | "collapsible": 1, 34 | "collapsible_depends_on": "eval: doc.docstatus === 0", 35 | "permlevel": PERMISSION_LEVEL.SEVEN.value, 36 | }, 37 | { 38 | "fieldname": "razorpayx_payout_desc", 39 | "label": "Payout Description", 40 | "fieldtype": "Data", 41 | "insert_after": "razorpayx_payout_section", 42 | "depends_on": "eval: doc.make_bank_online_payment", 43 | "mandatory_depends_on": f"eval: {PAYOUT_VIA_RAZORPAYX} && doc.payment_transfer_method === '{TRANSFER_METHOD.LINK.value}'", 44 | "length": 30, 45 | "permlevel": PERMISSION_LEVEL.SEVEN.value, 46 | "no_copy": 1, 47 | }, 48 | { 49 | "fieldname": "razorpayx_payout_cb", 50 | "fieldtype": "Column Break", 51 | "insert_after": "razorpayx_payout_desc", 52 | "permlevel": PERMISSION_LEVEL.SEVEN.value, 53 | }, 54 | { 55 | "fieldname": "razorpayx_payout_status", 56 | "label": "RazorpayX Payout Status", 57 | "fieldtype": "Select", 58 | "insert_after": "razorpayx_payout_cb", 59 | "options": PAYOUT_STATUS.title_case_values(as_string=True), 60 | "default": PAYOUT_STATUS.NOT_INITIATED.value.title(), 61 | "depends_on": f"eval: {PAYOUT_VIA_RAZORPAYX} && doc.creation", 62 | "read_only": 1, 63 | "allow_on_submit": 1, 64 | "in_list_view": 0, # TODO: remove after split 65 | "in_standard_filter": 1, 66 | "permlevel": PERMISSION_LEVEL.SEVEN.value, 67 | "no_copy": 1, 68 | }, 69 | { 70 | "fieldname": "razorpayx_payout_id_sec", 71 | "label": "RazorpayX Payout ID Section", 72 | "fieldtype": "Section Break", 73 | "insert_after": "razorpayx_payout_status", 74 | "hidden": 1, 75 | }, 76 | { 77 | "fieldname": "razorpayx_payout_id", 78 | "label": "RazorpayX Payout ID", 79 | "fieldtype": "Data", 80 | "insert_after": "razorpayx_payout_id_sec", 81 | "read_only": 1, 82 | "hidden": 1, 83 | "print_hide": 1, 84 | "permlevel": PERMISSION_LEVEL.SEVEN.value, 85 | "no_copy": 1, 86 | }, 87 | { 88 | "fieldname": "razorpayx_id_cb", 89 | "fieldtype": "Column Break", 90 | "insert_after": "razorpayx_payout_id", 91 | }, 92 | { 93 | "fieldname": "razorpayx_payout_link_id", 94 | "label": "RazorpayX Payout Link ID", 95 | "fieldtype": "Data", 96 | "insert_after": "razorpayx_id_cb", 97 | "read_only": 1, 98 | "hidden": 1, 99 | "print_hide": 1, 100 | "permlevel": PERMISSION_LEVEL.SEVEN.value, 101 | "no_copy": 1, 102 | }, 103 | #### PAYMENT SECTION END #### 104 | ], 105 | } 106 | 107 | # payments_processor App fields 108 | PROCESSOR_FIELDS = { 109 | RAZORPAYX_CONFIG: [ 110 | { 111 | "fieldname": "pay_on_auto_submit", 112 | "label": "Pay on Auto Submit", 113 | "fieldtype": "Check", 114 | "insert_after": "auto_cancel_payout", 115 | "default": "1", 116 | "description": "If the Payment Entry is submitted via the Payments Processor, then the payout will be initiated automatically.", 117 | }, 118 | ] 119 | } 120 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/constants/payouts.py: -------------------------------------------------------------------------------- 1 | from payment_integration_utils.payment_integration_utils.constants.enums import BaseEnum 2 | 3 | ### REGEX ### 4 | DESCRIPTION_REGEX = r"^[a-zA-Z0-9\s]{1,30}$" 5 | 6 | ### OTHERS ### 7 | STATUS_NOTIFICATION_METHOD = "send_rpx_payout_status_update" 8 | 9 | 10 | ### ENUMS ### 11 | class PAYOUT_FROM(BaseEnum): 12 | CURRENT_ACCOUNT = "Current Account" 13 | RAZORPAYX_LITE = "RazorpayX Lite" 14 | 15 | 16 | class CONTACT_TYPE(BaseEnum): 17 | """ 18 | Default Contact Type available in RazorpayX. 19 | """ 20 | 21 | EMPLOYEE = "employee" 22 | SUPPLIER = "vendor" 23 | CUSTOMER = "customer" 24 | SELF = "self" 25 | 26 | 27 | class FUND_ACCOUNT_TYPE(BaseEnum): 28 | BANK_ACCOUNT = "bank_account" 29 | VPA = "vpa" 30 | # CARD = "card" # ! Not supported currently 31 | 32 | 33 | class PAYOUT_CURRENCY(BaseEnum): 34 | INR = "INR" 35 | 36 | 37 | class PAYOUT_PURPOSE(BaseEnum): 38 | """ 39 | Default payout purpose available in RazorpayX. 40 | """ 41 | 42 | REFUND = "refund" 43 | CASH_BACK = "cashback" 44 | PAYOUT = "payout" 45 | SALARY = "salary" 46 | UTILITY_BILL = "utility bill" 47 | VENDOR_BILL = "vendor bill" 48 | 49 | 50 | class PAYOUT_STATUS(BaseEnum): 51 | """ 52 | Reference: 53 | - https://razorpay.com/docs/x/payouts/states-life-cycle/#payout-states 54 | """ 55 | 56 | # Custom Status 57 | NOT_INITIATED = "not initiated" 58 | 59 | # RazorpayX Payout Status 60 | PENDING = "pending" 61 | QUEUED = "queued" 62 | SCHEDULED = "scheduled" 63 | PROCESSING = "processing" 64 | PROCESSED = "processed" 65 | CANCELLED = "cancelled" 66 | REJECTED = "rejected" 67 | FAILED = "failed" 68 | REVERSED = "reversed" 69 | 70 | 71 | class PAYOUT_LINK_STATUS(BaseEnum): 72 | """ 73 | Reference: 74 | - https://razorpay.com/docs/x/payout-links/life-cycle/ 75 | """ 76 | 77 | # RazorpayX Payout Link Status 78 | PENDING = "pending" 79 | ISSUED = "issued" 80 | PROCESSING = "processing" 81 | PROCESSED = "processed" 82 | CANCELLED = "cancelled" 83 | REJECTED = "rejected" 84 | EXPIRED = "expired" 85 | 86 | 87 | ### MAPPINGS ### 88 | PAYOUT_ORDERS = { 89 | PAYOUT_STATUS.NOT_INITIATED.value: 1, # custom 90 | PAYOUT_STATUS.PENDING.value: 2, 91 | PAYOUT_STATUS.SCHEDULED.value: 2, 92 | PAYOUT_STATUS.QUEUED.value: 3, 93 | PAYOUT_STATUS.PROCESSING.value: 4, 94 | PAYOUT_STATUS.PROCESSED.value: 5, 95 | PAYOUT_STATUS.CANCELLED.value: 5, 96 | PAYOUT_STATUS.FAILED.value: 5, 97 | PAYOUT_STATUS.REJECTED.value: 5, 98 | PAYOUT_STATUS.REVERSED.value: 6, 99 | } 100 | 101 | PAYOUT_LINK_ORDERS = { 102 | PAYOUT_LINK_STATUS.PENDING.value: 1, 103 | PAYOUT_LINK_STATUS.ISSUED.value: 2, 104 | PAYOUT_LINK_STATUS.PROCESSING.value: 3, 105 | PAYOUT_LINK_STATUS.PROCESSED.value: 4, 106 | PAYOUT_LINK_STATUS.CANCELLED.value: 4, 107 | PAYOUT_LINK_STATUS.REJECTED.value: 4, 108 | PAYOUT_LINK_STATUS.EXPIRED.value: 4, 109 | } 110 | 111 | PAYOUT_PURPOSE_MAP = { 112 | "Supplier": PAYOUT_PURPOSE.VENDOR_BILL.value, 113 | "Customer": PAYOUT_PURPOSE.REFUND.value, 114 | "Employee": PAYOUT_PURPOSE.SALARY.value, 115 | } 116 | 117 | CONTACT_TYPE_MAP = { 118 | "Supplier": CONTACT_TYPE.SUPPLIER.value, 119 | "Customer": CONTACT_TYPE.CUSTOMER.value, 120 | "Employee": CONTACT_TYPE.EMPLOYEE.value, 121 | } 122 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/constants/property_setters.py: -------------------------------------------------------------------------------- 1 | PROPERTY_SETTERS = [] 2 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/constants/roles.py: -------------------------------------------------------------------------------- 1 | from payment_integration_utils.payment_integration_utils.constants.enums import BaseEnum 2 | from payment_integration_utils.payment_integration_utils.constants.roles import ( 3 | PERMISSION_LEVEL, 4 | PERMISSIONS, 5 | ) 6 | from payment_integration_utils.payment_integration_utils.constants.roles import ( 7 | ROLE_PROFILE as PAYMENT_PROFILES, 8 | ) 9 | 10 | from razorpayx_integration.constants import RAZORPAYX_CONFIG 11 | 12 | 13 | class ROLE_PROFILE(BaseEnum): 14 | RAZORPAYX_MANAGER = "RazorpayX Integration Manager" 15 | 16 | 17 | ROLES = [ 18 | ## RazorpayX Configuration ## 19 | { 20 | "doctype": RAZORPAYX_CONFIG, 21 | "role_name": ROLE_PROFILE.RAZORPAYX_MANAGER.value, 22 | "permlevels": PERMISSION_LEVEL.ZERO.value, 23 | "permissions": PERMISSIONS["Manager"], 24 | }, 25 | { 26 | "doctype": RAZORPAYX_CONFIG, 27 | "role_name": PAYMENT_PROFILES.PAYMENT_AUTHORIZER.value, 28 | "permlevels": PERMISSION_LEVEL.ZERO.value, 29 | "permissions": PERMISSIONS["Basic"], 30 | }, 31 | ## Bank Account ## 32 | { 33 | "doctype": "Bank Account", 34 | "role_name": ROLE_PROFILE.RAZORPAYX_MANAGER.value, 35 | "permlevels": PERMISSION_LEVEL.ZERO.value, 36 | "permissions": PERMISSIONS["Basic"], 37 | }, 38 | ] 39 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/constants/webhooks.py: -------------------------------------------------------------------------------- 1 | from payment_integration_utils.payment_integration_utils.constants.enums import BaseEnum 2 | 3 | 4 | class EVENTS_TYPE(BaseEnum): 5 | PAYOUT = "payout" 6 | PAYOUT_LINK = "payout_link" 7 | TRANSACTION = "transaction" 8 | ACCOUNT = "fund_account" # ! NOTE: currently not supported 9 | 10 | 11 | class FUND_ACCOUNT_EVENT(BaseEnum): 12 | """ 13 | Reference: https://razorpay.com/docs/webhooks/payloads/x/account-validation/ 14 | """ 15 | 16 | COMPLETED = "fund_account.validation.completed" # ! NOTE: currently not supported 17 | FAILED = "fund_account.validation.failed" # ! NOTE: currently not supported 18 | 19 | 20 | class PAYOUT_EVENT(BaseEnum): 21 | """ 22 | References: 23 | - https://razorpay.com/docs/webhooks/payloads/x/payouts/ 24 | - https://razorpay.com/docs/webhooks/payloads/x/payouts-approval/ 25 | """ 26 | 27 | PENDING = "payout.pending" 28 | REJECTED = "payout.rejected" 29 | QUEUED = "payout.queued" 30 | INITIATED = "payout.initiated" 31 | PROCESSED = "payout.processed" 32 | REVERSED = "payout.reversed" 33 | FAILED = "payout.failed" 34 | UPDATED = "payout.updated" # ! NOTE: currently not supported 35 | DOWNTIME_STARTED = "payout.downtime.started" # ! NOTE: currently not supported 36 | DOWNTIME_RESOLVED = "payout.downtime.resolved" # ! NOTE: currently not supported 37 | 38 | 39 | class PAYOUT_LINK_EVENT(BaseEnum): 40 | """ 41 | Reference: https://razorpay.com/docs/webhooks/payloads/x/payout-links/ 42 | """ 43 | 44 | PENDING = "payout_link.pending" # ! NOTE: currently not supported 45 | PROCESSING = "payout_link.processing" # ! NOTE: currently not supported 46 | PROCESSED = "payout_link.processed" # ! NOTE: currently not supported 47 | ATTEMPTED = "payout_link.attempted" # ! NOTE: currently not supported 48 | CANCELLED = "payout_link.cancelled" 49 | REJECTED = "payout_link.rejected" 50 | EXPIRED = "payout_link.expired" 51 | 52 | 53 | class TRANSACTION_EVENT(BaseEnum): 54 | """ 55 | Reference: https://razorpay.com/docs/webhooks/payloads/x/transactions/ 56 | """ 57 | 58 | CREATED = "transaction.created" 59 | 60 | 61 | class TRANSACTION_TYPE(BaseEnum): 62 | PAYOUT = "payout" 63 | REVERSAL = "reversal" 64 | BANK_TRANSFER = "bank_transfer" 65 | 66 | 67 | SUPPORTED_EVENTS = ( 68 | ## PAYOUT ## 69 | PAYOUT_EVENT.PENDING.value, 70 | PAYOUT_EVENT.REJECTED.value, 71 | PAYOUT_EVENT.QUEUED.value, 72 | PAYOUT_EVENT.INITIATED.value, 73 | PAYOUT_EVENT.PROCESSED.value, 74 | PAYOUT_EVENT.REVERSED.value, 75 | PAYOUT_EVENT.FAILED.value, 76 | ## PAYOUT LINK ## 77 | PAYOUT_LINK_EVENT.CANCELLED.value, 78 | PAYOUT_LINK_EVENT.REJECTED.value, 79 | PAYOUT_LINK_EVENT.EXPIRED.value, 80 | ## TRANSACTION ## 81 | TRANSACTION_EVENT.CREATED.value, 82 | ) 83 | 84 | # payload > source > entity 85 | SUPPORTED_TRANSACTION_TYPES = (TRANSACTION_TYPE.REVERSAL.value,) 86 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/doctype/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/doctype/razorpayx_configuration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/doctype/razorpayx_configuration/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/doctype/razorpayx_configuration/razorpayx_configuration.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, Resilient Tech and contributors 2 | // For license information, please see license.txt 3 | const WEBHOOK_PATH = "razorpayx_integration.razorpayx_integration.utils.webhook.webhook_listener"; 4 | 5 | frappe.ui.form.on("RazorpayX Configuration", { 6 | setup: function (frm) { 7 | frm.set_query("bank_account", function () { 8 | return { 9 | filters: { 10 | is_company_account: 1, 11 | disabled: 0, 12 | }, 13 | }; 14 | }); 15 | 16 | const coa_filters = { 17 | account_currency: "INR", 18 | freeze_account: "No", 19 | root_type: "Liability", 20 | company: frm.doc.company, 21 | }; 22 | 23 | frm.set_query("creditors_account", function () { 24 | return { 25 | filters: { 26 | ...coa_filters, 27 | account_type: "Payable", 28 | }, 29 | }; 30 | }); 31 | 32 | frm.set_query("payable_account", function () { 33 | return { 34 | filters: coa_filters, 35 | }; 36 | }); 37 | }, 38 | 39 | onload: function (frm) { 40 | if (!frm.is_new()) return; 41 | 42 | frm.set_intro( 43 | __( 44 | `Get RazorpayX API's Key ID and Secret from 45 | 46 | here {0} 47 | 48 | if not available.`, 49 | [frappe.utils.icon("link-url")] 50 | ) 51 | ); 52 | }, 53 | 54 | refresh: function (frm) { 55 | // listener to copy webhook url 56 | frm.$wrapper.find(".webhook-url").on("click", function () { 57 | frappe.utils.copy_to_clipboard(`${frappe.urllib.get_base_url()}/api/method/${WEBHOOK_PATH}`); 58 | }); 59 | 60 | if (frm.doc.__islocal) return; 61 | 62 | frm.add_custom_button(__("Sync Transactions"), () => { 63 | prompt_transactions_sync_date(frm); 64 | }); 65 | }, 66 | 67 | after_save: function (frm) { 68 | if (frm.doc.webhook_secret) return; 69 | 70 | frappe.show_alert({ 71 | message: __("Webhook Secret is missing!
You will not receive any updates!"), 72 | indicator: "orange", 73 | }); 74 | }, 75 | }); 76 | 77 | function prompt_transactions_sync_date(frm) { 78 | const default_range = [frm.doc.last_sync_on || frappe.datetime.month_start(), frappe.datetime.now_date()]; 79 | const dialog = new frappe.ui.Dialog({ 80 | title: __("Sync {0} Transactions", [frm.doc.bank_account]), 81 | fields: [ 82 | { 83 | label: __("Date Range"), 84 | fieldname: "date_range", 85 | fieldtype: "DateRange", 86 | reqd: 1, 87 | default: default_range, 88 | }, 89 | ], 90 | primary_action_label: __("{0} Sync", [frappe.utils.icon("refresh")]), 91 | primary_action: function (values) { 92 | sync_transactions(frm.docname, frm.doc.bank_account, ...values.date_range); 93 | dialog.hide(); 94 | }, 95 | size: "small", 96 | }); 97 | 98 | dialog.get_field("date_range").datepicker.update({ 99 | maxDate: new Date(frappe.datetime.get_today()), 100 | }); 101 | 102 | // defaults are removed when setting maxDate 103 | dialog.set_df_property("date_range", "default", default_range); 104 | 105 | dialog.show(); 106 | } 107 | 108 | function sync_transactions(razorpayx_config, bank_account, from_date, to_date) { 109 | frappe.show_alert({ 110 | message: __("Syncing Transactions from {0} to {1}", [ 111 | payment_integration_utils.get_date_in_user_fmt(from_date), 112 | payment_integration_utils.get_date_in_user_fmt(to_date), 113 | ]), 114 | indicator: "blue", 115 | }); 116 | 117 | frappe.call({ 118 | method: "razorpayx_integration.razorpayx_integration.utils.bank_transaction.sync_bank_transactions_with_razorpayx", 119 | args: { razorpayx_config, bank_account, from_date, to_date }, 120 | callback: function (r) { 121 | //TODO: If it is enqueued, need changes!! 122 | if (!r.exc) { 123 | frappe.show_alert({ 124 | message: __("{0} transactions synced successfully!", [razorpayx_config]), 125 | indicator: "green", 126 | }); 127 | } 128 | }, 129 | }); 130 | } 131 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/doctype/razorpayx_configuration/razorpayx_configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "format:{bank_account}", 4 | "creation": "2024-11-28 17:05:21.010259", 5 | "default_view": "List", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "api_tab", 11 | "disabled", 12 | "api_details_section", 13 | "key_id", 14 | "key_secret", 15 | "column_break_8tcki", 16 | "account_id", 17 | "webhook_secret", 18 | "account_details_section", 19 | "bank_account", 20 | "company_account", 21 | "company", 22 | "column_break_3gfbs", 23 | "payouts_from", 24 | "bank", 25 | "ifsc_code", 26 | "account_number", 27 | "section_break_bsie", 28 | "last_sync_on", 29 | "column_break_mken", 30 | "pe_config_section", 31 | "auto_cancel_payout", 32 | "accounting_tab", 33 | "fees_section", 34 | "automate_fees_accounting", 35 | "column_break_rshi", 36 | "fees_accounting_section", 37 | "creditors_account", 38 | "payable_account", 39 | "column_break_vdui", 40 | "supplier", 41 | "payout_reversal_section", 42 | "create_je_on_reversal", 43 | "column_break_ntie" 44 | ], 45 | "fields": [ 46 | { 47 | "fieldname": "key_id", 48 | "fieldtype": "Data", 49 | "label": "Key ID", 50 | "no_copy": 1, 51 | "reqd": 1 52 | }, 53 | { 54 | "fieldname": "key_secret", 55 | "fieldtype": "Password", 56 | "label": "Key Secret", 57 | "no_copy": 1, 58 | "reqd": 1 59 | }, 60 | { 61 | "fieldname": "api_details_section", 62 | "fieldtype": "Section Break", 63 | "label": "API Details" 64 | }, 65 | { 66 | "fieldname": "column_break_8tcki", 67 | "fieldtype": "Column Break" 68 | }, 69 | { 70 | "description": "Copy webhook URL", 71 | "fieldname": "webhook_secret", 72 | "fieldtype": "Password", 73 | "label": "Webhook Secret", 74 | "no_copy": 1 75 | }, 76 | { 77 | "fieldname": "account_details_section", 78 | "fieldtype": "Section Break", 79 | "label": "Account Details" 80 | }, 81 | { 82 | "fieldname": "bank_account", 83 | "fieldtype": "Link", 84 | "in_list_view": 1, 85 | "label": "Bank Account", 86 | "no_copy": 1, 87 | "options": "Bank Account", 88 | "reqd": 1, 89 | "unique": 1 90 | }, 91 | { 92 | "fieldname": "column_break_3gfbs", 93 | "fieldtype": "Column Break" 94 | }, 95 | { 96 | "default": "0", 97 | "fieldname": "disabled", 98 | "fieldtype": "Check", 99 | "in_standard_filter": 1, 100 | "label": "Disabled", 101 | "no_copy": 1 102 | }, 103 | { 104 | "depends_on": "eval: doc.bank_account", 105 | "fetch_from": "bank_account.company", 106 | "fieldname": "company", 107 | "fieldtype": "Link", 108 | "in_list_view": 1, 109 | "in_standard_filter": 1, 110 | "label": "Company", 111 | "no_copy": 1, 112 | "options": "Company", 113 | "read_only": 1 114 | }, 115 | { 116 | "fetch_from": "bank_account.bank", 117 | "fieldname": "bank", 118 | "fieldtype": "Link", 119 | "in_standard_filter": 1, 120 | "label": "Bank", 121 | "no_copy": 1, 122 | "options": "Bank", 123 | "read_only": 1 124 | }, 125 | { 126 | "fetch_from": "bank_account.bank_account_no", 127 | "fieldname": "account_number", 128 | "fieldtype": "Data", 129 | "label": "Account Number", 130 | "no_copy": 1, 131 | "read_only": 1 132 | }, 133 | { 134 | "fetch_from": "bank_account.branch_code", 135 | "fieldname": "ifsc_code", 136 | "fieldtype": "Data", 137 | "label": "IFSC Code", 138 | "no_copy": 1, 139 | "read_only": 1 140 | }, 141 | { 142 | "fetch_from": "bank_account.account", 143 | "fieldname": "company_account", 144 | "fieldtype": "Link", 145 | "label": "Company Account", 146 | "no_copy": 1, 147 | "options": "Account", 148 | "read_only": 1 149 | }, 150 | { 151 | "fieldname": "api_tab", 152 | "fieldtype": "Tab Break", 153 | "label": "API" 154 | }, 155 | { 156 | "description": " This is a Business ID found in RazorpayX settings.\n\nGet it from here.", 157 | "fieldname": "account_id", 158 | "fieldtype": "Data", 159 | "in_list_view": 1, 160 | "label": "Account ID", 161 | "no_copy": 1, 162 | "reqd": 1, 163 | "unique": 1 164 | }, 165 | { 166 | "default": "0", 167 | "description": "Payout and Payout Link will be automatically cancelled on Payment Entry cancellation if possible.", 168 | "fieldname": "auto_cancel_payout", 169 | "fieldtype": "Check", 170 | "label": "Automatically Cancel Payout on Payment Entry Cancellation" 171 | }, 172 | { 173 | "fieldname": "pe_config_section", 174 | "fieldtype": "Section Break", 175 | "label": "Configurations" 176 | }, 177 | { 178 | "fieldname": "section_break_bsie", 179 | "fieldtype": "Section Break" 180 | }, 181 | { 182 | "description": "Bank Transactions Synchronised upto this date", 183 | "fieldname": "last_sync_on", 184 | "fieldtype": "Date", 185 | "label": "Last Sync On", 186 | "read_only": 1 187 | }, 188 | { 189 | "fieldname": "column_break_mken", 190 | "fieldtype": "Column Break" 191 | }, 192 | { 193 | "fieldname": "column_break_vdui", 194 | "fieldtype": "Column Break" 195 | }, 196 | { 197 | "depends_on": "eval: doc.payouts_from === \"Current Account\"", 198 | "fieldname": "payable_account", 199 | "fieldtype": "Link", 200 | "label": "Payable Account", 201 | "mandatory_depends_on": "eval: doc.automate_fees_accounting && doc.payouts_from === \"Current Account\"", 202 | "options": "Account" 203 | }, 204 | { 205 | "fieldname": "creditors_account", 206 | "fieldtype": "Link", 207 | "label": "Creditors Account", 208 | "mandatory_depends_on": "eval: doc.automate_fees_accounting", 209 | "options": "Account" 210 | }, 211 | { 212 | "default": "1", 213 | "description": "Create a Journal Entry for the Payout fees and tax.", 214 | "fieldname": "automate_fees_accounting", 215 | "fieldtype": "Check", 216 | "label": "Automate Fees Accounting" 217 | }, 218 | { 219 | "fieldname": "column_break_rshi", 220 | "fieldtype": "Column Break" 221 | }, 222 | { 223 | "depends_on": "eval: doc.automate_fees_accounting", 224 | "fieldname": "fees_accounting_section", 225 | "fieldtype": "Section Break" 226 | }, 227 | { 228 | "depends_on": "eval: doc.bank_account", 229 | "fieldname": "accounting_tab", 230 | "fieldtype": "Tab Break", 231 | "label": "Accounting" 232 | }, 233 | { 234 | "fieldname": "supplier", 235 | "fieldtype": "Link", 236 | "label": "Supplier", 237 | "mandatory_depends_on": "eval: doc.automate_fees_accounting", 238 | "options": "Supplier" 239 | }, 240 | { 241 | "default": "Current Account", 242 | "depends_on": "eval: doc.bank_account", 243 | "fieldname": "payouts_from", 244 | "fieldtype": "Select", 245 | "label": "Payouts from", 246 | "mandatory_depends_on": "eval: doc.bank_account", 247 | "options": "Current Account\nRazorpayX Lite" 248 | }, 249 | { 250 | "description": "Read RazorpayX\n\n \n Fees and Tax\n \n \n \n \n \nfor details on charges and deductions.", 251 | "fieldname": "fees_section", 252 | "fieldtype": "Section Break", 253 | "label": "Fees and Tax" 254 | }, 255 | { 256 | "fieldname": "payout_reversal_section", 257 | "fieldtype": "Section Break", 258 | "label": "Payout Reversal" 259 | }, 260 | { 261 | "fieldname": "column_break_ntie", 262 | "fieldtype": "Column Break" 263 | }, 264 | { 265 | "default": "1", 266 | "description": "Automatically create a Journal Entry on Reversal of Payout and Unreconcile the Payment Entry.", 267 | "fieldname": "create_je_on_reversal", 268 | "fieldtype": "Check", 269 | "label": "Create JE on Payout Reversal" 270 | } 271 | ], 272 | "index_web_pages_for_search": 1, 273 | "links": [], 274 | "modified": "2025-03-26 09:41:53.146171", 275 | "modified_by": "Administrator", 276 | "module": "Razorpayx Integration", 277 | "name": "RazorpayX Configuration", 278 | "naming_rule": "Expression", 279 | "owner": "Administrator", 280 | "permissions": [ 281 | { 282 | "create": 1, 283 | "delete": 1, 284 | "email": 1, 285 | "export": 1, 286 | "print": 1, 287 | "read": 1, 288 | "report": 1, 289 | "role": "RazorpayX Integration Manager", 290 | "select": 1, 291 | "share": 1, 292 | "write": 1 293 | }, 294 | { 295 | "read": 1, 296 | "role": "Online Payments Authorizer", 297 | "select": 1 298 | } 299 | ], 300 | "sort_field": "modified", 301 | "sort_order": "DESC", 302 | "states": [], 303 | "track_changes": 1 304 | } -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/doctype/razorpayx_configuration/razorpayx_configuration.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Resilient Tech and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe import _ 6 | from frappe.model.document import Document 7 | 8 | 9 | class RazorpayXConfiguration(Document): 10 | # begin: auto-generated types 11 | # This code is auto-generated. Do not modify anything in this block. 12 | 13 | from typing import TYPE_CHECKING 14 | 15 | if TYPE_CHECKING: 16 | from frappe.types import DF 17 | 18 | account_id: DF.Data 19 | account_number: DF.Data | None 20 | auto_cancel_payout: DF.Check 21 | automate_fees_accounting: DF.Check 22 | bank: DF.Link | None 23 | bank_account: DF.Link 24 | company: DF.Link | None 25 | company_account: DF.Link | None 26 | create_je_on_reversal: DF.Check 27 | creditors_account: DF.Link | None 28 | disabled: DF.Check 29 | ifsc_code: DF.Data | None 30 | key_id: DF.Data 31 | key_secret: DF.Password 32 | last_sync_on: DF.Date | None 33 | payable_account: DF.Link | None 34 | payouts_from: DF.Literal["Current Account", "RazorpayX Lite"] 35 | supplier: DF.Link | None 36 | webhook_secret: DF.Password | None 37 | # end: auto-generated types 38 | 39 | def validate(self): 40 | self.validate_api_credentials() 41 | self.validate_bank_account() 42 | 43 | def validate_api_credentials(self): 44 | from razorpayx_integration.razorpayx_integration.apis.validate_razorpayx import ( 45 | RazorpayXValidation, 46 | ) 47 | 48 | if self.disabled: 49 | return 50 | 51 | if not self.key_id or not self.key_secret: 52 | frappe.throw( 53 | msg=_("Please set RazorpayX API credentials."), 54 | title=_("API Credentials Are Missing"), 55 | ) 56 | 57 | if not ( 58 | self.has_value_changed("key_id") 59 | or self.has_value_changed("key_secret") 60 | or self.has_value_changed("account_number") 61 | ): 62 | return 63 | 64 | RazorpayXValidation( 65 | self.key_id, 66 | self.get_password(fieldname="key_secret"), 67 | self.account_number, 68 | ).validate_credentials() 69 | 70 | def validate_bank_account(self): 71 | bank_account = frappe.get_value( 72 | "Bank Account", 73 | self.bank_account, 74 | ["disabled", "is_company_account"], 75 | as_dict=True, 76 | ) 77 | 78 | if not bank_account: 79 | frappe.throw( 80 | msg=_("Bank Account not found."), 81 | title=_("Invalid Bank Account"), 82 | ) 83 | 84 | if bank_account.disabled: 85 | frappe.throw( 86 | msg=_("Bank Account is disabled. Please enable it first."), 87 | title=_("Invalid Bank Account"), 88 | ) 89 | 90 | if not bank_account.is_company_account: 91 | frappe.throw( 92 | msg=_("Bank Account is not a company's bank account."), 93 | title=_("Invalid Bank Account"), 94 | ) 95 | 96 | if not self.account_number: 97 | frappe.throw( 98 | msg=_( 99 | "Please set Bank Account Number in bank account." 100 | ), 101 | title=_("Account Number Is Missing"), 102 | ) 103 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/doctype/razorpayx_configuration/test_razorpayx_configuration.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Resilient Tech and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestRazorpayXConfiguration(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/notification/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/notification/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/notification/failed_payout/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/notification/failed_payout/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/notification/failed_payout/failed_payout.json: -------------------------------------------------------------------------------- 1 | { 2 | "attach_print": 0, 3 | "channel": "Email", 4 | "condition": "doc.razorpayx_payout_status in [\"Cancelled\", \"Rejected\", \"Failed\", \"Reversed\"]", 5 | "creation": "2025-01-21 13:45:11.433147", 6 | "days_in_advance": 0, 7 | "docstatus": 0, 8 | "doctype": "Notification", 9 | "document_type": "Payment Entry", 10 | "enabled": 0, 11 | "event": "Method", 12 | "idx": 0, 13 | "is_standard": 0, 14 | "message": "

Dear {{ frappe.db.get_value(\"User\", {\"email\": doc.payment_authorized_by},\"first_name\") }},

\n\n

The payout has been {{ doc.razorpayx_payout_status }}! for Payment Entry {{ doc.name }}.

\n\n

For more details, visit your ERPNext site and check the details.

\n", 15 | "message_type": "Markdown", 16 | "method": "send_rpx_payout_status_update", 17 | "modified": "2025-03-28 12:02:21.410098", 18 | "modified_by": "Administrator", 19 | "module": "Razorpayx Integration", 20 | "name": "Failed Payout", 21 | "owner": "Administrator", 22 | "recipients": [ 23 | { 24 | "receiver_by_document_field": "payment_authorized_by", 25 | "receiver_by_role": "" 26 | } 27 | ], 28 | "send_system_notification": 0, 29 | "send_to_all_assignees": 0, 30 | "sender": "", 31 | "sender_email": "", 32 | "subject": "Payout has been {{ doc.razorpayx_payout_status }} for Payment Entry {{ doc.name }}", 33 | "value_changed": "razorpayx_payout_status" 34 | } -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/notification/failed_payout/failed_payout.md: -------------------------------------------------------------------------------- 1 |

Dear {{ frappe.db.get_value("User", {"email": doc.payment_authorized_by},"first_name") }},

2 | 3 |

The payout has been {{ doc.razorpayx_payout_status }}! for Payment Entry {{ doc.name }}.

4 | 5 |

For more details, visit your ERPNext site and check the details.

6 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/notification/failed_payout/failed_payout.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | def get_context(context): 5 | # do your magic here 6 | pass 7 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/notification/payout_processed/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/notification/payout_processed/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/notification/payout_processed/payout_processed.json: -------------------------------------------------------------------------------- 1 | { 2 | "attach_print": 1, 3 | "channel": "Email", 4 | "condition": "doc.razorpayx_payout_status == \"Processed\"", 5 | "creation": "2025-01-29 12:26:55.180087", 6 | "days_in_advance": 0, 7 | "docstatus": 0, 8 | "doctype": "Notification", 9 | "document_type": "Payment Entry", 10 | "enabled": 0, 11 | "event": "Method", 12 | "idx": 0, 13 | "is_standard": 0, 14 | "message": "

M/S {{ doc.party_name }},

\n\n

A payment of {{ frappe.utils.fmt_money(doc.paid_amount,currency=\"INR\") }} has been transferred to you by {{ doc.company }}.

\n\n

For more information, please check the attachment.

\n", 15 | "message_type": "Markdown", 16 | "method": "send_rpx_payout_status_update", 17 | "modified": "2025-03-28 12:02:01.220293", 18 | "modified_by": "Administrator", 19 | "module": "Razorpayx Integration", 20 | "name": "Payout Processed", 21 | "owner": "Administrator", 22 | "recipients": [ 23 | { 24 | "receiver_by_document_field": "contact_email", 25 | "receiver_by_role": "" 26 | } 27 | ], 28 | "send_system_notification": 0, 29 | "send_to_all_assignees": 0, 30 | "sender": "", 31 | "sender_email": "", 32 | "subject": "Payment of Rs. {{ frappe.utils.fmt_money(doc.paid_amount,currency=\"INR\") }} Transferred to You", 33 | "value_changed": "razorpayx_payout_status" 34 | } -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/notification/payout_processed/payout_processed.md: -------------------------------------------------------------------------------- 1 |

M/S {{ doc.party_name }},

2 | 3 |

A payment of {{ frappe.utils.fmt_money(doc.paid_amount,currency="INR") }} has been transferred to you by {{ doc.company }}.

4 | 5 |

For more information, please check the attachment.

6 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/notification/payout_processed/payout_processed.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | def get_context(context): 5 | # do your magic here 6 | pass 7 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/report/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/report/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/report/razorpayx_payout_status/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/report/razorpayx_payout_status/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/report/razorpayx_payout_status/razorpayx_payout_status.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025, Resilient Tech and contributors 2 | // For license information, please see license.txt 3 | 4 | const DOC_STATUS = { Draft: "grey", Submitted: "blue", Cancelled: "red" }; 5 | 6 | const TIMESPANS = [ 7 | "Today", 8 | "Yesterday", 9 | "This Week", 10 | "This Month", 11 | "This Quarter", 12 | "This Year", 13 | "Last Week", 14 | "Last Month", 15 | "Last Quarter", 16 | "Last Year", 17 | "Select Date Range", 18 | ]; 19 | 20 | const UTR_PLACEHOLDER = "*** UTR WILL BE SET AUTOMATICALLY ***"; 21 | 22 | frappe.query_reports["RazorpayX Payout Status"] = { 23 | filters: [ 24 | { 25 | fieldname: "company", 26 | label: __("Company"), 27 | fieldtype: "Link", 28 | options: "Company", 29 | default: frappe.defaults.get_user_default("Company"), 30 | reqd: 1, 31 | }, 32 | { 33 | fieldname: "date_time_span", 34 | label: __("Posting Date"), 35 | fieldtype: "Select", 36 | options: TIMESPANS, 37 | default: "This Month", 38 | reqd: 1, 39 | on_change: (report) => { 40 | if (report.get_filter_value("date_time_span") === "Select Date Range") { 41 | const date_range = report.get_filter("date_range"); 42 | date_range.df.reqd = 1; 43 | date_range.set_required(1); 44 | } 45 | 46 | report.refresh(); 47 | }, 48 | }, 49 | { 50 | fieldname: "date_range", 51 | fieldtype: "DateRange", 52 | label: __("Posting Date Range"), 53 | depends_on: "eval: doc.date_time_span === 'Select Date Range'", 54 | default: [frappe.datetime.month_start(), frappe.datetime.now_date()], 55 | }, 56 | { 57 | fieldname: "party_type", 58 | label: __("Party Type"), 59 | fieldtype: "Link", 60 | options: "Party Type", 61 | get_query: function () { 62 | return { 63 | filters: { 64 | name: ["in", Object.keys(frappe.boot.party_account_types)], 65 | }, 66 | }; 67 | }, 68 | }, 69 | { 70 | fieldname: "party", 71 | label: __("Party"), 72 | fieldtype: "Dynamic Link", 73 | options: "party_type", 74 | }, 75 | { 76 | fieldname: "docstatus", 77 | label: __("Document Status"), 78 | fieldtype: "MultiSelectList", 79 | get_data: () => get_multiselect_options(Object.keys(DOC_STATUS)), 80 | }, 81 | { 82 | fieldname: "payout_status", 83 | label: __("Payout Status"), 84 | fieldtype: "MultiSelectList", 85 | get_data: () => get_multiselect_options(Object.keys(razorpayx.PAYOUT_STATUS)), 86 | }, 87 | { 88 | fieldname: "payout_mode", 89 | label: __("Payout Mode"), 90 | fieldtype: "MultiSelectList", 91 | get_data: () => 92 | get_multiselect_options(Object.values(payment_integration_utils.PAYMENT_TRANSFER_METHOD)), 93 | }, 94 | { 95 | fieldname: "razorpayx_config", 96 | label: __("RazorpayX Configuration"), 97 | fieldtype: "Link", 98 | options: "RazorpayX Configuration", 99 | get_query: function () { 100 | return { 101 | filters: { company: frappe.query_report.get_filter_value("company") }, 102 | }; 103 | }, 104 | }, 105 | { 106 | fieldname: "payout_made_by", 107 | label: __("Payout Made By"), 108 | fieldtype: "Link", 109 | options: "User", 110 | }, 111 | ], 112 | 113 | onload: function (report) { 114 | const docstatus = report.get_filter("docstatus"); 115 | 116 | if (docstatus && (!docstatus.get_value() || docstatus.get_value().length === 0)) { 117 | docstatus.set_value("Submitted"); 118 | } 119 | }, 120 | 121 | formatter: function (value, row, column, data, default_formatter) { 122 | value = default_formatter(value, row, column, data); 123 | 124 | if (column.fieldname === "docstatus") { 125 | value = this.get_formatted_docstatus(value); 126 | } else if (column.fieldname === "payout_status") { 127 | value = this.get_formatted_payout_status(value); 128 | } else if (column.fieldname === "utr") { 129 | if (value === UTR_PLACEHOLDER) { 130 | value = `-`; 131 | } 132 | } 133 | 134 | return value; 135 | }, 136 | 137 | get_formatted_docstatus: function (value) { 138 | return `
139 | 140 | ${value} 141 | 142 |
`; 143 | }, 144 | 145 | get_formatted_payout_status: function (value) { 146 | return `
147 | ${value} 148 |
`; 149 | }, 150 | }; 151 | 152 | function get_multiselect_options(values) { 153 | const options = []; 154 | for (const option of values) { 155 | options.push({ 156 | value: option, 157 | label: option, 158 | description: "", 159 | }); 160 | } 161 | return options; 162 | } 163 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/report/razorpayx_payout_status/razorpayx_payout_status.json: -------------------------------------------------------------------------------- 1 | { 2 | "add_total_row": 1, 3 | "add_translate_data": 0, 4 | "columns": [], 5 | "creation": "2025-03-13 16:55:43.309877", 6 | "disabled": 0, 7 | "docstatus": 0, 8 | "doctype": "Report", 9 | "filters": [], 10 | "idx": 0, 11 | "is_standard": "Yes", 12 | "letterhead": null, 13 | "modified": "2025-03-13 16:55:43.309877", 14 | "modified_by": "Administrator", 15 | "module": "Razorpayx Integration", 16 | "name": "RazorpayX Payout Status", 17 | "owner": "Administrator", 18 | "prepared_report": 0, 19 | "ref_doctype": "Payment Entry", 20 | "report_name": "RazorpayX Payout Status", 21 | "report_type": "Script Report", 22 | "roles": [ 23 | { 24 | "role": "Online Payments Authorizer" 25 | }, 26 | { 27 | "role": "RazorpayX Integration Manager" 28 | } 29 | ], 30 | "timeout": 0 31 | } -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/report/razorpayx_payout_status/razorpayx_payout_status.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, Resilient Tech and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe import _ 6 | from frappe.query_builder.functions import Date 7 | from frappe.utils.data import get_timespan_date_range 8 | 9 | from razorpayx_integration.constants import RAZORPAYX_CONFIG 10 | 11 | 12 | def execute(filters=None): 13 | return get_columns(), get_data(filters) 14 | 15 | 16 | def get_data(filters: dict | None = None) -> list[dict]: 17 | from_date, to_date = parse_date_range(filters) 18 | 19 | PE = frappe.qb.DocType("Payment Entry") 20 | 21 | base_query = ( 22 | frappe.qb.from_(PE) 23 | .select( 24 | PE.name.as_("payment_entry"), 25 | PE.posting_date, 26 | PE.company, 27 | PE.party_type, 28 | PE.party, 29 | PE.paid_amount, 30 | PE.razorpayx_payout_status.as_("payout_status"), 31 | PE.payment_transfer_method.as_("payout_mode"), 32 | PE.razorpayx_payout_desc.as_("payout_description"), 33 | PE.status.as_("docstatus"), 34 | PE.payment_authorized_by.as_("payout_made_by"), 35 | PE.integration_docname.as_("razorpayx_config"), 36 | PE.reference_no.as_("utr"), 37 | PE.razorpayx_payout_id.as_("payout_id"), 38 | PE.razorpayx_payout_link_id.as_("payout_link_id"), 39 | ) 40 | .where(PE.company == filters.company) 41 | .where(PE.posting_date >= Date(from_date)) 42 | .where(PE.posting_date <= Date(to_date)) 43 | .where(PE.integration_doctype == RAZORPAYX_CONFIG) 44 | .where(PE.make_bank_online_payment == 1) 45 | .orderby(PE.posting_date, order=frappe.qb.desc) 46 | ) 47 | 48 | # update the query based on filters 49 | if filters.party_type: 50 | base_query = base_query.where(PE.party_type == filters.party_type) 51 | 52 | if filters.party: 53 | base_query = base_query.where(PE.party == filters.party) 54 | 55 | if filters.docstatus: 56 | base_query = base_query.where(PE.status.isin(filters.docstatus)) 57 | 58 | if filters.payout_status: 59 | base_query = base_query.where( 60 | PE.razorpayx_payout_status.isin(filters.payout_status) 61 | ) 62 | 63 | if filters.payout_mode: 64 | base_query = base_query.where( 65 | PE.payment_transfer_method.isin(filters.payout_mode) 66 | ) 67 | 68 | if filters.razorpayx_config: 69 | base_query = base_query.where( 70 | PE.integration_docname == filters.razorpayx_config 71 | ) 72 | 73 | if filters.payout_made_by: 74 | base_query = base_query.where( 75 | PE.payment_authorized_by == filters.payout_made_by 76 | ) 77 | 78 | return base_query.run(as_dict=True) 79 | 80 | 81 | def parse_date_range(filters: dict) -> tuple[str, str]: 82 | if filters.date_time_span == "Select Date Range": 83 | return filters.date_range 84 | 85 | return get_timespan_date_range(filters.date_time_span.lower()) 86 | 87 | 88 | def get_columns() -> list[dict]: 89 | return [ 90 | { 91 | "label": _("Payment Entry"), 92 | "fieldname": "payment_entry", 93 | "fieldtype": "Link", 94 | "options": "Payment Entry", 95 | "width": 200, 96 | }, 97 | { 98 | "label": _("Company"), 99 | "fieldname": "company", 100 | "fieldtype": "Link", 101 | "options": "Company", 102 | "width": 180, 103 | }, 104 | { 105 | "label": _("Posting Date"), 106 | "fieldname": "posting_date", 107 | "fieldtype": "Date", 108 | "width": 150, 109 | }, 110 | { 111 | "label": _("Party Type"), 112 | "fieldname": "party_type", 113 | "fieldtype": "Link", 114 | "options": "Party Type", 115 | "width": 120, 116 | }, 117 | { 118 | "label": _("Party"), 119 | "fieldname": "party", 120 | "fieldtype": "Dynamic Link", 121 | "options": "party_type", 122 | "width": 180, 123 | }, 124 | { 125 | "label": _("Paid Amount"), 126 | "fieldname": "paid_amount", 127 | "fieldtype": "Currency", 128 | "options": "INR", 129 | "width": 150, 130 | }, 131 | { 132 | "label": _("Payout Status"), 133 | "fieldname": "payout_status", 134 | "fieldtype": "Data", 135 | "width": 120, 136 | }, 137 | { 138 | "label": _("Payout Mode"), 139 | "fieldname": "payout_mode", 140 | "fieldtype": "Data", 141 | "width": 100, 142 | }, 143 | { 144 | "label": _("Payout Description"), 145 | "fieldname": "payout_description", 146 | "fieldtype": "Data", 147 | "width": 200, 148 | }, 149 | { 150 | "label": _("Docstatus"), 151 | "fieldname": "docstatus", 152 | "fieldtype": "Data", 153 | "width": 120, 154 | }, 155 | { 156 | "label": _("Payout Made By"), 157 | "fieldname": "payout_made_by", 158 | "fieldtype": "Link", 159 | "options": "User", 160 | "width": 200, 161 | }, 162 | { 163 | "label": _("RazorpayX Configuration"), 164 | "fieldname": "razorpayx_config", 165 | "fieldtype": "Link", 166 | "options": "RazorpayX Configuration", 167 | "width": 150, 168 | }, 169 | { 170 | "label": _("UTR"), 171 | "fieldname": "utr", 172 | "fieldtype": "Data", 173 | "width": 200, 174 | }, 175 | { 176 | "label": _("Payout ID"), 177 | "fieldname": "payout_id", 178 | "fieldtype": "Data", 179 | "width": 180, 180 | }, 181 | { 182 | "label": _("Payout Link ID"), 183 | "fieldname": "payout_link_id", 184 | "fieldtype": "Data", 185 | "width": 180, 186 | }, 187 | ] 188 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/server_overrides/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/razorpayx_integration/server_overrides/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/server_overrides/doctype/payment_entry.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | import frappe 4 | from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry 5 | from frappe import _ 6 | from payment_integration_utils.payment_integration_utils.constants.payments import ( 7 | TRANSFER_METHOD, 8 | ) 9 | from payment_integration_utils.payment_integration_utils.server_overrides.doctype.payment_entry import ( 10 | validate_transfer_methods, 11 | ) 12 | from payment_integration_utils.payment_integration_utils.utils.auth import ( 13 | run_before_payment_authentication as has_payment_permissions, 14 | ) 15 | 16 | from razorpayx_integration.constants import RAZORPAYX_CONFIG 17 | from razorpayx_integration.razorpayx_integration.constants.payouts import ( 18 | PAYOUT_CURRENCY, 19 | ) 20 | from razorpayx_integration.razorpayx_integration.utils import ( 21 | is_auto_cancel_payout_enabled, 22 | is_auto_pay_enabled, 23 | is_payout_via_razorpayx, 24 | ) 25 | from razorpayx_integration.razorpayx_integration.utils.payout import ( 26 | PayoutWithPaymentEntry, 27 | ) 28 | 29 | #### CONSTANTS #### 30 | TRANSFER_METHODS = Literal["NEFT", "RTGS", "IMPS", "UPI", "Link"] 31 | UTR_PLACEHOLDER = "*** UTR WILL BE SET AUTOMATICALLY ***" 32 | 33 | 34 | #### DOC EVENTS #### 35 | def onload(doc: PaymentEntry, method=None): 36 | if doc.docstatus == 1 and is_payout_via_razorpayx(doc): 37 | doc.set_onload( 38 | "auto_cancel_payout_enabled", 39 | is_auto_cancel_payout_enabled(doc.integration_docname), 40 | ) 41 | 42 | 43 | def validate(doc: PaymentEntry, method=None): 44 | if doc.flags._is_already_paid: 45 | return 46 | 47 | set_integration_config(doc) 48 | set_for_payments_processor(doc) 49 | validate_payout_details(doc) 50 | 51 | 52 | def before_submit(doc: PaymentEntry, method=None): 53 | # for bulk submission from client side or single submission without payment 54 | if not should_uncheck_make_bank_online_payment(doc): 55 | return 56 | 57 | # PE is not authorized to make payout or auto pay is disabled 58 | doc.make_bank_online_payment = 0 59 | 60 | if frappe.flags.initiated_by_payment_processor: 61 | return 62 | 63 | # Show single alert message only 64 | alert_msg = _("Please make payout manually after Payment Entry submission.") 65 | alert_sent = False 66 | 67 | for message in frappe.message_log: 68 | if alert_msg in message.get("message"): 69 | alert_sent = True 70 | break 71 | 72 | if not alert_sent: 73 | frappe.msgprint(msg=alert_msg, alert=True) 74 | 75 | 76 | def should_uncheck_make_bank_online_payment(doc: PaymentEntry) -> bool: 77 | if not is_payout_via_razorpayx(doc): 78 | return False 79 | 80 | should_uncheck_payment_flag = ( 81 | not is_auto_pay_enabled(doc.integration_docname) 82 | if frappe.flags.initiated_by_payment_processor 83 | else not doc.flags._is_already_paid and not get_auth_id(doc) 84 | ) 85 | 86 | return should_uncheck_payment_flag 87 | 88 | 89 | def on_submit(doc: PaymentEntry, method=None): 90 | if not is_payout_via_razorpayx(doc): 91 | return 92 | 93 | PayoutWithPaymentEntry(doc).make(get_auth_id(doc)) 94 | 95 | 96 | def before_cancel(doc: PaymentEntry, method=None): 97 | # PE is cancelled by RazorpayX webhook or PE is cancelled when payout got cancelled 98 | if not is_payout_via_razorpayx(doc) or doc.flags.__canceled_by_rpx: 99 | return 100 | 101 | PayoutWithPaymentEntry(doc).cancel() 102 | 103 | 104 | ### AUTHORIZATION ### 105 | def get_auth_id(doc: PaymentEntry) -> str | None: 106 | """ 107 | Get `auth_id` from Payment Entry onload. 108 | 109 | It is used to authorize the Payment Entry to make payout. 110 | """ 111 | onload = doc.get_onload() or frappe._dict() 112 | return onload.get("auth_id") 113 | 114 | 115 | #### VALIDATIONS #### 116 | def set_integration_config(doc: PaymentEntry): 117 | def reset_rpx_config(): 118 | if doc.integration_doctype == RAZORPAYX_CONFIG: 119 | doc.integration_doctype = "" 120 | doc.integration_docname = "" 121 | 122 | if doc.paid_from_account_currency != PAYOUT_CURRENCY.INR.value: 123 | reset_rpx_config() 124 | return 125 | 126 | if config := frappe.db.get_value( 127 | RAZORPAYX_CONFIG, {"disabled": 0, "bank_account": doc.bank_account} 128 | ): 129 | doc.integration_doctype = RAZORPAYX_CONFIG 130 | doc.integration_docname = config 131 | else: 132 | reset_rpx_config() 133 | 134 | 135 | def set_for_payments_processor(doc: PaymentEntry): 136 | if not frappe.flags.initiated_by_payment_processor: 137 | return 138 | 139 | if doc.integration_doctype != RAZORPAYX_CONFIG: 140 | return 141 | 142 | if not is_auto_pay_enabled(doc.integration_docname): 143 | return 144 | 145 | def get_payout_desc() -> str: 146 | invoice = doc.flags.invoice_list[0] 147 | desc = invoice.bill_no or invoice.name 148 | desc = "".join(e for e in desc if e.isalnum()) 149 | return desc[:30] 150 | 151 | doc.make_bank_online_payment = 1 152 | doc.razorpayx_payout_desc = get_payout_desc() 153 | 154 | 155 | def validate_payout_details(doc: PaymentEntry): 156 | if not doc.make_bank_online_payment or doc.integration_doctype != RAZORPAYX_CONFIG: 157 | return 158 | 159 | if not doc.bank_account: 160 | frappe.throw( 161 | msg=_("Company's Bank Account is mandatory to make payment."), 162 | title=_("Mandatory Field Missing"), 163 | exc=frappe.MandatoryError, 164 | ) 165 | 166 | if not doc.reference_no or doc.docstatus == 0: 167 | doc.reference_no = UTR_PLACEHOLDER 168 | 169 | if ( 170 | doc.payment_transfer_method == TRANSFER_METHOD.LINK.value 171 | and not doc.razorpayx_payout_desc 172 | ): 173 | frappe.throw( 174 | msg=_("Payout Description is mandatory to make Payout Link."), 175 | title=_("Mandatory Fields Missing"), 176 | exc=frappe.MandatoryError, 177 | ) 178 | 179 | 180 | ### APIs ### 181 | # TODO: Make API more easy to use and less error-prone 182 | # 1. Fetch bank account details from the `party_bank_account` 183 | # 2. Fetch contact details from the `contact_person` or set directly mobile and email 184 | # 3. If party is `Employee`, fetch contact details from the Employee's contact 185 | # 4. Also check `Contact Person` and `Party Bank Account` is associated with the `Party` 186 | # 6. Based on the `transfer_method`, set the fields automatically 187 | @frappe.whitelist() 188 | def make_payout_with_razorpayx( 189 | auth_id: str, 190 | docname: str, 191 | transfer_method: TRANSFER_METHODS = TRANSFER_METHOD.LINK.value, 192 | **kwargs, 193 | ): 194 | """ 195 | Make RazorpayX Payout or Payout Link with Payment Entry. 196 | 197 | :param auth_id: Authentication ID (after otp or password verification) 198 | :param docname: Payment Entry name 199 | :param transfer_method: Transfer method to make payout with (NEFT, RTGS, IMPS, UPI, Link) 200 | :param kwargs: Payout Details 201 | """ 202 | has_payment_permissions(docname, throw=True) 203 | doc = frappe.get_doc("Payment Entry", docname) 204 | 205 | if doc.make_bank_online_payment: 206 | frappe.msgprint( 207 | msg=_( 208 | "Payout for {0} is already in {1} state" 209 | ).format(docname, doc.razorpayx_payout_status), 210 | alert=True, 211 | ) 212 | 213 | return 214 | 215 | # Set the fields to make payout 216 | doc.db_set( 217 | { 218 | "make_bank_online_payment": 1, 219 | "payment_transfer_method": transfer_method, 220 | # Party 221 | "party_bank_account": kwargs.get("party_bank_account"), 222 | "party_bank_account_no": kwargs.get("party_bank_account_no"), 223 | "party_bank_ifsc": kwargs.get("party_bank_ifsc"), 224 | "party_upi_id": kwargs.get("party_upi_id"), 225 | "contact_person": kwargs.get("contact_person"), 226 | "contact_mobile": kwargs.get("contact_mobile"), 227 | "contact_email": kwargs.get("contact_email"), 228 | # RazorpayX 229 | "razorpayx_payout_desc": kwargs.get("razorpayx_payout_desc"), 230 | # ERPNext 231 | "reference_no": UTR_PLACEHOLDER, 232 | "remarks": doc.remarks.replace(doc.reference_no, UTR_PLACEHOLDER, 1), 233 | } 234 | ) 235 | 236 | validate_transfer_methods(doc) 237 | validate_payout_details(doc) 238 | PayoutWithPaymentEntry(doc).make(auth_id) 239 | 240 | 241 | @frappe.whitelist() 242 | def mark_payout_for_cancellation(docname: str, cancel: bool | int): 243 | """ 244 | Marking Payment Entry's payout or payout link for cancellation. 245 | 246 | Saving in cache to remember the action. 247 | 248 | :param docname: Payment Entry name. 249 | :param cancel: Cancel or not. 250 | """ 251 | 252 | frappe.has_permission("Payment Entry", "cancel", doc=docname, throw=True) 253 | 254 | config = frappe.db.get_value("Payment Entry", docname, "integration_docname") 255 | frappe.has_permission(RAZORPAYX_CONFIG, doc=config, throw=True) 256 | 257 | key = PayoutWithPaymentEntry.get_cancel_payout_key(docname) 258 | value = "True" if cancel else "False" 259 | 260 | frappe.cache.set(key, value, 100) 261 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry 3 | 4 | from razorpayx_integration.constants import PAYMENTS_PROCESSOR_APP, RAZORPAYX_CONFIG 5 | 6 | 7 | def is_payout_via_razorpayx(doc: PaymentEntry) -> bool: 8 | """ 9 | Check if the Payment Entry is paid via RazorpayX. 10 | """ 11 | return bool( 12 | doc.make_bank_online_payment 13 | and doc.integration_doctype == RAZORPAYX_CONFIG 14 | and doc.integration_docname 15 | ) 16 | 17 | 18 | def is_auto_cancel_payout_enabled(razorpayx_config: str) -> bool | int: 19 | return frappe.db.get_value(RAZORPAYX_CONFIG, razorpayx_config, "auto_cancel_payout") 20 | 21 | 22 | def is_auto_pay_enabled(razorpayx_config: str) -> bool | int: 23 | if PAYMENTS_PROCESSOR_APP not in frappe.get_installed_apps(): 24 | return False 25 | 26 | return frappe.db.get_value(RAZORPAYX_CONFIG, razorpayx_config, "pay_on_auto_submit") 27 | 28 | 29 | def get_fees_accounting_config(razorpayx_config: str) -> dict: 30 | return ( 31 | frappe.db.get_value( 32 | RAZORPAYX_CONFIG, 33 | razorpayx_config, 34 | [ 35 | "automate_fees_accounting", 36 | "payouts_from", 37 | "creditors_account", 38 | "supplier", 39 | "payable_account", 40 | ], 41 | as_dict=True, 42 | ) 43 | or frappe._dict() 44 | ) 45 | 46 | 47 | def is_create_je_on_reversal_enabled(razorpayx_config: str) -> bool | int: 48 | return frappe.db.get_value( 49 | RAZORPAYX_CONFIG, razorpayx_config, "create_je_on_reversal" 50 | ) 51 | 52 | 53 | def get_payouts_made_from(razorpayx_config: str) -> str: 54 | return frappe.db.get_value(RAZORPAYX_CONFIG, razorpayx_config, "payouts_from") 55 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/utils/bank_transaction.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | import frappe 4 | from frappe import _ 5 | from frappe.utils import DateTimeLikeObject, getdate 6 | from payment_integration_utils.payment_integration_utils.utils import ( 7 | get_str_datetime_from_epoch, 8 | paisa_to_rupees, 9 | ) 10 | 11 | from razorpayx_integration.constants import RAZORPAYX_CONFIG 12 | from razorpayx_integration.razorpayx_integration.apis.transaction import ( 13 | RazorpayXTransaction, 14 | ) 15 | from razorpayx_integration.razorpayx_integration.constants.payouts import PAYOUT_FROM 16 | from razorpayx_integration.razorpayx_integration.constants.webhooks import ( 17 | TRANSACTION_TYPE as ENTITY, 18 | ) 19 | from razorpayx_integration.razorpayx_integration.utils import get_payouts_made_from 20 | 21 | 22 | ######### PROCESSOR ######### 23 | class RazorpayXBankTransaction: 24 | def __init__( 25 | self, 26 | razorpayx_config: str, 27 | from_date: DateTimeLikeObject | None = None, 28 | to_date: DateTimeLikeObject | None = None, 29 | *, 30 | bank_account: str | None = None, 31 | source_doctype: str | None = None, 32 | source_docname: str | None = None, 33 | ): 34 | self.razorpayx_config = razorpayx_config 35 | self.from_date = from_date 36 | self.to_date = to_date 37 | self.source_doctype = source_doctype 38 | self.source_docname = source_docname 39 | 40 | self.set_bank_account(bank_account) 41 | 42 | def set_bank_account(self, bank_account: str | None = None): 43 | if not bank_account: 44 | bank_account = frappe.db.get_value( 45 | doctype=RAZORPAYX_CONFIG, 46 | filters=self.razorpayx_config, 47 | fieldname="bank_account", 48 | ) 49 | 50 | if not bank_account: 51 | frappe.throw( 52 | msg=_( 53 | "Company Bank Account not found for RazorpayX Configuration {0}" 54 | ).format(self.razorpayx_config), 55 | title=_("Company Bank Account Not Found"), 56 | ) 57 | 58 | self.bank_account = bank_account 59 | 60 | def sync(self): 61 | transactions = self.fetch_transactions() 62 | 63 | if not transactions: 64 | return 65 | 66 | existing_transactions = self.get_existing_transactions(transactions) 67 | 68 | for transaction in transactions: 69 | if transaction["id"] in existing_transactions: 70 | continue 71 | 72 | self.create(self.map(transaction)) 73 | 74 | def fetch_transactions(self) -> list[dict] | None: 75 | """ 76 | Fetching Bank Transactions from RazorpayX API. 77 | """ 78 | try: 79 | return RazorpayXTransaction(self.razorpayx_config).get_all( 80 | from_date=self.from_date, 81 | to_date=self.to_date, 82 | source_doctype=self.source_doctype, 83 | source_docname=self.source_docname, 84 | ) 85 | 86 | except Exception: 87 | frappe.log_error( 88 | title=( 89 | f"Failed to Fetch RazorpayX Transactions for Config: {self.razorpayx_config}" 90 | ), 91 | message=frappe.get_traceback(), 92 | reference_doctype=RAZORPAYX_CONFIG, 93 | reference_name=self.razorpayx_config, 94 | ) 95 | 96 | def get_existing_transactions(self, transactions: list[str]) -> set[str]: 97 | """ 98 | Get existing bank account transactions from the ERPNext database. 99 | 100 | :param transactions: List of transactions from RazorpayX API. 101 | """ 102 | return set( 103 | frappe.get_all( 104 | "Bank Transaction", 105 | filters={ 106 | "bank_account": self.bank_account, 107 | "transaction_id": ( 108 | "in", 109 | {transaction["id"] for transaction in transactions}, 110 | ), 111 | }, 112 | pluck="transaction_id", 113 | ) 114 | ) 115 | 116 | def map(self, transaction: dict): 117 | """ 118 | Map RazorpayX transaction to ERPNext's Bank Transaction. 119 | 120 | :param transaction: RazorpayX Transaction 121 | """ 122 | 123 | def get_description(source: dict) -> str | None: 124 | # TODO: Needs description of payout/bank transfer or other transactions 125 | notes = source.get("notes") or {} 126 | 127 | if not notes: 128 | return 129 | 130 | description = "" 131 | source_doctype = notes.get("source_doctype") 132 | source_docname = notes.get("source_docname") 133 | 134 | if source_doctype and source_docname: 135 | description = f"{source_doctype}: {source_docname}" 136 | 137 | if desc := notes.get("description"): 138 | description += f"\nNarration: {desc}" 139 | 140 | if not description: 141 | description = "\n".join(notes.values()) 142 | 143 | return description 144 | 145 | # Some transactions do not have source 146 | source = transaction.get("source") or {} 147 | 148 | mapped = { 149 | "doctype": "Bank Transaction", 150 | "bank_account": self.bank_account, 151 | "transaction_id": transaction["id"], 152 | "date": get_str_datetime_from_epoch(transaction["created_at"]), 153 | "deposit": paisa_to_rupees(transaction["credit"]), 154 | "withdrawal": paisa_to_rupees(transaction["debit"]), 155 | "closing_balance": paisa_to_rupees(transaction["balance"]), 156 | "currency": transaction["currency"], 157 | "transaction_type": source.get("mode"), 158 | "description": get_description(source), 159 | "reference_number": source.get("utr") or source.get("bank_reference"), 160 | } 161 | 162 | # auto reconciliation 163 | # TODO: fees deduction at the end of the day is not handled 164 | mapped["payment_entries"] = [] 165 | self.set_matching_payment_entry(mapped, source) 166 | self.set_matching_journal_entry(mapped, source) 167 | 168 | return mapped 169 | 170 | def set_matching_payment_entry(self, mapped: dict, source: dict | None = None): 171 | """ 172 | Setting matching Payment Entry for the Bank Reconciliation. 173 | 174 | :param mapped: Mapped Bank Transaction 175 | :param source: Source of the transaction (In transaction response) 176 | 177 | --- 178 | Note: 179 | - Payment Entry will be find by `Payout ID` or `UTR`. 180 | - For `reversal` entity it returns without finding PE. 181 | """ 182 | if not source or source.get("entity") == ENTITY.REVERSAL.value: 183 | return 184 | 185 | def get_payment_entry(**filters): 186 | # TODO: confirm company or bank account 187 | return frappe.db.get_value( 188 | "Payment Entry", 189 | { 190 | "docstatus": 1, 191 | "clearance_date": ["is", "not set"], 192 | **filters, 193 | }, 194 | fieldname=["name", "paid_amount"], 195 | order_by="creation desc", # to get latest 196 | as_dict=True, 197 | ) 198 | 199 | payment_entry = None 200 | 201 | # reconciliation with payout_id or bank_reference 202 | if source.get("entity") == ENTITY.PAYOUT.value: 203 | payment_entry = get_payment_entry(razorpayx_payout_id=source["id"]) 204 | elif source.get("entity") == ENTITY.BANK_TRANSFER.value: 205 | payment_entry = get_payment_entry(reference_no=source["bank_reference"]) 206 | 207 | # reconciliation with reference number (UTR) 208 | if not payment_entry and source.get("utr"): 209 | payment_entry = get_payment_entry(reference_no=mapped["reference_number"]) 210 | 211 | if not payment_entry: 212 | return 213 | 214 | mapped["payment_entries"].append( 215 | { 216 | "payment_document": "Payment Entry", 217 | "payment_entry": payment_entry.name, 218 | "allocated_amount": payment_entry.paid_amount, 219 | } 220 | ) 221 | 222 | def set_matching_journal_entry(self, mapped: dict, source: dict | None = None): 223 | """ 224 | Setting matching Journal Entry for the Bank Reconciliation. 225 | 226 | :param mapped: Mapped Bank Transaction 227 | :param source: Source of the transaction (In transaction response) 228 | 229 | --- 230 | Note: 231 | - For reversal, two transactions will be created. 232 | - simple transaction with reversal id 233 | - payout reversal transaction with payout id 234 | - JE created only when payout processed or reversed 235 | - JE will be find by `Payout ID` or `Reversal ID` or `UTR` or `Bank Reference`. 236 | """ 237 | if not source: 238 | return 239 | 240 | payouts_from = get_payouts_made_from(self.razorpayx_config) 241 | 242 | def get_journal_entry( 243 | check_no: str, 244 | reversal_of: Literal["set", "not set"], 245 | ) -> dict | None: 246 | return frappe.db.get_value( 247 | "Journal Entry", 248 | { 249 | "is_system_generated": 1, 250 | "docstatus": 1, 251 | "difference": 0, 252 | "cheque_no": check_no, 253 | "reversal_of": ["is", reversal_of], 254 | }, 255 | fieldname=["name", "total_debit"], 256 | as_dict=True, 257 | ) 258 | 259 | def is_current_account_payout() -> bool: 260 | return payouts_from == PAYOUT_FROM.CURRENT_ACCOUNT.value 261 | 262 | entity = source.get("entity") 263 | 264 | # for current account payouts, fees will not be deducted immediately but JE will be created 265 | if entity == ENTITY.PAYOUT.value and is_current_account_payout(): 266 | return 267 | 268 | # get cheque no to fetch JE 269 | cheque_no = "" 270 | 271 | if entity in [ENTITY.PAYOUT.value, ENTITY.REVERSAL.value]: 272 | cheque_no = source.get("id") 273 | elif entity == ENTITY.BANK_TRANSFER.value: 274 | cheque_no = source.get("bank_reference") 275 | else: 276 | cheque_no = source.get("utr") 277 | 278 | if not cheque_no: 279 | return 280 | 281 | # finding Fees JE or Payout Reversal JE with `cheque_no` 282 | # Note: for fees `check_no` is payout_id and for reversal `check_no` is reversal_id 283 | journal_entry = get_journal_entry(cheque_no, "not set") 284 | 285 | if journal_entry: 286 | mapped["payment_entries"].append( 287 | { 288 | "payment_document": "Journal Entry", 289 | "payment_entry": journal_entry.name, 290 | "allocated_amount": journal_entry.total_debit, 291 | } 292 | ) 293 | 294 | if entity != "reversal" or is_current_account_payout(): 295 | return 296 | 297 | # get fees reversal JE (Only for RazorpayX Lite) 298 | fees_reversal_je = get_journal_entry(cheque_no, "set") 299 | 300 | if not fees_reversal_je: 301 | return 302 | 303 | mapped["payment_entries"].append( 304 | { 305 | "payment_document": "Journal Entry", 306 | "payment_entry": fees_reversal_je.name, 307 | "allocated_amount": fees_reversal_je.total_debit, 308 | } 309 | ) 310 | 311 | # TODO: can use bulk insert? 312 | def create(self, mapped_transaction: dict): 313 | """ 314 | Create Bank Transaction in the ERPNext. 315 | 316 | :param mapped_transaction: Mapped Bank Transaction 317 | """ 318 | return ( 319 | frappe.get_doc(mapped_transaction).insert(ignore_permissions=True).submit() 320 | ) 321 | 322 | 323 | ######### APIs ######### 324 | @frappe.whitelist() 325 | def sync_transactions_for_reconcile( 326 | bank_account: str, razorpayx_config: str | None = None 327 | ): 328 | """ 329 | Sync RazorpayX bank account transactions. 330 | 331 | Syncs from the last sync date to the current date. 332 | 333 | If last sync date is not set, it will sync all transactions. 334 | 335 | :param bank_account: Company Bank Account 336 | :param razorpayx_config: RazorpayX Configuration 337 | """ 338 | BRT = "Bank Reconciliation Tool" 339 | frappe.has_permission(BRT, throw=True) 340 | 341 | if not razorpayx_config: 342 | razorpayx_config = frappe.db.get_value( 343 | RAZORPAYX_CONFIG, {"bank_account": bank_account, "disabled": 0} 344 | ) 345 | 346 | if not razorpayx_config: 347 | frappe.throw( 348 | _( 349 | "RazorpayX Configuration not found for Bank Account {0}" 350 | ).format(bank_account) 351 | ) 352 | 353 | RazorpayXBankTransaction( 354 | razorpayx_config, 355 | bank_account=bank_account, 356 | source_docname=BRT, 357 | source_doctype=BRT, 358 | ).sync() 359 | 360 | 361 | # TODO: we need to enqueue this or not!! 362 | @frappe.whitelist() 363 | def sync_bank_transactions_with_razorpayx( 364 | razorpayx_config: str, 365 | from_date: DateTimeLikeObject, 366 | to_date: DateTimeLikeObject, 367 | bank_account: str | None = None, 368 | ): 369 | """ 370 | Sync RazorpayX bank account transactions. 371 | 372 | :param razorpayx_config: RazorpayX Configuration which has the bank account. 373 | :param from_date: Start Date 374 | :param to_date: End Date 375 | :param bank_account: Company Bank Account 376 | """ 377 | frappe.has_permission(RAZORPAYX_CONFIG, throw=True) 378 | 379 | RazorpayXBankTransaction( 380 | razorpayx_config, 381 | from_date, 382 | to_date, 383 | bank_account=bank_account, 384 | source_doctype=RAZORPAYX_CONFIG, 385 | source_docname=razorpayx_config, 386 | ).sync() 387 | 388 | 389 | def sync_transactions_periodically(): 390 | """ 391 | Sync all enabled RazorpayX bank account transactions. 392 | 393 | Called by scheduler. 394 | """ 395 | today = getdate() 396 | 397 | configs = frappe.get_all( 398 | doctype=RAZORPAYX_CONFIG, 399 | filters={"disabled": 0}, 400 | fields=["name", "bank_account"], 401 | ) 402 | 403 | if not configs: 404 | return 405 | 406 | for config in configs: 407 | RazorpayXBankTransaction(config.name, bank_account=config.bank_account).sync() 408 | 409 | # update last sync date 410 | frappe.db.set_value( 411 | RAZORPAYX_CONFIG, 412 | {"name": ("in", {config.name for config in configs})}, 413 | "last_sync_on", 414 | today, 415 | ) 416 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/utils/payout.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry 3 | from frappe import _ 4 | from payment_integration_utils.payment_integration_utils.constants.payments import ( 5 | TRANSFER_METHOD as PAYOUT_MODE, 6 | ) 7 | from payment_integration_utils.payment_integration_utils.utils import ( 8 | is_already_paid, 9 | ) 10 | from payment_integration_utils.payment_integration_utils.utils.auth import ( 11 | Authenticate2FA, 12 | ) 13 | 14 | from razorpayx_integration.razorpayx_integration.apis.payout import ( 15 | RazorpayXCompositePayout, 16 | RazorpayXLinkPayout, 17 | ) 18 | from razorpayx_integration.razorpayx_integration.constants.payouts import ( 19 | PAYOUT_CURRENCY, 20 | PAYOUT_STATUS, 21 | STATUS_NOTIFICATION_METHOD, 22 | ) 23 | from razorpayx_integration.razorpayx_integration.utils import ( 24 | is_auto_cancel_payout_enabled, 25 | is_payout_via_razorpayx, 26 | ) 27 | 28 | 29 | class PayoutWithPaymentEntry: 30 | """ 31 | Handle Razorpayx Payout | Payout Link with Payment Entry. 32 | 33 | :param payment_entry: Payment Entry doc. 34 | 35 | --- 36 | Caution: 🔴 Payout with `Fund Account ID` and Payout Link with `Contact ID` are not supported. 37 | """ 38 | 39 | def __init__(self, doc: PaymentEntry, *args, **kwargs): 40 | self.doc = doc 41 | self.config_name = self.doc.integration_docname 42 | 43 | ### Make Payout | Payout Link ### 44 | def make(self, auth_id: str | None = None) -> dict | None: 45 | """ 46 | Make payout with given Payment Entry. 47 | 48 | :param auth_id: Authentication ID for making payout. 49 | """ 50 | if is_already_paid(self.doc.amended_from): 51 | return 52 | 53 | if not self._can_make_payout(): 54 | frappe.throw( 55 | msg=_( 56 | "Payout cannot be made for this Payment Entry. Please check the payout details." 57 | ), 58 | title=_("Invalid Payment Entry"), 59 | ) 60 | 61 | self._is_authenticated_payout(auth_id) 62 | 63 | payout_details = self._get_payout_details() 64 | 65 | if self.doc.payment_transfer_method == PAYOUT_MODE.LINK.value: 66 | response = RazorpayXLinkPayout(self.config_name).pay(payout_details) 67 | else: 68 | response = RazorpayXCompositePayout(self.config_name).pay(payout_details) 69 | 70 | self._update_after_making(response) 71 | 72 | return response 73 | 74 | def _is_authenticated_payout(self, auth_id: str | None = None) -> bool: 75 | """ 76 | Check if the Payout (Payment Entry) is authenticated or not. 77 | 78 | :param auth_id: Authentication ID 79 | 80 | --- 81 | Note: when `frappe.flags.initiated_by_payment_processor` is set, it will bypass the authentication. 82 | """ 83 | if frappe.flags.initiated_by_payment_processor: 84 | return True 85 | 86 | if not auth_id: 87 | frappe.throw( 88 | title=_("Unauthorized Access"), 89 | msg=_("Authentication ID is required to make payout."), 90 | exc=frappe.PermissionError, 91 | ) 92 | 93 | if not Authenticate2FA.is_authenticated(auth_id): 94 | frappe.throw( 95 | title=_("Unauthorized Access"), 96 | msg=_("You are not authorized to access this Payment Entry."), 97 | exc=frappe.PermissionError, 98 | ) 99 | 100 | if self.doc.name not in Authenticate2FA.get_payment_entries(auth_id): 101 | frappe.throw( 102 | title=_("Unauthorized Access"), 103 | msg=_("This Payment Entry is not authenticated for payment."), 104 | exc=frappe.PermissionError, 105 | ) 106 | 107 | return True 108 | 109 | def _can_make_payout(self) -> bool: 110 | return bool( 111 | self.doc.payment_type == "Pay" 112 | and self.doc.paid_from_account_currency == PAYOUT_CURRENCY.INR.value 113 | and self.doc.docstatus == 1 114 | and is_payout_via_razorpayx(self.doc) 115 | ) 116 | 117 | def _get_payout_details(self) -> dict: 118 | return { 119 | # Mandatory for all 120 | "source_doctype": self.doc.doctype, 121 | "source_docname": self.doc.name, 122 | "amount": self.doc.paid_amount, 123 | "party_type": self.doc.party_type, 124 | "mode": self.doc.payment_transfer_method, 125 | # Party Details 126 | "party_id": self.doc.party, 127 | "party_name": self.doc.party_name, 128 | "party_payment_details": { 129 | "bank_account_no": self.doc.party_bank_account_no, 130 | "bank_ifsc": self.doc.party_bank_ifsc, 131 | "upi_id": self.doc.party_upi_id, 132 | }, 133 | "party_contact_details": { 134 | "party_name": self.doc.party_name, 135 | "party_mobile": self.doc.contact_mobile, 136 | "party_email": self.doc.contact_email, 137 | }, 138 | # Payment Details 139 | "description": self.doc.razorpayx_payout_desc, 140 | } 141 | 142 | def _update_after_making(self, response: dict | None = None): 143 | notify = not frappe.flags.initiated_by_payment_processor 144 | 145 | user = ( 146 | frappe.get_cached_value("User", "Administrator", "email") 147 | if frappe.session.user == "Administrator" 148 | else frappe.session.user 149 | ) 150 | 151 | if user: 152 | self.doc.db_set("payment_authorized_by", user, notify=notify) 153 | 154 | if not response: 155 | return 156 | 157 | values = {} 158 | 159 | entity = response.get("entity") 160 | id = response.get("id") 161 | 162 | if not entity or not id: 163 | return 164 | 165 | if entity == "payout": 166 | values["razorpayx_payout_id"] = id 167 | 168 | if status := response.get("status"): 169 | values["razorpayx_payout_status"] = status.title() 170 | 171 | elif entity == "payout_link": 172 | values["razorpayx_payout_link_id"] = id 173 | 174 | if values: 175 | self.doc.db_set(values, notify=notify) 176 | 177 | # Note: status for Payout Link are not supported 178 | if entity == "payout": 179 | self.doc.run_notifications(STATUS_NOTIFICATION_METHOD) 180 | 181 | #### Cancel Payout | Payout Link #### 182 | def cancel(self, cancel_pe: bool = False): 183 | """ 184 | Cancel payout and payout link of source document. 185 | 186 | This method supported only after cancelling Payment Entry's `before cancel` doc event. 187 | 188 | :param cancel_pe: Cancel Payment Entry or not. 189 | 190 | --- 191 | Note: 192 | - ⚠️ Only `queued` payout can be cancelled, otherwise it will raise error. 193 | - ⚠️ Only `issued` payout link can be cancelled, otherwise it will raise error. 194 | """ 195 | marked_to_cancel = PayoutWithPaymentEntry.is_cancel_payout_marked(self.doc.name) 196 | 197 | if not self._can_cancel_payout_or_link(): 198 | # from client side manually marked to cancel 199 | if marked_to_cancel: 200 | frappe.msgprint( 201 | title=_("Invalid Action"), 202 | msg=_("Payout couldn't be cancelled."), 203 | ) 204 | return 205 | 206 | if marked_to_cancel or is_auto_cancel_payout_enabled(self.config_name): 207 | self.cancel_payout(cancel_pe=cancel_pe) 208 | self.cancel_payout_link(cancel_pe=cancel_pe) 209 | 210 | def cancel_payout(self, *, cancel_pe: bool = False) -> dict: 211 | """ 212 | Cancel payout. 213 | 214 | :param cancel_pe: Cancel Payment Entry or not. 215 | 216 | --- 217 | Note: ⚠️ Only `queued` payout can be cancelled, otherwise it will raise error. 218 | """ 219 | 220 | if not self.doc.razorpayx_payout_id: 221 | return 222 | 223 | response = RazorpayXCompositePayout(self.config_name).cancel( 224 | self.doc.razorpayx_payout_id, 225 | source_doctype=self.doc.doctype, 226 | source_docname=self.doc.name, 227 | ) 228 | 229 | self._update_after_cancelling(response, cancel_pe=cancel_pe) 230 | 231 | return response 232 | 233 | def cancel_payout_link(self, *, cancel_pe: bool = False) -> dict: 234 | """ 235 | Cancel payout link. 236 | 237 | :param cancel_pe: Cancel Payment Entry or not. 238 | 239 | --- 240 | Note: ⚠️ Only `issued` payout link can be cancelled, otherwise it will raise error. 241 | """ 242 | if not self.doc.razorpayx_payout_link_id: 243 | return 244 | 245 | response = RazorpayXLinkPayout(self.config_name).cancel( 246 | self.doc.razorpayx_payout_link_id, 247 | source_doctype=self.doc.doctype, 248 | source_docname=self.doc.name, 249 | ) 250 | 251 | self._update_after_cancelling(response, cancel_pe=cancel_pe) 252 | 253 | return response 254 | 255 | def _can_cancel_payout_or_link(self) -> bool: 256 | return self.doc.razorpayx_payout_status.lower() in [ 257 | PAYOUT_STATUS.QUEUED.value, 258 | PAYOUT_STATUS.NOT_INITIATED.value, 259 | ] and is_payout_via_razorpayx(self.doc) 260 | 261 | def _update_after_cancelling(self, response: dict, *, cancel_pe: bool = False): 262 | """ 263 | Update document after cancelling payout or payout link. 264 | 265 | :param response: Cancel API response. 266 | :param cancel_pe: Cancel Payment Entry or not. 267 | """ 268 | 269 | self.doc.db_set( 270 | "razorpayx_payout_status", 271 | (response.get("status") or PAYOUT_STATUS.CANCELLED.value).title(), 272 | ) 273 | 274 | if cancel_pe and self.doc.docstatus == 1: 275 | self.doc.flags.__canceled_by_rpx = True 276 | self.doc.cancel() 277 | 278 | @staticmethod 279 | def get_cancel_payout_key(docname: str) -> str: 280 | return f"cancel_payout_{frappe.scrub(docname)}" 281 | 282 | @staticmethod 283 | def is_cancel_payout_marked(docname: str) -> bool: 284 | key = PayoutWithPaymentEntry.get_cancel_payout_key(docname) 285 | 286 | if flag := frappe.cache.get(key): 287 | return flag.decode("utf-8") == "True" 288 | 289 | return False 290 | -------------------------------------------------------------------------------- /razorpayx_integration/razorpayx_integration/utils/validation.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import frappe 4 | from frappe import _ 5 | 6 | from razorpayx_integration.razorpayx_integration.constants.payouts import ( 7 | DESCRIPTION_REGEX, 8 | FUND_ACCOUNT_TYPE, 9 | ) 10 | 11 | 12 | def validate_fund_account_type(type: str): 13 | """ 14 | :raises frappe.ValidationError: If the type is not valid. 15 | """ 16 | if FUND_ACCOUNT_TYPE.has_value(type): 17 | return 18 | 19 | frappe.throw( 20 | msg=_("Invalid Account type: {0}.
Must be one of :
{1}").format( 21 | type, FUND_ACCOUNT_TYPE.values_as_html_list() 22 | ), 23 | title=_("Invalid RazorpayX Fund Account type"), 24 | exc=frappe.ValidationError, 25 | ) 26 | 27 | 28 | def validate_payout_description(description: str): 29 | """ 30 | Description/Narration should be of max 30 characters and A-Z, a-z, 0-9, and space only. 31 | 32 | Standard RazorpayX Payout Description/Narration validation. 33 | 34 | :raises frappe.ValidationError: If the description is not valid. 35 | """ 36 | pattern = re.compile(DESCRIPTION_REGEX) 37 | 38 | if pattern.match(description): 39 | return 40 | 41 | frappe.throw( 42 | msg=_( 43 | "Must be alphanumeric and contain spaces only, with a maximum of 30 characters." 44 | ), 45 | title=_("Invalid RazorpayX Payout Description"), 46 | exc=frappe.ValidationError, 47 | ) 48 | -------------------------------------------------------------------------------- /razorpayx_integration/setup.py: -------------------------------------------------------------------------------- 1 | import click 2 | import frappe 3 | from frappe.custom.doctype.custom_field.custom_field import ( 4 | create_custom_fields as make_custom_fields, 5 | ) 6 | from payment_integration_utils.payment_integration_utils.setup import ( 7 | delete_custom_fields, 8 | delete_property_setters, 9 | delete_roles_and_permissions, 10 | make_roles_and_permissions, 11 | ) 12 | 13 | from razorpayx_integration.constants import PAYMENTS_PROCESSOR_APP 14 | from razorpayx_integration.razorpayx_integration.constants.custom_fields import ( 15 | CUSTOM_FIELDS, 16 | PROCESSOR_FIELDS, 17 | ) 18 | from razorpayx_integration.razorpayx_integration.constants.property_setters import ( 19 | PROPERTY_SETTERS, 20 | ) 21 | from razorpayx_integration.razorpayx_integration.constants.roles import ROLES 22 | 23 | 24 | ################### After Install ################### 25 | def setup_customizations(): 26 | click.secho("Creating Roles and Permissions...", fg="blue") 27 | create_roles_and_permissions() 28 | 29 | click.secho("Creating Custom Fields...", fg="blue") 30 | create_custom_fields() 31 | 32 | if PAYMENTS_PROCESSOR_APP in frappe.get_installed_apps(): 33 | click.secho( 34 | f"Creating Custom Fields for {frappe.unscrub(PAYMENTS_PROCESSOR_APP)}...", 35 | fg="blue", 36 | ) 37 | create_payments_processor_custom_fields() 38 | 39 | click.secho("Creating Property Setters...", fg="blue") 40 | create_property_setters() 41 | 42 | 43 | # Note: separate functions are required to use in patches 44 | def create_roles_and_permissions(): 45 | make_roles_and_permissions(ROLES) 46 | 47 | 48 | def create_custom_fields(): 49 | make_custom_fields(CUSTOM_FIELDS) 50 | 51 | 52 | def create_property_setters(): 53 | for property_setter in PROPERTY_SETTERS: 54 | frappe.make_property_setter(property_setter) 55 | 56 | 57 | def create_payments_processor_custom_fields(): 58 | make_custom_fields(PROCESSOR_FIELDS) 59 | 60 | 61 | ################### Before Uninstall ################### 62 | def delete_customizations(): 63 | click.secho("Deleting Custom Fields...", fg="blue") 64 | delete_custom_fields(CUSTOM_FIELDS) 65 | 66 | click.secho( 67 | f"Deleting Custom Fields for {frappe.unscrub(PAYMENTS_PROCESSOR_APP)}...", 68 | fg="blue", 69 | ) 70 | delete_payments_processor_custom_fields() 71 | 72 | click.secho("Deleting Property Setters...", fg="blue") 73 | delete_property_setters(PROPERTY_SETTERS) 74 | 75 | click.secho("Deleting Roles and Permissions...", fg="blue") 76 | delete_roles_and_permissions(ROLES) 77 | 78 | 79 | # Note: separate functions are required to use in patches 80 | def delete_payments_processor_custom_fields(): 81 | delete_custom_fields(PROCESSOR_FIELDS) 82 | -------------------------------------------------------------------------------- /razorpayx_integration/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/templates/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/templates/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resilient-tech/razorpayx-integration/661dd51f5b4938b3f03d28ebace0bc297e18ee50/razorpayx_integration/templates/pages/__init__.py -------------------------------------------------------------------------------- /razorpayx_integration/uninstall.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from razorpayx_integration.constants import BUG_REPORT_URL, PAYMENTS_PROCESSOR_APP 4 | from razorpayx_integration.hooks import app_title as APP_NAME 5 | from razorpayx_integration.setup import ( 6 | delete_customizations, 7 | delete_payments_processor_custom_fields, 8 | ) 9 | 10 | 11 | def before_uninstall(): 12 | try: 13 | delete_customizations() 14 | except Exception as e: 15 | click.secho( 16 | ( 17 | f"\nUninstallation of {APP_NAME} failed due to an error." 18 | "Please try re-uninstalling the app or " 19 | f"report the issue on {BUG_REPORT_URL} if not resolved." 20 | ), 21 | fg="bright_red", 22 | ) 23 | raise e 24 | 25 | click.secho(f"Thank you for using {APP_NAME}!", fg="green") 26 | 27 | 28 | def before_app_uninstall(app_name): 29 | if app_name == PAYMENTS_PROCESSOR_APP: 30 | delete_payments_processor_custom_fields() 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pre-commit --------------------------------------------------------------------------------