├── .github └── workflows │ ├── ci.yml │ └── linter.yml ├── .gitignore ├── README.md ├── frappe_paystack ├── __init__.py ├── api.py ├── config │ ├── __init__.py │ └── desktop.py ├── frappe_paystack │ ├── __init__.py │ ├── doctype │ │ ├── __init__.py │ │ ├── paystack_gateway_setting │ │ │ ├── __init__.py │ │ │ ├── paystack_gateway_setting.js │ │ │ ├── paystack_gateway_setting.json │ │ │ ├── paystack_gateway_setting.py │ │ │ └── test_paystack_gateway_setting.py │ │ └── paystack_payment_log │ │ │ ├── __init__.py │ │ │ ├── paystack_payment_log.js │ │ │ ├── paystack_payment_log.json │ │ │ ├── paystack_payment_log.py │ │ │ └── test_paystack_payment_log.py │ ├── report │ │ ├── __init__.py │ │ ├── customer_paystack_volume │ │ │ ├── __init__.py │ │ │ ├── customer_paystack_volume.js │ │ │ ├── customer_paystack_volume.json │ │ │ └── customer_paystack_volume.py │ │ └── paystack_transactions │ │ │ ├── __init__.py │ │ │ ├── paystack_transactions.js │ │ │ ├── paystack_transactions.json │ │ │ └── paystack_transactions.py │ └── workspace │ │ └── paystack_dashboard │ │ └── paystack_dashboard.json ├── hooks.py ├── modules.txt ├── patches.txt ├── public │ ├── .gitkeep │ └── js │ │ ├── paystack-checkout │ │ ├── __init__.py │ │ └── index.js │ │ ├── sales_invoice.js │ │ ├── sales_order.js │ │ ├── sweetalert2@11.js │ │ └── vue.global.js ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py ├── tests │ └── test_paystack_payment_log.py ├── utils.py └── www │ ├── __init__.py │ ├── my-payments │ ├── index.html │ └── index.py │ └── paystack-checkout │ ├── __init__.py │ ├── index.html │ └── index.py ├── img ├── completed.png ├── gateway.png ├── payment.png ├── payment_button.png └── payment_page.png ├── license.txt └── pyproject.toml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: 7 | - version-15 8 | pull_request: 9 | 10 | concurrency: 11 | group: frappe-paystack-${{ github.event.number }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | tests: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | name: Server 20 | 21 | services: 22 | redis-cache: 23 | image: redis:alpine 24 | ports: 25 | - 13000:6379 26 | redis-queue: 27 | image: redis:alpine 28 | ports: 29 | - 11000:6379 30 | mariadb: 31 | image: mariadb:10.6 32 | env: 33 | MYSQL_ROOT_PASSWORD: root 34 | ports: 35 | - 3306:3306 36 | options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 37 | 38 | steps: 39 | - name: Clone 40 | uses: actions/checkout@v3 41 | 42 | - name: Find tests 43 | run: | 44 | echo "Finding tests" 45 | grep -rn "def test" > /dev/null 46 | 47 | - name: Setup Python 48 | uses: actions/setup-python@v4 49 | with: 50 | python-version: '3.10' 51 | 52 | - name: Setup Node 53 | uses: actions/setup-node@v3 54 | with: 55 | node-version: 18 56 | check-latest: true 57 | 58 | - name: Cache pip 59 | uses: actions/cache@v4 60 | with: 61 | path: ~/.cache/pip 62 | key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }} 63 | restore-keys: | 64 | ${{ runner.os }}-pip- 65 | ${{ runner.os }}- 66 | 67 | - name: Get yarn cache directory path 68 | id: yarn-cache-dir-path 69 | run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT' 70 | 71 | - uses: actions/cache@v4 72 | id: yarn-cache 73 | with: 74 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 75 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 76 | restore-keys: | 77 | ${{ runner.os }}-yarn- 78 | 79 | - name: Install MariaDB Client 80 | run: | 81 | sudo apt update 82 | sudo apt-get install mariadb-client 83 | 84 | - name: Setup 85 | run: | 86 | pip install frappe-bench 87 | bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench 88 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" 89 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" 90 | 91 | - name: Install 92 | working-directory: /home/runner/frappe-bench 93 | run: | 94 | bench get-app erpnext 95 | bench get-app https://github.com/mymi14s/frappe_paystack --branch=version-15 96 | bench setup requirements --dev 97 | bench new-site --db-root-password root --admin-password admin test_site 98 | bench --site test_site install-app erpnext 99 | bench --site test_site install-app frappe_paystack 100 | bench build 101 | env: 102 | CI: 'Yes' 103 | 104 | - name: Run Tests 105 | working-directory: /home/runner/frappe-bench 106 | run: | 107 | bench --site test_site set-config allow_tests true 108 | bench --site test_site run-tests --app frappe_paystack 109 | env: 110 | TYPE: server -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Linters 3 | 4 | on: 5 | push: 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | linter: 17 | name: 'Frappe Linter' 18 | runs-on: ubuntu-latest 19 | if: github.event_name == 'pull_request' 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.10' 26 | cache: pip 27 | - uses: pre-commit/action@v3.0.0 28 | 29 | - name: Download Semgrep rules 30 | run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules 31 | 32 | - name: Run Semgrep rules 33 | run: | 34 | pip install semgrep 35 | semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness 36 | 37 | deps-vulnerable-check: 38 | name: 'Vulnerable Dependency Check' 39 | runs-on: ubuntu-latest 40 | 41 | steps: 42 | - uses: actions/setup-python@v5 43 | with: 44 | python-version: '3.10' 45 | 46 | - uses: actions/checkout@v4 47 | 48 | - name: Cache pip 49 | uses: actions/cache@v3 50 | with: 51 | path: ~/.cache/pip 52 | key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} 53 | restore-keys: | 54 | ${{ runner.os }}-pip- 55 | ${{ runner.os }}- 56 | 57 | - name: Install and run pip-audit 58 | run: | 59 | pip install pip-audit 60 | cd ${GITHUB_WORKSPACE} 61 | pip-audit --desc on . 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | node_modules 7 | __pycache__ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frappe Paystack Integration 2 | 3 | **Frappe Paystack** is a seamless payment gateway integration for Frappe Framework and ERPNext that enables businesses to accept payments via [Paystack](https://paystack.com). 4 | 5 | --- 6 | 7 | ## Key Features 8 | 9 | - **Partial Payments**: Accept partial payments against Sales Invoices or Sales Orders. 10 | - **Payment Links via Email**: Generate and send payment links to customers via email. 11 | - **Payment Validation**: Real-time verification of transaction status to prevent fraud and ensure accuracy. 12 | - **Payment Logs**: Logs Payment Data. 13 | - **Payment Reconciliation**: Auto-complete Sales Invoices or Sales Orders upon successful payment. 14 | - **Reports**: reports for tracking Paystack transactions. 15 | 16 | --- 17 | 18 | ## Installation & Setup 19 | 20 | Follow these steps to install and configure the Frappe Paystack app in your ERPNext/Frappe instance. 21 | 22 | ### 1. Install the App 23 | 24 | ```bash 25 | bench get-app frappe_paystack https://github.com/mymi14s/frappe_paystack 26 | ``` 27 | 28 | ### 2. Install on Your Site 29 | 30 | Replace `your-site` with the name of your actual site: 31 | 32 | ```bash 33 | bench --site your-site install-app frappe_paystack 34 | ``` 35 | 36 | ### 3. Run Migrations 37 | 38 | Apply database schema changes: 39 | 40 | ```bash 41 | bench migrate 42 | ``` 43 | 44 | ### 4. Configure Paystack Gateway Settings 45 | 46 | 1. Log in to your ERPNext/Frappe instance as a System Manager. 47 | 2. Go to **Paystack Gateway Setting** (under **Accounts > Payment Gateways**). 48 | 3. Obtain your **Public Key** and **Secret Key** from the [Paystack Dashboard](https://dashboard.paystack.com/#/settings/developers). 49 | 4. Enter the keys in the respective fields. 50 | 5. Set a **Suspense Account** (used to temporarily hold funds before reconciliation). 51 | 6. Select a **Mode of Payment** linked to Paystack. 52 | 7. **Enable** the gateway and click **Save**. 53 | 54 | ![Logo](img/gateway.png) 55 | 56 | > Ensure your Paystack account is verified and active before going live. 57 | 58 | ### 5. Start Accepting Payments 59 | 60 | - Open any **Sales Invoice** or **Sales Order**. 61 | - Click the **Payment** button. 62 | - Choose **Paystack** as the payment method. 63 | - The system will generate a payment link and optionally email it to the customer. 64 | - Upon successful payment, the document will be automatically marked as **Paid** (or partially paid, if applicable). 65 | 66 | ![Logo](img/payment_button.png) 67 | -------------------------------- 68 | 69 | ![Logo](img/payment_page.png) 70 | --- 71 | 72 | ![Logo](img/payment.png) 73 | --- 74 | 75 | ![Logo](img/completed.png) 76 | --- 77 | 78 | ## Reports 79 | 80 | Access transaction insights via: 81 | - **Paystack Transactions**: View all payment with status, amount, reference, and timestamp. 82 | - **Customer Volume**: See total transactions for each customer. 83 | 84 | --- 85 | 86 | ## Support & Contributions 87 | 88 | For issues, feature requests, or contributions, please visit the [GitHub repository](https://github.com/mymi14s/frappe_paystack). 89 | 90 | --- 91 | 92 | > **Note**: Always test in **Paystack Test Mode** before enabling live transactions. 93 | 94 | #### License 95 | 96 | mit -------------------------------------------------------------------------------- /frappe_paystack/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "15.0.2" 2 | -------------------------------------------------------------------------------- /frappe_paystack/api.py: -------------------------------------------------------------------------------- 1 | 2 | import frappe, hmac, hashlib, json 3 | from frappe_paystack.utils import ( 4 | resolve_paystack_settings, is_paystack_enabled, coalesce_currency, resolve_paystack_settings 5 | ) 6 | 7 | LOG_DOCTYPE = "Paystack Payment Log" 8 | 9 | @frappe.whitelist() 10 | def is_enabled_for_company(company): 11 | return is_paystack_enabled(company) 12 | 13 | 14 | def log_pending_payment(doc, amount, currency): 15 | log = frappe.new_doc("Paystack Payment Log") 16 | log.company = doc.company 17 | log.linked_doctype = doc.doctype 18 | log.linked_docname = doc.name 19 | log.amount = amount 20 | log.currency = currency or "NGN" 21 | log.status = "Pending" 22 | log.insert(ignore_permissions=True) 23 | return log 24 | 25 | def _company_from_reference(reference): 26 | try: 27 | return frappe.db.get_value("Paystack Payment Log", reference, "company") 28 | except Exception: return None 29 | 30 | def verify_paystack_signature(payload, signature, secret): 31 | expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha512).hexdigest() 32 | return hmac.compare_digest(expected, signature or "") 33 | 34 | @frappe.whitelist(allow_guest=True) 35 | def paystack_webhook(): 36 | data = frappe.request.get_json() or {} 37 | if frappe.local.conf.developer_mode: 38 | process_webhook_event(data) 39 | frappe.local.response["http_status_code"] = 201 40 | return 41 | signature = frappe.get_request_header("x-paystack-signature") 42 | payload = frappe.request.data or b"" 43 | metadata = frappe._dict(dict(data.get("data")).get("metadata")) 44 | ref = metadata.get("reference") 45 | if not ref: frappe.throw("Invalid webhook payload") 46 | company = _company_from_reference(ref) 47 | settings = resolve_paystack_settings(company) if company else None 48 | if not settings: frappe.throw("No Paystack settings for company", frappe.PermissionError) 49 | if not verify_paystack_signature(payload, signature, settings["secret_key"]): 50 | frappe.throw("Invalid Paystack signature", frappe.PermissionError) 51 | process_webhook_event(data) 52 | 53 | frappe.local.response["http_status_code"] = 201 54 | 55 | def process_webhook_event(data): 56 | try: 57 | tx = frappe._dict(data.get("data")) or {} 58 | metadata = frappe._dict(tx.get("metadata")) 59 | ref = metadata.get("reference") 60 | amount = (tx.get("amount") or 0)/100 61 | currency = (tx.get("currency") or "NGN").upper() 62 | if not ref: return 63 | name = ref if frappe.db.exists("Paystack Payment Log", ref) else None 64 | if not name: 65 | return 66 | log = frappe.get_doc("Paystack Payment Log", name) 67 | log.status = "Processed" if tx.get("status") == "success" else "Failed" 68 | log.amount_paid = amount 69 | log.currency_paid = currency 70 | log.payment_reference = tx.get("reference") 71 | log.transaction_id = tx.get("reference") 72 | log.payment_date = tx.get("paid_at").split("T")[0] 73 | log.raw_response = json.dumps(tx) 74 | log.save(ignore_permissions=True) 75 | frappe.db.commit() 76 | if not frappe.db.get_value("Customer", metadata.get("customer"), "email_id"): 77 | frappe.db.set_value("Customer", metadata.get("customer"), "email_id", metadata.get("email")) 78 | except Exception as e: 79 | frappe.log_error(str(e), "Paystack payment") 80 | 81 | 82 | @frappe.whitelist() 83 | def create_payment_link(doctype, docname, amount: float=None, currency: str=None): 84 | doc = frappe.get_doc(doctype, docname) 85 | settings = resolve_paystack_settings(getattr(doc, "company", None)) 86 | if not settings: 87 | frappe.throw(f"Paystack not enabled for {getattr(doc, 'company', '')}") 88 | 89 | if amount is None: 90 | if doctype == "Sales Order": 91 | total = float(getattr(doc, "grand_total", 0) or 0) 92 | adv = float(getattr(doc, "advance_paid", 0) or 0) 93 | amount = max(0.0, total - adv) 94 | else: 95 | amount = float(getattr(doc, "outstanding_amount", 0) or 0) 96 | currency = coalesce_currency(currency, getattr(doc, "company", None), settings) 97 | 98 | reference = log_pending_payment(doc, amount, currency) 99 | return reference.get_payment_link() 100 | 101 | @frappe.whitelist(allow_guest=True) 102 | def validate_payment_link(docname): 103 | if frappe.db.exists(LOG_DOCTYPE, docname): 104 | doc = frappe.get_doc(LOG_DOCTYPE, docname).get_data() 105 | return doc 106 | return {} -------------------------------------------------------------------------------- /frappe_paystack/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/config/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/config/desktop.py: -------------------------------------------------------------------------------- 1 | 2 | from frappe import _ 3 | def get_data(): 4 | return [{ 5 | "module_name": "Frappe Paystack", 6 | "category": "Modules", 7 | "label": _("Frappe Paystack"), 8 | "icon": "octicon octicon-credit-card", 9 | "type": "module", 10 | "hidden": 0 11 | }] 12 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/frappe_paystack/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/frappe_paystack/doctype/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_gateway_setting/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/frappe_paystack/doctype/paystack_gateway_setting/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_gateway_setting/paystack_gateway_setting.js: -------------------------------------------------------------------------------- 1 | // paystack_gateway_setting.js (client) 2 | frappe.ui.form.on('Paystack Gateway Setting', { 3 | refresh(frm) { 4 | frm.add_custom_button(__('Test Signature'), () => { 5 | frappe.confirm(__('This just validates we can read the secret and compute HMAC. Continue?'), () => { 6 | // We call a small server method just to ensure we can access the doc’s secret key 7 | frappe.call({ 8 | method: 'frappe.client.get', 9 | args: { doctype: 'Paystack Gateway Setting', name: frm.doc.name } 10 | }).then(() => { 11 | frappe.show_alert({message:__('OK — credentials are readable'), indicator:'green'}); 12 | }); 13 | }); 14 | }); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_gateway_setting/paystack_gateway_setting.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "field:gateway", 4 | "creation": "2024-04-24 17:59:27.773899", 5 | "default_view": "List", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "gateway", 11 | "enabled", 12 | "secret_key", 13 | "public_key", 14 | "column_break_0nsce", 15 | "company", 16 | "currency", 17 | "suspense_account", 18 | "mode_of_payment" 19 | ], 20 | "fields": [ 21 | { 22 | "fieldname": "gateway", 23 | "fieldtype": "Data", 24 | "in_list_view": 1, 25 | "label": "Gateway Name", 26 | "unique": 1 27 | }, 28 | { 29 | "fieldname": "secret_key", 30 | "fieldtype": "Password", 31 | "label": "Secret Key", 32 | "reqd": 1 33 | }, 34 | { 35 | "fieldname": "public_key", 36 | "fieldtype": "Data", 37 | "in_list_view": 1, 38 | "label": "Public Key", 39 | "reqd": 1 40 | }, 41 | { 42 | "default": "0", 43 | "fieldname": "enabled", 44 | "fieldtype": "Check", 45 | "label": "Enabled" 46 | }, 47 | { 48 | "fieldname": "column_break_0nsce", 49 | "fieldtype": "Column Break" 50 | }, 51 | { 52 | "fieldname": "company", 53 | "fieldtype": "Link", 54 | "label": "Company", 55 | "options": "Company", 56 | "reqd": 1 57 | }, 58 | { 59 | "fetch_from": "company.default_currency", 60 | "fieldname": "currency", 61 | "fieldtype": "Link", 62 | "label": "Currency", 63 | "options": "Currency", 64 | "reqd": 1 65 | }, 66 | { 67 | "fieldname": "suspense_account", 68 | "fieldtype": "Link", 69 | "label": "Suspense Account", 70 | "options": "Account", 71 | "reqd": 1 72 | }, 73 | { 74 | "fieldname": "mode_of_payment", 75 | "fieldtype": "Link", 76 | "label": "Mode of Payment", 77 | "options": "Mode of Payment", 78 | "reqd": 1 79 | } 80 | ], 81 | "links": [], 82 | "modified": "2025-09-27 14:31:15.509146", 83 | "modified_by": "admin@example.com", 84 | "module": "Frappe Paystack", 85 | "name": "Paystack Gateway Setting", 86 | "naming_rule": "By fieldname", 87 | "owner": "Administrator", 88 | "permissions": [ 89 | { 90 | "create": 1, 91 | "delete": 1, 92 | "email": 1, 93 | "export": 1, 94 | "print": 1, 95 | "read": 1, 96 | "report": 1, 97 | "role": "System Manager", 98 | "share": 1, 99 | "write": 1 100 | } 101 | ], 102 | "row_format": "Dynamic", 103 | "sort_field": "modified", 104 | "sort_order": "DESC", 105 | "states": [], 106 | "title_field": "gateway", 107 | "track_changes": 1, 108 | "track_seen": 1, 109 | "track_views": 1 110 | } -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_gateway_setting/paystack_gateway_setting.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Anthony C. Emmanuel and contributors 2 | # For license information, please see license.txt 3 | 4 | from urllib.parse import urlencode 5 | 6 | import frappe 7 | from frappe import _ 8 | from frappe.utils import call_hook_method, get_url 9 | from frappe.model.document import Document 10 | 11 | class PaystackGatewaySetting(Document): 12 | supported_currencies = ['NGN', 'GHS', 'ZAR', 'USD'] 13 | 14 | def validate(self): 15 | self.check_enabled() 16 | 17 | def get_secret_key(self): 18 | return self.get_password('secret_key') 19 | 20 | def check_enabled(self): 21 | """ 22 | Ensure only one gateway is enabled for each gate type. 23 | """ 24 | if self.enabled: 25 | enabled_gateway = frappe.db.get_list(self.doctype, filters={ 26 | "enabled":1, 27 | "company":self.company, 28 | "name":["!=", self.name] 29 | }, 30 | fields=["name"]) 31 | if enabled_gateway: 32 | frappe.throw(f""" 33 | Another gateway is enabled, disable it before enabling this one.
34 | {enabled_gateway[0].name} 35 | """) 36 | def validate_transaction_currency(self, currency): 37 | if currency not in self.supported_currencies: 38 | frappe.throw( 39 | _( 40 | "Please select another payment method. Paystack does not support transactions in currency '{0}'" 41 | ).format(currency) 42 | ) 43 | 44 | def get_supported_currency(self): 45 | return self.supported_currencies 46 | 47 | def get_payment_url(self, **kwargs): 48 | return get_url(f"./paystack_checkout?{urlencode(kwargs)}") 49 | 50 | 51 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_gateway_setting/test_paystack_gateway_setting.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Anthony C. Emmanuel and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestPaystackGatewaySetting(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_payment_log/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/frappe_paystack/doctype/paystack_payment_log/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_payment_log/paystack_payment_log.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Paystack Payment Log', { 2 | refresh(frm) { 3 | frm.trigger('decorate'); 4 | frm.trigger('action_buttons'); 5 | }, 6 | 7 | status(frm) { 8 | frm.trigger('decorate'); 9 | }, 10 | 11 | decorate(frm) { 12 | const s = frm.doc.status || 'Pending'; 13 | const color = { 14 | 'Pending': 'orange', 15 | 'Processed': 'blue', 16 | 'Completed': 'green', 17 | 'Failed': 'red' 18 | }[s] || 'gray'; 19 | frm.dashboard.clear_headline(); 20 | frm.dashboard.set_headline_alert( 21 | __(`Status: ${s}`), color 22 | ); 23 | }, 24 | action_buttons(frm) { 25 | console.log 26 | if (!frm.doc.transaction_id) return; 27 | 28 | frm.add_custom_button(__('Open in Paystack'), () => { 29 | const url = `https://dashboard.paystack.com/#/transactions/${frm.doc.transaction_id}/analytics`; 30 | window.open(url, '_blank'); 31 | }, __('Actions')); 32 | 33 | frm.add_custom_button(__('Verify Transaction'), () => { 34 | frm.call("validate_payment").then(res=>{ 35 | if (res.message.message) { 36 | frappe.msgprint(res.message.message); 37 | } 38 | }) 39 | }, __('Actions')); 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_payment_log/paystack_payment_log.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2025-09-21 11:01:41.970331", 4 | "doctype": "DocType", 5 | "engine": "InnoDB", 6 | "field_order": [ 7 | "company", 8 | "linked_doctype", 9 | "linked_docname", 10 | "amount", 11 | "currency", 12 | "column_break_jvkw", 13 | "status", 14 | "retry_count", 15 | "last_retry", 16 | "amount_paid", 17 | "currency_paid", 18 | "column_break_rbnp", 19 | "payment_reference", 20 | "transaction_id", 21 | "payment_date", 22 | "payment_entry", 23 | "section_break_vqwk", 24 | "raw_response", 25 | "tab_break_v5qn", 26 | "errors", 27 | "amended_from" 28 | ], 29 | "fields": [ 30 | { 31 | "fieldname": "company", 32 | "fieldtype": "Link", 33 | "in_list_view": 1, 34 | "label": "Company", 35 | "options": "Company", 36 | "reqd": 1 37 | }, 38 | { 39 | "fieldname": "linked_doctype", 40 | "fieldtype": "Data", 41 | "label": "Linked Doctype" 42 | }, 43 | { 44 | "fieldname": "linked_docname", 45 | "fieldtype": "Data", 46 | "label": "Linked Docname" 47 | }, 48 | { 49 | "fieldname": "amount", 50 | "fieldtype": "Currency", 51 | "label": "Amount" 52 | }, 53 | { 54 | "fieldname": "currency", 55 | "fieldtype": "Data", 56 | "label": "Currency" 57 | }, 58 | { 59 | "default": "Pending", 60 | "fieldname": "status", 61 | "fieldtype": "Select", 62 | "label": "Status", 63 | "options": "Pending\nProcessed\nCompleted\nFailed" 64 | }, 65 | { 66 | "fieldname": "retry_count", 67 | "fieldtype": "Int", 68 | "label": "Retry Count" 69 | }, 70 | { 71 | "fieldname": "last_retry", 72 | "fieldtype": "Datetime", 73 | "label": "Last Retry" 74 | }, 75 | { 76 | "fieldname": "raw_response", 77 | "fieldtype": "Text", 78 | "label": "Raw Response", 79 | "options": "JSON" 80 | }, 81 | { 82 | "fieldname": "column_break_jvkw", 83 | "fieldtype": "Column Break" 84 | }, 85 | { 86 | "fieldname": "amount_paid", 87 | "fieldtype": "Currency", 88 | "label": "Amount Paid", 89 | "read_only": 1 90 | }, 91 | { 92 | "fieldname": "currency_paid", 93 | "fieldtype": "Link", 94 | "label": "Currency Paid", 95 | "options": "Currency", 96 | "read_only": 1 97 | }, 98 | { 99 | "fieldname": "section_break_vqwk", 100 | "fieldtype": "Section Break" 101 | }, 102 | { 103 | "fieldname": "column_break_rbnp", 104 | "fieldtype": "Column Break" 105 | }, 106 | { 107 | "fieldname": "payment_reference", 108 | "fieldtype": "Data", 109 | "label": "Payment Reference", 110 | "read_only": 1 111 | }, 112 | { 113 | "fieldname": "payment_date", 114 | "fieldtype": "Date", 115 | "label": "Payment Date", 116 | "read_only": 1 117 | }, 118 | { 119 | "fieldname": "payment_entry", 120 | "fieldtype": "Data", 121 | "label": "Payment Entry", 122 | "read_only": 1 123 | }, 124 | { 125 | "fieldname": "tab_break_v5qn", 126 | "fieldtype": "Tab Break", 127 | "label": "Errors" 128 | }, 129 | { 130 | "fieldname": "errors", 131 | "fieldtype": "Small Text", 132 | "label": "Errors" 133 | }, 134 | { 135 | "fieldname": "amended_from", 136 | "fieldtype": "Link", 137 | "label": "Amended From", 138 | "no_copy": 1, 139 | "options": "Paystack Payment Log", 140 | "print_hide": 1, 141 | "read_only": 1, 142 | "search_index": 1 143 | }, 144 | { 145 | "fieldname": "transaction_id", 146 | "fieldtype": "Data", 147 | "label": "Transaction ID", 148 | "read_only": 1 149 | } 150 | ], 151 | "in_create": 1, 152 | "is_submittable": 1, 153 | "links": [], 154 | "modified": "2025-09-27 16:31:37.806933", 155 | "modified_by": "Administrator", 156 | "module": "Frappe Paystack", 157 | "name": "Paystack Payment Log", 158 | "owner": "Administrator", 159 | "permissions": [ 160 | { 161 | "print": 1, 162 | "read": 1, 163 | "report": 1, 164 | "role": "System Manager", 165 | "submit": 1, 166 | "write": 1 167 | }, 168 | { 169 | "print": 1, 170 | "read": 1, 171 | "report": 1, 172 | "role": "Accounts Manager" 173 | }, 174 | { 175 | "print": 1, 176 | "read": 1, 177 | "report": 1, 178 | "role": "Accounts User" 179 | } 180 | ], 181 | "row_format": "Dynamic", 182 | "sort_field": "modified", 183 | "sort_order": "DESC", 184 | "states": [], 185 | "track_changes": 1 186 | } -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_payment_log/paystack_payment_log.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.model.document import Document 3 | from frappe.utils import flt 4 | from frappe import log_error 5 | from frappe_paystack.utils import ( 6 | normalize_currency, get_paid_to_account, validate_payment, get_customer_email 7 | ) 8 | 9 | GATEWAY_DOCTYPE = "Paystack Gateway Setting" 10 | SALES_ORDER = "Sales Order" 11 | SALES_INVOICE = "Sales Invoice" 12 | 13 | class PaystackPaymentLog(Document): 14 | def after_insert(self): 15 | frappe.db.commit() 16 | 17 | def before_insert(self): 18 | self.validate_payment() 19 | validated = self.validate_record() 20 | if validated:frappe.throw(validated) 21 | 22 | def validate_record(self): 23 | errors = "" 24 | if self.status == "Completed": 25 | return "" 26 | elif frappe.db.exists(self.linked_doctype, {"name":self.linked_docname}): 27 | doc = frappe.get_doc(self.linked_doctype, self.linked_docname) 28 | error_id = f"{self.linked_doctype} - {self.linked_docname}" 29 | if doc.docstatus == 1 and not doc.status in [ 30 | "Partly Paid", "Unpaid", "Overdue", "To Deliver and Bill", "To Bill" 31 | "To Deliver"]: 32 | errors = f"{error_id}: Document already settled" 33 | elif doc.docstatus in [0, 2]: 34 | erros = f"{error_id}: Document has been cancelled or in draft." 35 | else: 36 | errors = f"{error_id}: Document not found." 37 | 38 | return errors 39 | 40 | def validate(self): 41 | 42 | if self.currency: 43 | self.currency = normalize_currency(self.currency) 44 | 45 | validated = self.validate_record() 46 | if validated: 47 | self.db_set("errors", validated) 48 | self.reload() 49 | if validated:frappe.throw(validated) 50 | if bool(self.linked_doctype) ^ bool(self.linked_docname): 51 | frappe.throw("Linked Doctype and Linked Docname must both be set or both be empty.") 52 | 53 | if flt(self.amount) < 0: 54 | frappe.throw("Amount cannot be negative.") 55 | 56 | if self.status not in ("Pending", "Processed", "Completed", "Failed"): 57 | frappe.throw("Invalid status value.") 58 | 59 | def on_update(self): 60 | """ 61 | If this log references a Sales Invoice and status is Processed/Completed, 62 | attempt to clear the invoice by creating a Payment Entry (partial or full). 63 | We keep this conservative: if currencies mismatch or amount is zero, skip. 64 | """ 65 | 66 | if not (self.linked_doctype == self.linked_doctype and self.linked_docname): 67 | return 68 | 69 | if (self.status not in ("Processed", "Completed")) or (self.docstatus in [1, 2]) or (self.payment_entry): 70 | return 71 | 72 | try: 73 | inv = frappe.get_doc(self.linked_doctype, self.linked_docname) 74 | except Exception: 75 | log_error(msg="Invoice fetch failed in PaystackPaymentLog.on_update", data={"log": self.name}) 76 | return 77 | 78 | if inv.doctype != SALES_ORDER: 79 | if flt(inv.outstanding_amount) <= 0: 80 | if self.status != "Completed": 81 | self.db_set("status", "Completed", update_modified=True) 82 | return 83 | 84 | 85 | paid_amount = round(self.amount_paid/inv.conversion_rate, 2) 86 | if paid_amount <= 0: 87 | return 88 | try: 89 | frappe.set_user("administrator") 90 | GATE_WAY_SETTINGS = self.get_payment_public_key() 91 | pe = frappe.new_doc("Payment Entry") 92 | pe.payment_type = "Receive" 93 | pe.company = inv.company 94 | pe.posting_date = frappe.utils.getdate() 95 | pe.mode_of_payment = GATE_WAY_SETTINGS.get("mode_of_payment") 96 | pe.party_type = "Customer" 97 | pe.party = inv.customer 98 | if inv.doctype == SALES_INVOICE: 99 | pe.paid_from = inv.debit_to 100 | elif inv.doctype == SALES_ORDER: 101 | account = get_paid_to_account(inv.customer, inv.company) 102 | pe.paid_from = account 103 | pe.paid_to = GATE_WAY_SETTINGS.get("suspense_account") 104 | pe.paid_amount = paid_amount 105 | pe.received_amount = self.amount_paid 106 | pe.source_exchange_rate = inv.conversion_rate 107 | pe.reference_date = frappe.utils.getdate() 108 | pe.target_exchange_rate = 1 109 | pe.reference_date = self.payment_date 110 | pe.reference_no = self.payment_reference 111 | pe.remarks = f"Auto-created from Paystack Payment Log {self.name}" 112 | 113 | pe.append("references", { 114 | "reference_doctype": inv.doctype, 115 | "reference_name": inv.name, 116 | "allocated_amount": paid_amount 117 | }) 118 | pe.save(ignore_permissions=True) 119 | pe.submit() 120 | self.db_set("payment_entry", pe.name, update_modified=False) 121 | self.db_set("status", "Completed", update_modified=True) 122 | self.reload() 123 | self.submit() 124 | frappe.set_user("Guest") 125 | except Exception as e: 126 | frappe.log_error("Failed to create Payment Entry from Paystack log", f"""{self.name} - {frappe.get_traceback()}""") 127 | 128 | def get_payment_link(self): 129 | return f"{frappe.utils.get_url()}/paystack-checkout/{self.name}" 130 | 131 | def get_payment_public_key(self): 132 | if frappe.db.exists(GATEWAY_DOCTYPE, {"enabled": 1, "company":self.company}): 133 | doc = frappe.get_doc(GATEWAY_DOCTYPE, {"enabled": 1, "company":self.company}) 134 | key = { 135 | "public_key":doc.get_password("public_key"), 136 | "currency": doc.currency, 137 | "suspense_account": doc.suspense_account, 138 | "mode_of_payment": doc.mode_of_payment, 139 | } 140 | else: 141 | key = None 142 | return key 143 | 144 | def get_data(self): 145 | order = frappe.get_doc(self.linked_doctype, self.linked_docname) 146 | gateway_settings = self.get_payment_public_key() 147 | data = { 148 | "customer": order.customer, 149 | "exchange_rate": order.conversion_rate, 150 | "order_currency": order.currency, 151 | "grand_total": self.amount, 152 | "payment_amount": round(self.amount * order.conversion_rate,2), 153 | "status": self.status, 154 | "order_status": order.status, 155 | "order_docstatus": order.docstatus, 156 | "order_no": order.name, 157 | "reference_doctype":self.linked_doctype, 158 | "reference_docname":self.linked_docname, 159 | "email": get_customer_email(order.customer) or "", 160 | "reference": self.name 161 | } 162 | if gateway_settings: 163 | data.update(gateway_settings) 164 | 165 | return data 166 | 167 | def on_trash(self): 168 | frappe.throw("You are not allowed to cancel this document") 169 | 170 | 171 | @frappe.whitelist() 172 | def validate_payment(self): 173 | data = validate_payment(self) 174 | return data -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/doctype/paystack_payment_log/test_paystack_payment_log.py: -------------------------------------------------------------------------------- 1 | import frappe, unittest 2 | 3 | class TestPaystackPaymentLog(unittest.TestCase): 4 | def test_meta(self): 5 | self.assertTrue(frappe.get_meta('Paystack Payment Log')) 6 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/report/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/frappe_paystack/report/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/report/customer_paystack_volume/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/frappe_paystack/report/customer_paystack_volume/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/report/customer_paystack_volume/customer_paystack_volume.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025, Anthony Emmanuel and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.query_reports["Customer Paystack Volume"] = { 5 | "filters": [ 6 | { 7 | fieldname: "company", 8 | label: __("Company"), 9 | fieldtype: "Link", 10 | options: "Company", 11 | reqd: 1, 12 | 13 | }, 14 | { 15 | fieldname: "customer", 16 | label: __("Customer"), 17 | fieldtype: "Link", 18 | options: "Customer", 19 | reqd: 0, 20 | 21 | }, 22 | ] 23 | }; 24 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/report/customer_paystack_volume/customer_paystack_volume.json: -------------------------------------------------------------------------------- 1 | { 2 | "add_total_row": 0, 3 | "add_translate_data": 0, 4 | "columns": [], 5 | "creation": "2025-09-23 09:51:27.522844", 6 | "disabled": 0, 7 | "docstatus": 0, 8 | "doctype": "Report", 9 | "filters": [], 10 | "idx": 0, 11 | "is_standard": "Yes", 12 | "letterhead": null, 13 | "modified": "2025-09-23 09:53:42.301906", 14 | "modified_by": "Administrator", 15 | "module": "Frappe Paystack", 16 | "name": "Customer Paystack Volume", 17 | "owner": "Administrator", 18 | "prepared_report": 0, 19 | "ref_doctype": "Sales Invoice", 20 | "report_name": "Customer Paystack Volume", 21 | "report_type": "Script Report", 22 | "roles": [], 23 | "timeout": 0 24 | } -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/report/customer_paystack_volume/customer_paystack_volume.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | def execute(filters=None): 4 | cols = [ 5 | {"label":"Customer","fieldname":"customer","fieldtype":"Link","options":"Customer","width":250}, 6 | {"label":"Company","fieldname":"company","fieldtype":"Link","options":"Company","width":350}, 7 | {"label":"Total Amount","fieldname":"total","fieldtype":"Currency", "options": "currency", "width":140}, 8 | {"label":"Currency","fieldname":"currency","fieldtype":"Data","width":100} 9 | ] 10 | 11 | conditions = [] 12 | values = {} 13 | 14 | if filters.get("customer"): 15 | conditions.append("coalesce(si.customer, so.customer) = %(customer)s") 16 | values["customer"] = filters["customer"] 17 | 18 | q = f""" 19 | select 20 | coalesce(si.customer, so.customer) as customer, 21 | p.company, 22 | sum(p.amount) as total, 23 | max(p.currency) as currency 24 | from `tabPaystack Payment Log` p 25 | left join `tabSales Invoice` si on si.name = p.linked_docname 26 | left join `tabSales Order` so on so.name = p.linked_docname 27 | where p.status in ("Processed","Completed") and p.company="{filters.company}" 28 | {(" and " + " and ".join(conditions)) if conditions else ""} 29 | group by coalesce(si.customer, so.customer), p.company 30 | """ 31 | 32 | data = frappe.db.sql(q, values=values, as_dict=True) 33 | return cols, data 34 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/report/paystack_transactions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/frappe_paystack/report/paystack_transactions/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/report/paystack_transactions/paystack_transactions.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025, Anthony Emmanuel and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.query_reports["Paystack Transactions"] = { 5 | "filters": [ 6 | { 7 | fieldname: "gateway", 8 | label: __("Gateway"), 9 | fieldtype: "Link", 10 | reqd: 1, 11 | options: "Paystack Gateway Setting", 12 | get_query: function() { 13 | return { 14 | filters: { 15 | enabled: 1 16 | } 17 | }; 18 | } 19 | }, 20 | { 21 | fieldname: "customer", 22 | label: __("Customer ID"), 23 | fieldtype: "Int", 24 | reqd: 0 25 | }, 26 | { 27 | fieldname: "status", 28 | label: __("Status"), 29 | fieldtype: "Select", 30 | options: ["", "success", "failed", "abandoned"], 31 | reqd: 0 32 | }, 33 | { 34 | fieldname: "from_date", 35 | label: __("From Date"), 36 | fieldtype: "Datetime", 37 | reqd: 0 38 | }, 39 | { 40 | fieldname: "to_date", 41 | label: __("To Date"), 42 | fieldtype: "Datetime", 43 | reqd: 0 44 | }, 45 | { 46 | fieldname: "amount", 47 | label: __("Amount"), 48 | fieldtype: "Int", 49 | reqd: 0 50 | }, 51 | { 52 | fieldname: "per_page", 53 | label: __("Per Page"), 54 | fieldtype: "Int", 55 | default: 50 56 | }, 57 | { 58 | fieldname: "page", 59 | label: __("Page Number"), 60 | fieldtype: "Int", 61 | default: 1 62 | } 63 | ] 64 | }; 65 | 66 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/report/paystack_transactions/paystack_transactions.json: -------------------------------------------------------------------------------- 1 | { 2 | "add_total_row": 1, 3 | "add_translate_data": 0, 4 | "columns": [], 5 | "creation": "2025-09-23 21:28:04.307873", 6 | "disabled": 0, 7 | "docstatus": 0, 8 | "doctype": "Report", 9 | "filters": [], 10 | "idx": 0, 11 | "is_standard": "Yes", 12 | "letterhead": null, 13 | "modified": "2025-09-27 16:30:13.537447", 14 | "modified_by": "Administrator", 15 | "module": "Frappe Paystack", 16 | "name": "Paystack Transactions", 17 | "owner": "Administrator", 18 | "prepared_report": 0, 19 | "ref_doctype": "Paystack Payment Log", 20 | "report_name": "Paystack Transactions", 21 | "report_type": "Script Report", 22 | "roles": [ 23 | { 24 | "role": "System Manager" 25 | }, 26 | { 27 | "role": "Accounts Manager" 28 | }, 29 | { 30 | "role": "Accounts User" 31 | } 32 | ], 33 | "timeout": 0 34 | } -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/report/paystack_transactions/paystack_transactions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, Anthony Emmanuel and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe, requests 5 | from frappe_paystack.utils import get_gateway_secret 6 | 7 | 8 | def execute(filters=None): 9 | columns = get_columns() 10 | data = get_data(filters) 11 | return columns, data 12 | 13 | 14 | def get_columns(): 15 | return [ 16 | {"label": "Transaction ID", "fieldname": "id", "fieldtype": "Int", "width": 180}, 17 | {"label": "Status", "fieldname": "status", "fieldtype": "Data", "width": 100}, 18 | {"label": "Reference", "fieldname": "reference", "fieldtype": "Data", "width": 200}, 19 | {"label": "Amount", "fieldname": "amount", "fieldtype": "Currency", "width": 120}, 20 | {"label": "Currency", "fieldname": "currency", "fieldtype": "Data", "width": 80}, 21 | {"label": "Customer", "fieldname": "customer", "fieldtype": "Data", "width": 250}, 22 | {"label": "Email", "fieldname": "email", "fieldtype": "Data", "width": 200}, 23 | {"label": "Doctype", "fieldname": "reference_doctype", "fieldtype": "Data", "width": 200}, 24 | {"label": "Docname", "fieldname": "reference_docname", "fieldtype": "Dynamic Link", "options":"reference_doctype", "width": 200}, 25 | {"label": "Payment Log", "fieldname": "reference_log", "fieldtype": "Link", "options":"Paystack Payment Log", "width": 200}, 26 | {"label": "Channel", "fieldname": "channel", "fieldtype": "Data", "width": 120}, 27 | {"label": "Paid At", "fieldname": "paid_at", "fieldtype": "Datetime", "width": 180}, 28 | {"label": "Created At", "fieldname": "created_at", "fieldtype": "Datetime", "width": 180}, 29 | {"label": "Gateway Response", "fieldname": "gateway_response", "fieldtype": "Data", "width": 200}, 30 | {"label": "Domain", "fieldname": "domain", "fieldtype": "Data", "width": 200}, 31 | {"label": "IP", "fieldname": "ip_address", "fieldtype": "Data", "width": 130} 32 | ] 33 | 34 | 35 | def get_data(filters): 36 | url = "https://api.paystack.co/transaction" 37 | headers = { 38 | "Authorization": f"Bearer {get_gateway_secret(filters.gateway)}" 39 | } 40 | params = {} 41 | if filters.get("per_page"): 42 | params["perPage"] = filters["per_page"] 43 | if filters.get("page"): 44 | params["page"] = filters["page"] 45 | if filters.get("customer"): 46 | params["customer"] = filters["customer"] 47 | if filters.get("terminalid"): 48 | params["terminalid"] = filters["terminalid"] 49 | if filters.get("status"): 50 | params["status"] = filters["status"] 51 | if filters.get("from_date"): 52 | params["from"] = filters["from_date"] 53 | if filters.get("to_date"): 54 | params["to"] = filters["to_date"] 55 | if filters.get("amount"): 56 | params["amount"] = filters["amount"] 57 | 58 | try: 59 | res = requests.get(url, headers=headers, params=params, timeout=30) 60 | res.raise_for_status() 61 | response = res.json() 62 | except Exception as e: 63 | frappe.throw(f"Paystack API Error: {str(e)}") 64 | 65 | data = [] 66 | if response.get("status"): 67 | for tx in response.get("data", []): 68 | tx["email"] = (tx.get("customer") or {}).get("email") 69 | metadata = tx["metadata"] 70 | metadata["reference_log"] = metadata.get("reference") 71 | if metadata["reference_log"]: 72 | del metadata["reference"] 73 | tx.update(metadata) 74 | for dl in ["log", "metadata", "authorization", "source"]:del tx[dl] 75 | tx["amount"] /=100 76 | data.append(tx) 77 | 78 | return data 79 | -------------------------------------------------------------------------------- /frappe_paystack/frappe_paystack/workspace/paystack_dashboard/paystack_dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "charts": [], 3 | "content": "[{\"id\":\"5rx2lYfcEM\",\"type\":\"header\",\"data\":{\"text\":\"Paystack Dashboard\",\"col\":12}},{\"id\":\"nr4W0kuuZU\",\"type\":\"card\",\"data\":{\"card_name\":\"Doctypes\",\"col\":4}},{\"id\":\"ye4fJv_V4O\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]", 4 | "creation": "2025-09-27 16:45:24.172605", 5 | "custom_blocks": [], 6 | "docstatus": 0, 7 | "doctype": "Workspace", 8 | "for_user": "", 9 | "hide_custom": 0, 10 | "icon": "both", 11 | "idx": 0, 12 | "indicator_color": "green", 13 | "is_hidden": 0, 14 | "label": "Paystack Dashboard", 15 | "links": [ 16 | { 17 | "hidden": 0, 18 | "is_query_report": 0, 19 | "label": "Reports", 20 | "link_count": 2, 21 | "link_type": "DocType", 22 | "onboard": 0, 23 | "type": "Card Break" 24 | }, 25 | { 26 | "hidden": 0, 27 | "is_query_report": 1, 28 | "label": "Paystack Transactions", 29 | "link_count": 0, 30 | "link_to": "Paystack Transactions", 31 | "link_type": "Report", 32 | "onboard": 0, 33 | "type": "Link" 34 | }, 35 | { 36 | "hidden": 0, 37 | "is_query_report": 1, 38 | "label": "Customer Paystack Volume", 39 | "link_count": 0, 40 | "link_to": "Customer Paystack Volume", 41 | "link_type": "Report", 42 | "onboard": 0, 43 | "type": "Link" 44 | }, 45 | { 46 | "hidden": 0, 47 | "is_query_report": 0, 48 | "label": "Doctypes", 49 | "link_count": 2, 50 | "link_type": "DocType", 51 | "onboard": 0, 52 | "type": "Card Break" 53 | }, 54 | { 55 | "hidden": 0, 56 | "is_query_report": 0, 57 | "label": "Paystack Payment Log", 58 | "link_count": 0, 59 | "link_to": "Paystack Payment Log", 60 | "link_type": "DocType", 61 | "onboard": 0, 62 | "type": "Link" 63 | }, 64 | { 65 | "hidden": 0, 66 | "is_query_report": 0, 67 | "label": "Paystack Gateway Setting", 68 | "link_count": 0, 69 | "link_to": "Paystack Gateway Setting", 70 | "link_type": "DocType", 71 | "onboard": 0, 72 | "type": "Link" 73 | } 74 | ], 75 | "modified": "2025-09-27 16:47:42.754014", 76 | "modified_by": "Administrator", 77 | "module": "Frappe Paystack", 78 | "name": "Paystack Dashboard", 79 | "number_cards": [], 80 | "owner": "Administrator", 81 | "parent_page": "", 82 | "public": 1, 83 | "quick_lists": [], 84 | "roles": [], 85 | "sequence_id": 28.0, 86 | "shortcuts": [], 87 | "title": "Paystack Dashboard" 88 | } -------------------------------------------------------------------------------- /frappe_paystack/hooks.py: -------------------------------------------------------------------------------- 1 | app_name = "frappe_paystack" 2 | app_title = "Frappe Paystack" 3 | app_publisher = "Anthony Emmanuel" 4 | app_description = "Paystack integration for Frappe/ERPNext" 5 | app_email = "hackacehuawei@gmail.com" 6 | app_license = "mit" 7 | 8 | 9 | 10 | doctype_js = { 11 | "Sales Invoice": "public/js/sales_invoice.js", 12 | "Sales Order": "public/js/sales_order.js", 13 | } 14 | 15 | 16 | website_route_rules = [ 17 | {"from_route": "/paystack-checkout/", "to_route": "paystack-checkout"} 18 | ] 19 | 20 | 21 | fixtures = [ 22 | { 23 | "doctype": "Mode of Payment", 24 | "filters": [["name", "=", "Paystack"]] 25 | } 26 | ] 27 | 28 | -------------------------------------------------------------------------------- /frappe_paystack/modules.txt: -------------------------------------------------------------------------------- 1 | Frappe Paystack -------------------------------------------------------------------------------- /frappe_paystack/patches.txt: -------------------------------------------------------------------------------- 1 | [pre_model_sync] 2 | # Patches added in this section will be executed before doctypes are migrated 3 | # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations 4 | 5 | [post_model_sync] 6 | # Patches added in this section will be executed after doctypes are migrated -------------------------------------------------------------------------------- /frappe_paystack/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/public/.gitkeep -------------------------------------------------------------------------------- /frappe_paystack/public/js/paystack-checkout/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/public/js/paystack-checkout/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/public/js/paystack-checkout/index.js: -------------------------------------------------------------------------------- 1 | const isEmail = str => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str); 2 | 3 | const { createApp } = Vue 4 | 5 | createApp({ 6 | delimiters: ['[%', '%]'], 7 | data() { 8 | return { 9 | id: '', 10 | payment_data: {}, 11 | gateway: '', 12 | showDiv: false, 13 | doc: window.doc, 14 | } 15 | }, 16 | methods: { 17 | payWithPaystack(){ 18 | let me = this; 19 | let handler = PaystackPop.setup({ 20 | key: doc.public_key, 21 | amount: doc.payment_amount * 100, 22 | // ref: me.payment_data.name+'_'+Math.floor((Math.random() * 1000000000) + 1), // generates a pseudo-unique reference. Please replace with a reference you generated. Or remove the line entirely so our API will generate one for you 23 | currency: doc.currency, 24 | email: doc.email, 25 | metadata: { 26 | reference_doctype:doc.reference_doctype, 27 | reference_docname:doc.reference_docname, 28 | customer:doc.customer, 29 | reference:doc.reference, 30 | email: doc.email 31 | }, 32 | // label: "Optional string that replaces customer email" 33 | onClose: function(){ 34 | alert('Payment Terminated.'); 35 | }, 36 | callback: function(response){ 37 | console.log(response) 38 | // frappe.call({ 39 | // type: "POST", 40 | // method: "frappe_paystack.api.paystack_callback", 41 | // args:response, 42 | // callback: function(r) { 43 | 44 | // } 45 | // }); 46 | $('#paymentBTN').hide(); 47 | Swal.fire( 48 | 'Successful', 49 | 'Your payment was successful, we will issue you receipt shortly.', 50 | 'success' 51 | ) 52 | } 53 | }); 54 | 55 | handler.openIframe(); 56 | }, 57 | getData(){ 58 | let me = this; 59 | frappe.call("frappe_paystack.api.validate_payment_link", {"docname": doc.reference}).then(res=>{ 60 | let data = res.message; 61 | if (data.order_status in ["Completed", "Closed'", "Paid"]) { 62 | errors = "Paid or Completed" 63 | } else if (["Processed", "Completed"].includes(data.status)){ 64 | errors = "Payment already processed." 65 | } else if ([0, 2].includes(data.order_docstatus)) { 66 | errors = "Payment link expired or invalid." 67 | } else { 68 | errors = "" 69 | } 70 | if (errors){ 71 | window.location.reload(); 72 | } else { 73 | if (doc.email){ 74 | this.payWithPaystack(); 75 | } else { 76 | 77 | Swal.fire({ 78 | title: "Your email", 79 | input: "text", 80 | inputAttributes: { 81 | autocapitalize: "off" 82 | }, 83 | showCancelButton: false, 84 | confirmButtonText: "Continue", 85 | showLoaderOnConfirm: true, 86 | allowOutsideClick: () => !Swal.isLoading() 87 | }).then((value) => { 88 | if (value.isConfirmed) { 89 | if (isEmail(value.value)){ 90 | doc.email = value.value; 91 | me.payWithPaystack(); 92 | } else { 93 | Swal.fire({ 94 | title: "Invalid Email", 95 | text: "Retry", 96 | icon: "warning" 97 | }); 98 | } 99 | } 100 | 101 | }); 102 | } 103 | } 104 | }) 105 | }, 106 | formatCurrency(amount, currency){ 107 | if(currency){ 108 | return Intl.NumberFormat('en-US', {currency:currency, style:'currency'}).format(amount); 109 | } else { 110 | return Intl.NumberFormat('en-US').format(amount); 111 | } 112 | } 113 | }, 114 | mounted(){ 115 | 116 | } 117 | }).mount('#app') 118 | 119 | 120 | 121 | document.querySelector("paymentBTN") -------------------------------------------------------------------------------- /frappe_paystack/public/js/sales_invoice.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Sales Invoice', { 2 | refresh(frm) { 3 | if (!frm.doc.company) return; 4 | 5 | const can_pay = frm.doc.docstatus === 1 && flt(frm.doc.outstanding_amount || 0) > 0; 6 | 7 | frappe.call({ 8 | method: "frappe_paystack.api.is_enabled_for_company", 9 | args: { company: frm.doc.company } 10 | }).then(r => { 11 | const enabled = !!r.message; 12 | if (!enabled) { 13 | frm.dashboard.add_comment(__('Paystack disabled or not configured'), 'red', true); 14 | return; 15 | } 16 | 17 | if (can_pay) { 18 | frm.dashboard.add_comment(__('Paystack enabled'), 'green', true); 19 | frm.add_custom_button(__('Pay Now'), () => { 20 | make_paystack_link(frm, { 21 | doctype: frm.doc.doctype, 22 | docname: frm.doc.name, 23 | amount: flt(frm.doc.outstanding_amount || 0), 24 | currency: frm.doc.currency || 'NGN', 25 | }); 26 | }, __('Paystack')); 27 | 28 | frm.add_custom_button(__('Send Payment Link'), () => { 29 | frappe.call("frappe_paystack.utils.get_customer_email", {"customer": frm.doc.customer}).then(res=>{ 30 | if (!res.message) { 31 | frappe.throw("Please set customer Email ID in Customer Doctype") 32 | } else { 33 | make_paystack_link(frm, { 34 | doctype: frm.doc.doctype, 35 | docname: frm.doc.name, 36 | amount: flt(frm.doc.outstanding_amount || 0), 37 | currency: frm.doc.currency || 'NGN', 38 | }, { send: true, email: res.message }); 39 | } 40 | }) 41 | }, __('Paystack')); 42 | 43 | frm.add_custom_button(__('Partial Payment'), () => { 44 | const partial_dialog = new frappe.ui.Dialog({ 45 | title: __('Partial Payment'), 46 | fields: [ 47 | { fieldtype: 'Currency', fieldname: 'amount', label: __(`Amount (${frm.doc.currency})`), options: frm.doc.currency, reqd: 1}, 48 | { fieldtype: 'Select', fieldname: 'mode', label: __('Payment Mode'), options: ["", "Pay Now", "Send Payment Link"], reqd: 1}, 49 | ], 50 | primary_action_label: __('Create'), 51 | primary_action(values) { 52 | if (values.amount > 0 && values.amount <= frm.doc.outstanding_amount) { 53 | if (values.mode==="Pay Now"){ 54 | make_paystack_link(frm, { 55 | doctype: frm.doc.doctype, 56 | docname: frm.doc.name, 57 | amount: flt(values.amount || 0), 58 | currency: frm.doc.currency || 'NGN', 59 | }); 60 | partial_dialog.hide(); 61 | } else { 62 | frappe.call("frappe_paystack.utils.get_customer_email", {"customer": frm.doc.customer}).then(res=>{ 63 | if (!res.message) { 64 | frappe.throw("Please set customer Email ID in Customer Doctype") 65 | } else { 66 | make_paystack_link(frm, { 67 | doctype: frm.doc.doctype, 68 | docname: frm.doc.name, 69 | amount: flt(values.amount || 0), 70 | currency: frm.doc.currency || 'NGN', 71 | }, { send: true, email: res.message }); 72 | partial_dialog.hide(); 73 | } 74 | }) 75 | } 76 | } else { 77 | frappe.throw("Amount must be > 0 or <= outstanding_amount"); 78 | } 79 | } 80 | }); 81 | partial_dialog.show(); 82 | }, __('Paystack')); 83 | } 84 | }); 85 | } 86 | }); 87 | 88 | function make_paystack_link(frm, { doctype, docname, amount, currency }, opts = {}) { 89 | const payment_link = () => frappe.call({ 90 | method: "frappe_paystack.api.create_payment_link", 91 | args: { doctype, docname, amount, currency } 92 | }); 93 | 94 | 95 | (payment_link().catch()).then(res => { 96 | const url = res?.message; 97 | if (!url) { 98 | frappe.msgprint(__('Could not generate Paystack link.')); 99 | return; 100 | } 101 | 102 | show_link_dialog(frm, url, opts); 103 | 104 | }); 105 | } 106 | 107 | function show_link_dialog(frm, url, opts) { 108 | if (opts.send) { 109 | if (opts.send) { 110 | prompt_send_email(frm, url, opts); 111 | } 112 | } else { 113 | const d = new frappe.ui.Dialog({ 114 | title: __('Pay via Paystack'), 115 | fields: [ 116 | { fieldtype: 'Data', fieldname: 'link', label: __('Payment Link'), read_only: 1, default: url, bold: 1 }, 117 | ], 118 | primary_action_label: __('Open Link'), 119 | primary_action: () => { window.open(url, '_blank'); d.hide(); } 120 | }); 121 | 122 | d.set_secondary_action_label(__('Copy Link')); 123 | d.set_secondary_action(() => { 124 | const val = d.get_value('link'); 125 | if (navigator.clipboard?.writeText) { 126 | navigator.clipboard.writeText(val).then(() => frappe.show_alert({ message: __('Copied!'), indicator: 'green' })); 127 | } else { 128 | frappe.msgprint(__('Copy this link') + ':
' + val); 129 | } 130 | }); 131 | 132 | d.show(); 133 | } 134 | } 135 | 136 | function prompt_send_email(frm, url, opts) { 137 | const email_dialog = new frappe.ui.Dialog({ 138 | title: __('Send Payment Link'), 139 | fields: [ 140 | { fieldtype: 'Data', fieldname: 'to', label: __('To (Email)'), reqd: 1, default: opts.email }, 141 | { fieldtype: 'Data', fieldname: 'subject', label: __('Subject'), default: __('Payment link for {0}', [frm.doc.name]) }, 142 | { fieldtype: 'Small Text', fieldname: 'message', label: __('Message'), 143 | default: __('Hello,') + '
' + 144 | __('Please use the Paystack link below to complete payment for {0}.', [frm.doc.name]) + 145 | '
' + url + __('

Thank you!') } 146 | ], 147 | primary_action_label: __('Send'), 148 | primary_action(values) { 149 | frappe.call({ 150 | method: "frappe.core.doctype.communication.email.make", 151 | args: { 152 | recipients: values.to, 153 | subject: values.subject, 154 | content: values.message, 155 | doctype: frm.doc.doctype, 156 | name: frm.doc.name, 157 | send_email: 1, 158 | } 159 | }).then(() => { 160 | frappe.show_alert({ message: __('Email sent'), indicator: 'green' }); 161 | email_dialog.hide(); 162 | frappe.show_alert({ message: __(), indicator: 'green' }); 163 | frappe.msgprint(`Payment link has been sent to ${frm.doc.customer} via ${opts.email}`) 164 | }); 165 | } 166 | }); 167 | email_dialog.show(); 168 | } 169 | -------------------------------------------------------------------------------- /frappe_paystack/public/js/sales_order.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Sales Order', { 2 | refresh(frm) { 3 | if (!frm.doc.company) return; 4 | 5 | const grand_total = flt(frm.doc.grand_total || 0); 6 | const advance_paid = flt(frm.doc.advance_paid || 0); 7 | const so_outstanding = Math.max(0, grand_total - advance_paid); 8 | const can_pay = frm.doc.docstatus === 1 && so_outstanding > 0 && 9 | !['Completed', 'Closed'].includes(frm.doc.status); 10 | 11 | frappe.call({ 12 | method: "frappe_paystack.api.is_enabled_for_company", 13 | args: { company: frm.doc.company } 14 | }).then(r => { 15 | const enabled = !!r.message; 16 | if (!enabled) { 17 | frm.dashboard.add_comment(__('Paystack disabled or not configured'), 'red', true); 18 | return; 19 | } 20 | 21 | if (can_pay) { 22 | frm.dashboard.add_comment(__('Paystack enabled'), 'green', true); 23 | 24 | frm.add_custom_button(__('Pay via Paystack (SO)'), () => { 25 | make_paystack_link(frm, { 26 | doctype: frm.doc.doctype, 27 | docname: frm.doc.name, 28 | amount: so_outstanding, 29 | currency: frm.doc.currency || 'NGN', 30 | }); 31 | }, __('Paystack')); 32 | 33 | frm.add_custom_button(__('Send Paystack Link (SO)'), () => { 34 | frappe.call("frappe_paystack.utils.get_customer_email", {"customer": frm.doc.customer}).then(res=>{ 35 | if (!res.message) { 36 | frappe.throw("Please set customer Email ID in Customer Doctype") 37 | } else { 38 | make_paystack_link(frm, { 39 | doctype: frm.doc.doctype, 40 | docname: frm.doc.name, 41 | amount: flt(so_outstanding || 0), 42 | currency: frm.doc.currency || 'NGN', 43 | }, { send: true, email: res.message }); 44 | } 45 | }) 46 | }, __('Paystack')); 47 | 48 | frm.add_custom_button(__('Partial Payment'), () => { 49 | const partial_dialog = new frappe.ui.Dialog({ 50 | title: __('Partial Payment'), 51 | fields: [ 52 | { fieldtype: 'Currency', fieldname: 'amount', label: __(`Amount (${frm.doc.currency})`), options: frm.doc.currency, reqd: 1}, 53 | { fieldtype: 'Select', fieldname: 'mode', label: __('Payment Mode'), options: ["", "Pay Now", "Send Payment Link"], reqd: 1}, 54 | ], 55 | primary_action_label: __('Create'), 56 | primary_action(values) { 57 | if (values.amount > 0 && values.amount <= so_outstanding) { 58 | if (values.mode==="Pay Now"){ 59 | make_paystack_link(frm, { 60 | doctype: frm.doc.doctype, 61 | docname: frm.doc.name, 62 | amount: flt(values.amount || 0), 63 | currency: frm.doc.currency || 'NGN', 64 | }); 65 | partial_dialog.hide(); 66 | } else { 67 | frappe.call("frappe_paystack.utils.get_customer_email", {"customer": frm.doc.customer}).then(res=>{ 68 | if (!res.message) { 69 | frappe.throw("Please set customer Email ID in Customer Doctype") 70 | } else { 71 | make_paystack_link(frm, { 72 | doctype: frm.doc.doctype, 73 | docname: frm.doc.name, 74 | amount: flt(values.amount || 0), 75 | currency: frm.doc.currency || 'NGN', 76 | }, { send: true, email: res.message }); 77 | partial_dialog.hide(); 78 | } 79 | }) 80 | } 81 | } else { 82 | frappe.throw("Amount must be > 0 or <= grand_total"); 83 | } 84 | } 85 | }); 86 | partial_dialog.show(); 87 | }, __('Paystack')); 88 | } 89 | }); 90 | } 91 | }); 92 | 93 | function make_paystack_link(frm, { doctype, docname, amount, currency }, opts = {}) { 94 | const payment_link = () => frappe.call({ 95 | method: "frappe_paystack.api.create_payment_link", 96 | args: { doctype, docname, amount, currency } 97 | }); 98 | 99 | 100 | (payment_link().catch()).then(res => { 101 | const url = res?.message; 102 | if (!url) { 103 | frappe.msgprint(__('Could not generate Paystack link.')); 104 | return; 105 | } 106 | 107 | show_link_dialog(frm, url, opts); 108 | 109 | }); 110 | } 111 | 112 | function show_link_dialog(frm, url, opts) { 113 | if (opts.send) { 114 | if (opts.send) { 115 | prompt_send_email(frm, url, opts); 116 | } 117 | } else { 118 | const d = new frappe.ui.Dialog({ 119 | title: __('Pay via Paystack'), 120 | fields: [ 121 | { fieldtype: 'Data', fieldname: 'link', label: __('Payment Link'), read_only: 1, default: url, bold: 1 }, 122 | ], 123 | primary_action_label: __('Open Link'), 124 | primary_action: () => { window.open(url, '_blank'); d.hide(); } 125 | }); 126 | 127 | d.set_secondary_action_label(__('Copy Link')); 128 | d.set_secondary_action(() => { 129 | const val = d.get_value('link'); 130 | if (navigator.clipboard?.writeText) { 131 | navigator.clipboard.writeText(val).then(() => frappe.show_alert({ message: __('Copied!'), indicator: 'green' })); 132 | } else { 133 | frappe.msgprint(__('Copy this link') + ':
' + val); 134 | } 135 | }); 136 | 137 | d.show(); 138 | } 139 | } 140 | 141 | function prompt_send_email(frm, url, opts) { 142 | const email_dialog = new frappe.ui.Dialog({ 143 | title: __('Send Payment Link'), 144 | fields: [ 145 | { fieldtype: 'Data', fieldname: 'to', label: __('To (Email)'), reqd: 1, default: opts.email }, 146 | { fieldtype: 'Data', fieldname: 'subject', label: __('Subject'), default: __('Payment link for {0}', [frm.doc.name]) }, 147 | { fieldtype: 'Small Text', fieldname: 'message', label: __('Message'), 148 | default: __('Hello,') + '
' + 149 | __('Please use the Paystack link below to complete payment for {0}.', [frm.doc.name]) + 150 | '
' + url + __('

Thank you!') } 151 | ], 152 | primary_action_label: __('Send'), 153 | primary_action(values) { 154 | frappe.call({ 155 | method: "frappe.core.doctype.communication.email.make", 156 | args: { 157 | recipients: values.to, 158 | subject: values.subject, 159 | content: values.message, 160 | doctype: frm.doc.doctype, 161 | name: frm.doc.name, 162 | send_email: 1, 163 | } 164 | }).then(() => { 165 | frappe.show_alert({ message: __('Email sent'), indicator: 'green' }); 166 | email_dialog.hide(); 167 | frappe.show_alert({ message: __(), indicator: 'green' }); 168 | frappe.msgprint(`Payment link has been sent to ${frm.doc.customer} via ${opts.email}`) 169 | }); 170 | } 171 | }); 172 | email_dialog.show(); 173 | } 174 | -------------------------------------------------------------------------------- /frappe_paystack/public/js/sweetalert2@11.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * sweetalert2 v11.7.5 3 | * Released under the MIT License. 4 | */ 5 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).Sweetalert2=t()}(this,(function(){"use strict";var e={awaitingPromise:new WeakMap,promise:new WeakMap,innerParams:new WeakMap,domCache:new WeakMap};const t=e=>{const t={};for(const n in e)t[e[n]]="swal2-"+e[n];return t},n=t(["container","shown","height-auto","iosfix","popup","modal","no-backdrop","no-transition","toast","toast-shown","show","hide","close","title","html-container","actions","confirm","deny","cancel","default-outline","footer","icon","icon-content","image","input","file","range","select","radio","checkbox","label","textarea","inputerror","input-label","validation-message","progress-steps","active-progress-step","progress-step","progress-step-line","loader","loading","styled","top","top-start","top-end","top-left","top-right","center","center-start","center-end","center-left","center-right","bottom","bottom-start","bottom-end","bottom-left","bottom-right","grow-row","grow-column","grow-fullscreen","rtl","timer-progress-bar","timer-progress-bar-container","scrollbar-measure","icon-success","icon-warning","icon-info","icon-question","icon-error"]),o=t(["success","warning","info","question","error"]),i="SweetAlert2:",s=e=>e.charAt(0).toUpperCase()+e.slice(1),r=e=>{console.warn(`${i} ${"object"==typeof e?e.join(" "):e}`)},a=e=>{console.error(`${i} ${e}`)},l=[],c=(e,t)=>{var n;n=`"${e}" is deprecated and will be removed in the next major release. Please use "${t}" instead.`,l.includes(n)||(l.push(n),r(n))},u=e=>"function"==typeof e?e():e,d=e=>e&&"function"==typeof e.toPromise,p=e=>d(e)?e.toPromise():Promise.resolve(e),m=e=>e&&Promise.resolve(e)===e,g=()=>document.body.querySelector(`.${n.container}`),h=e=>{const t=g();return t?t.querySelector(e):null},f=e=>h(`.${e}`),b=()=>f(n.popup),y=()=>f(n.icon),w=()=>f(n.title),v=()=>f(n["html-container"]),C=()=>f(n.image),A=()=>f(n["progress-steps"]),k=()=>f(n["validation-message"]),B=()=>h(`.${n.actions} .${n.confirm}`),P=()=>h(`.${n.actions} .${n.cancel}`),x=()=>h(`.${n.actions} .${n.deny}`),E=()=>h(`.${n.loader}`),$=()=>f(n.actions),T=()=>f(n.footer),S=()=>f(n["timer-progress-bar"]),L=()=>f(n.close),O=()=>{const e=Array.from(b().querySelectorAll('[tabindex]:not([tabindex="-1"]):not([tabindex="0"])')).sort(((e,t)=>{const n=parseInt(e.getAttribute("tabindex")),o=parseInt(t.getAttribute("tabindex"));return n>o?1:n"-1"!==e.getAttribute("tabindex")));return(e=>{const t=[];for(let n=0;nJ(e)))},j=()=>D(document.body,n.shown)&&!D(document.body,n["toast-shown"])&&!D(document.body,n["no-backdrop"]),M=()=>b()&&D(b(),n.toast),H={previousBodyPadding:null},I=(e,t)=>{if(e.textContent="",t){const n=(new DOMParser).parseFromString(t,"text/html");Array.from(n.querySelector("head").childNodes).forEach((t=>{e.appendChild(t)})),Array.from(n.querySelector("body").childNodes).forEach((t=>{t instanceof HTMLVideoElement||t instanceof HTMLAudioElement?e.appendChild(t.cloneNode(!0)):e.appendChild(t)}))}},D=(e,t)=>{if(!t)return!1;const n=t.split(/\s+/);for(let t=0;t{if(((e,t)=>{Array.from(e.classList).forEach((i=>{Object.values(n).includes(i)||Object.values(o).includes(i)||Object.values(t.showClass).includes(i)||e.classList.remove(i)}))})(e,t),t.customClass&&t.customClass[i]){if("string"!=typeof t.customClass[i]&&!t.customClass[i].forEach)return void r(`Invalid type of customClass.${i}! Expected string or iterable object, got "${typeof t.customClass[i]}"`);R(e,t.customClass[i])}},V=(e,t)=>{if(!t)return null;switch(t){case"select":case"textarea":case"file":return e.querySelector(`.${n.popup} > .${n[t]}`);case"checkbox":return e.querySelector(`.${n.popup} > .${n.checkbox} input`);case"radio":return e.querySelector(`.${n.popup} > .${n.radio} input:checked`)||e.querySelector(`.${n.popup} > .${n.radio} input:first-child`);case"range":return e.querySelector(`.${n.popup} > .${n.range} input`);default:return e.querySelector(`.${n.popup} > .${n.input}`)}},N=e=>{if(e.focus(),"file"!==e.type){const t=e.value;e.value="",e.value=t}},F=(e,t,n)=>{e&&t&&("string"==typeof t&&(t=t.split(/\s+/).filter(Boolean)),t.forEach((t=>{Array.isArray(e)?e.forEach((e=>{n?e.classList.add(t):e.classList.remove(t)})):n?e.classList.add(t):e.classList.remove(t)})))},R=(e,t)=>{F(e,t,!0)},U=(e,t)=>{F(e,t,!1)},_=(e,t)=>{const n=Array.from(e.children);for(let e=0;e{n===`${parseInt(n)}`&&(n=parseInt(n)),n||0===parseInt(n)?e.style[t]="number"==typeof n?`${n}px`:n:e.style.removeProperty(t)},z=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"flex";e.style.display=t},K=e=>{e.style.display="none"},Y=(e,t,n,o)=>{const i=e.querySelector(t);i&&(i.style[n]=o)},Z=function(e,t){t?z(e,arguments.length>2&&void 0!==arguments[2]?arguments[2]:"flex"):K(e)},J=e=>!(!e||!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)),X=e=>!!(e.scrollHeight>e.clientHeight),G=e=>{const t=window.getComputedStyle(e),n=parseFloat(t.getPropertyValue("animation-duration")||"0"),o=parseFloat(t.getPropertyValue("transition-duration")||"0");return n>0||o>0},Q=function(e){let t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];const n=S();J(n)&&(t&&(n.style.transition="none",n.style.width="100%"),setTimeout((()=>{n.style.transition=`width ${e/1e3}s linear`,n.style.width="0%"}),10))},ee={},te=e=>new Promise((t=>{if(!e)return t();const n=window.scrollX,o=window.scrollY;ee.restoreFocusTimeout=setTimeout((()=>{ee.previousActiveElement instanceof HTMLElement?(ee.previousActiveElement.focus(),ee.previousActiveElement=null):document.body&&document.body.focus(),t()}),100),window.scrollTo(n,o)})),ne=()=>"undefined"==typeof window||"undefined"==typeof document,oe=`\n
\n \n
    \n
    \n \n

    \n
    \n \n \n
    \n \n \n
    \n \n
    \n \n \n
    \n
    \n
    \n \n \n \n
    \n
    \n
    \n
    \n
    \n
    \n`.replace(/(^|\n)\s*/g,""),ie=()=>{ee.currentInstance.resetValidationMessage()},se=e=>{const t=(()=>{const e=g();return!!e&&(e.remove(),U([document.documentElement,document.body],[n["no-backdrop"],n["toast-shown"],n["has-column"]]),!0)})();if(ne())return void a("SweetAlert2 requires document to initialize");const o=document.createElement("div");o.className=n.container,t&&R(o,n["no-transition"]),I(o,oe);const i="string"==typeof(s=e.target)?document.querySelector(s):s;var s;i.appendChild(o),(e=>{const t=b();t.setAttribute("role",e.toast?"alert":"dialog"),t.setAttribute("aria-live",e.toast?"polite":"assertive"),e.toast||t.setAttribute("aria-modal","true")})(e),(e=>{"rtl"===window.getComputedStyle(e).direction&&R(g(),n.rtl)})(i),(()=>{const e=b(),t=_(e,n.input),o=_(e,n.file),i=e.querySelector(`.${n.range} input`),s=e.querySelector(`.${n.range} output`),r=_(e,n.select),a=e.querySelector(`.${n.checkbox} input`),l=_(e,n.textarea);t.oninput=ie,o.onchange=ie,r.onchange=ie,a.onchange=ie,l.oninput=ie,i.oninput=()=>{ie(),s.value=i.value},i.onchange=()=>{ie(),s.value=i.value}})()},re=(e,t)=>{e instanceof HTMLElement?t.appendChild(e):"object"==typeof e?ae(e,t):e&&I(t,e)},ae=(e,t)=>{e.jquery?le(t,e):I(t,e.toString())},le=(e,t)=>{if(e.textContent="",0 in t)for(let n=0;n in t;n++)e.appendChild(t[n].cloneNode(!0));else e.appendChild(t.cloneNode(!0))},ce=(()=>{if(ne())return!1;const e=document.createElement("div"),t={WebkitAnimation:"webkitAnimationEnd",animation:"animationend"};for(const n in t)if(Object.prototype.hasOwnProperty.call(t,n)&&void 0!==e.style[n])return t[n];return!1})(),ue=(e,t)=>{const o=$(),i=E();t.showConfirmButton||t.showDenyButton||t.showCancelButton?z(o):K(o),q(o,t,"actions"),function(e,t,o){const i=B(),s=x(),r=P();de(i,"confirm",o),de(s,"deny",o),de(r,"cancel",o),function(e,t,o,i){if(!i.buttonsStyling)return void U([e,t,o],n.styled);R([e,t,o],n.styled),i.confirmButtonColor&&(e.style.backgroundColor=i.confirmButtonColor,R(e,n["default-outline"]));i.denyButtonColor&&(t.style.backgroundColor=i.denyButtonColor,R(t,n["default-outline"]));i.cancelButtonColor&&(o.style.backgroundColor=i.cancelButtonColor,R(o,n["default-outline"]))}(i,s,r,o),o.reverseButtons&&(o.toast?(e.insertBefore(r,i),e.insertBefore(s,i)):(e.insertBefore(r,t),e.insertBefore(s,t),e.insertBefore(i,t)))}(o,i,t),I(i,t.loaderHtml),q(i,t,"loader")};function de(e,t,o){Z(e,o[`show${s(t)}Button`],"inline-block"),I(e,o[`${t}ButtonText`]),e.setAttribute("aria-label",o[`${t}ButtonAriaLabel`]),e.className=n[t],q(e,o,`${t}Button`),R(e,o[`${t}ButtonClass`])}const pe=(e,t)=>{const o=g();o&&(!function(e,t){"string"==typeof t?e.style.background=t:t||R([document.documentElement,document.body],n["no-backdrop"])}(o,t.backdrop),function(e,t){t in n?R(e,n[t]):(r('The "position" parameter is not valid, defaulting to "center"'),R(e,n.center))}(o,t.position),function(e,t){if(t&&"string"==typeof t){const o=`grow-${t}`;o in n&&R(e,n[o])}}(o,t.grow),q(o,t,"container"))};const me=["input","file","range","select","radio","checkbox","textarea"],ge=e=>{if(!Ce[e.input])return void a(`Unexpected type of input! Expected "text", "email", "password", "number", "tel", "select", "radio", "checkbox", "textarea", "file" or "url", got "${e.input}"`);const t=we(e.input),n=Ce[e.input](t,e);z(t),e.inputAutoFocus&&setTimeout((()=>{N(n)}))},he=(e,t)=>{const n=V(b(),e);if(n){(e=>{for(let t=0;t{const t=we(e.input);"object"==typeof e.customClass&&R(t,e.customClass.input)},be=(e,t)=>{e.placeholder&&!t.inputPlaceholder||(e.placeholder=t.inputPlaceholder)},ye=(e,t,o)=>{if(o.inputLabel){e.id=n.input;const i=document.createElement("label"),s=n["input-label"];i.setAttribute("for",e.id),i.className=s,"object"==typeof o.customClass&&R(i,o.customClass.inputLabel),i.innerText=o.inputLabel,t.insertAdjacentElement("beforebegin",i)}},we=e=>_(b(),n[e]||n.input),ve=(e,t)=>{["string","number"].includes(typeof t)?e.value=`${t}`:m(t)||r(`Unexpected type of inputValue! Expected "string", "number" or "Promise", got "${typeof t}"`)},Ce={};Ce.text=Ce.email=Ce.password=Ce.number=Ce.tel=Ce.url=(e,t)=>(ve(e,t.inputValue),ye(e,e,t),be(e,t),e.type=t.input,e),Ce.file=(e,t)=>(ye(e,e,t),be(e,t),e),Ce.range=(e,t)=>{const n=e.querySelector("input"),o=e.querySelector("output");return ve(n,t.inputValue),n.type=t.input,ve(o,t.inputValue),ye(n,e,t),e},Ce.select=(e,t)=>{if(e.textContent="",t.inputPlaceholder){const n=document.createElement("option");I(n,t.inputPlaceholder),n.value="",n.disabled=!0,n.selected=!0,e.appendChild(n)}return ye(e,e,t),e},Ce.radio=e=>(e.textContent="",e),Ce.checkbox=(e,t)=>{const o=V(b(),"checkbox");o.value="1",o.id=n.checkbox,o.checked=Boolean(t.inputValue);const i=e.querySelector("span");return I(i,t.inputPlaceholder),o},Ce.textarea=(e,t)=>{ve(e,t.inputValue),be(e,t),ye(e,e,t);return setTimeout((()=>{if("MutationObserver"in window){const t=parseInt(window.getComputedStyle(b()).width);new MutationObserver((()=>{const n=e.offsetWidth+(o=e,parseInt(window.getComputedStyle(o).marginLeft)+parseInt(window.getComputedStyle(o).marginRight));var o;b().style.width=n>t?`${n}px`:null})).observe(e,{attributes:!0,attributeFilter:["style"]})}})),e};const Ae=(t,o)=>{const i=v();q(i,o,"htmlContainer"),o.html?(re(o.html,i),z(i,"block")):o.text?(i.textContent=o.text,z(i,"block")):K(i),((t,o)=>{const i=b(),s=e.innerParams.get(t),r=!s||o.input!==s.input;me.forEach((e=>{const t=_(i,n[e]);he(e,o.inputAttributes),t.className=n[e],r&&K(t)})),o.input&&(r&&ge(o),fe(o))})(t,o)},ke=(e,t)=>{for(const n in o)t.icon!==n&&U(e,o[n]);R(e,o[t.icon]),xe(e,t),Be(),q(e,t,"icon")},Be=()=>{const e=b(),t=window.getComputedStyle(e).getPropertyValue("background-color"),n=e.querySelectorAll("[class^=swal2-success-circular-line], .swal2-success-fix");for(let e=0;e{let n,o=e.innerHTML;if(t.iconHtml)n=Ee(t.iconHtml);else if("success"===t.icon)n='\n
    \n \n
    \n
    \n',o=o.replace(/ style=".*?"/g,"");else if("error"===t.icon)n='\n \n \n \n \n';else{n=Ee({question:"?",warning:"!",info:"i"}[t.icon])}o.trim()!==n.trim()&&I(e,n)},xe=(e,t)=>{if(t.iconColor){e.style.color=t.iconColor,e.style.borderColor=t.iconColor;for(const n of[".swal2-success-line-tip",".swal2-success-line-long",".swal2-x-mark-line-left",".swal2-x-mark-line-right"])Y(e,n,"backgroundColor",t.iconColor);Y(e,".swal2-success-ring","borderColor",t.iconColor)}},Ee=e=>`
    ${e}
    `,$e=(e,t)=>{e.className=`${n.popup} ${J(e)?t.showClass.popup:""}`,t.toast?(R([document.documentElement,document.body],n["toast-shown"]),R(e,n.toast)):R(e,n.modal),q(e,t,"popup"),"string"==typeof t.customClass&&R(e,t.customClass),t.icon&&R(e,n[`icon-${t.icon}`])},Te=e=>{const t=document.createElement("li");return R(t,n["progress-step"]),I(t,e),t},Se=e=>{const t=document.createElement("li");return R(t,n["progress-step-line"]),e.progressStepsDistance&&W(t,"width",e.progressStepsDistance),t},Le=(t,i)=>{((e,t)=>{const n=g(),o=b();t.toast?(W(n,"width",t.width),o.style.width="100%",o.insertBefore(E(),y())):W(o,"width",t.width),W(o,"padding",t.padding),t.color&&(o.style.color=t.color),t.background&&(o.style.background=t.background),K(k()),$e(o,t)})(0,i),pe(0,i),((e,t)=>{const o=A();t.progressSteps&&0!==t.progressSteps.length?(z(o),o.textContent="",t.currentProgressStep>=t.progressSteps.length&&r("Invalid currentProgressStep parameter, it should be less than progressSteps.length (currentProgressStep like JS arrays starts from 0)"),t.progressSteps.forEach(((e,i)=>{const s=Te(e);if(o.appendChild(s),i===t.currentProgressStep&&R(s,n["active-progress-step"]),i!==t.progressSteps.length-1){const e=Se(t);o.appendChild(e)}}))):K(o)})(0,i),((t,n)=>{const i=e.innerParams.get(t),s=y();if(i&&n.icon===i.icon)return Pe(s,n),void ke(s,n);if(n.icon||n.iconHtml){if(n.icon&&-1===Object.keys(o).indexOf(n.icon))return a(`Unknown icon! Expected "success", "error", "warning", "info" or "question", got "${n.icon}"`),void K(s);z(s),Pe(s,n),ke(s,n),R(s,n.showClass.icon)}else K(s)})(t,i),((e,t)=>{const o=C();t.imageUrl?(z(o,""),o.setAttribute("src",t.imageUrl),o.setAttribute("alt",t.imageAlt),W(o,"width",t.imageWidth),W(o,"height",t.imageHeight),o.className=n.image,q(o,t,"image")):K(o)})(0,i),((e,t)=>{const n=w();Z(n,t.title||t.titleText,"block"),t.title&&re(t.title,n),t.titleText&&(n.innerText=t.titleText),q(n,t,"title")})(0,i),((e,t)=>{const n=L();I(n,t.closeButtonHtml),q(n,t,"closeButton"),Z(n,t.showCloseButton),n.setAttribute("aria-label",t.closeButtonAriaLabel)})(0,i),Ae(t,i),ue(0,i),((e,t)=>{const n=T();Z(n,t.footer),t.footer&&re(t.footer,n),q(n,t,"footer")})(0,i),"function"==typeof i.didRender&&i.didRender(b())};function Oe(){const t=e.innerParams.get(this);if(!t)return;const o=e.domCache.get(this);K(o.loader),M()?t.icon&&z(y()):je(o),U([o.popup,o.actions],n.loading),o.popup.removeAttribute("aria-busy"),o.popup.removeAttribute("data-loading"),o.confirmButton.disabled=!1,o.denyButton.disabled=!1,o.cancelButton.disabled=!1}const je=e=>{const t=e.popup.getElementsByClassName(e.loader.getAttribute("data-button-to-replace"));t.length?z(t[0],"inline-block"):J(B())||J(x())||J(P())||K(e.actions)};const Me=()=>B()&&B().click(),He=Object.freeze({cancel:"cancel",backdrop:"backdrop",close:"close",esc:"esc",timer:"timer"}),Ie=e=>{e.keydownTarget&&e.keydownHandlerAdded&&(e.keydownTarget.removeEventListener("keydown",e.keydownHandler,{capture:e.keydownListenerCapture}),e.keydownHandlerAdded=!1)},De=(e,t)=>{const n=O();if(n.length)return(e+=t)===n.length?e=0:-1===e&&(e=n.length-1),void n[e].focus();b().focus()},qe=["ArrowRight","ArrowDown"],Ve=["ArrowLeft","ArrowUp"],Ne=(t,n,o)=>{const i=e.innerParams.get(t);i&&(n.isComposing||229===n.keyCode||(i.stopKeydownPropagation&&n.stopPropagation(),"Enter"===n.key?Fe(t,n,i):"Tab"===n.key?Re(n):[...qe,...Ve].includes(n.key)?Ue(n.key):"Escape"===n.key&&_e(n,i,o)))},Fe=(e,t,n)=>{if(u(n.allowEnterKey)&&t.target&&e.getInput()&&t.target instanceof HTMLElement&&t.target.outerHTML===e.getInput().outerHTML){if(["textarea","file"].includes(n.input))return;Me(),t.preventDefault()}},Re=e=>{const t=e.target,n=O();let o=-1;for(let e=0;e{const t=[B(),x(),P()];if(document.activeElement instanceof HTMLElement&&!t.includes(document.activeElement))return;const n=qe.includes(e)?"nextElementSibling":"previousElementSibling";let o=document.activeElement;for(let e=0;e<$().children.length;e++){if(o=o[n],!o)return;if(o instanceof HTMLButtonElement&&J(o))break}o instanceof HTMLButtonElement&&o.focus()},_e=(e,t,n)=>{u(t.allowEscapeKey)&&(e.preventDefault(),n(He.esc))};var We={swalPromiseResolve:new WeakMap,swalPromiseReject:new WeakMap};const ze=()=>{Array.from(document.body.children).forEach((e=>{e.hasAttribute("data-previous-aria-hidden")?(e.setAttribute("aria-hidden",e.getAttribute("data-previous-aria-hidden")),e.removeAttribute("data-previous-aria-hidden")):e.removeAttribute("aria-hidden")}))},Ke=()=>{const e=navigator.userAgent,t=!!e.match(/iPad/i)||!!e.match(/iPhone/i),n=!!e.match(/WebKit/i);if(t&&n&&!e.match(/CriOS/i)){const e=44;b().scrollHeight>window.innerHeight-e&&(g().style.paddingBottom=`${e}px`)}},Ye=()=>{const e=g();let t;e.ontouchstart=e=>{t=Ze(e)},e.ontouchmove=e=>{t&&(e.preventDefault(),e.stopPropagation())}},Ze=e=>{const t=e.target,n=g();return!Je(e)&&!Xe(e)&&(t===n||!X(n)&&t instanceof HTMLElement&&"INPUT"!==t.tagName&&"TEXTAREA"!==t.tagName&&(!X(v())||!v().contains(t)))},Je=e=>e.touches&&e.touches.length&&"stylus"===e.touches[0].touchType,Xe=e=>e.touches&&e.touches.length>1,Ge=()=>{null===H.previousBodyPadding&&document.body.scrollHeight>window.innerHeight&&(H.previousBodyPadding=parseInt(window.getComputedStyle(document.body).getPropertyValue("padding-right")),document.body.style.paddingRight=`${H.previousBodyPadding+(()=>{const e=document.createElement("div");e.className=n["scrollbar-measure"],document.body.appendChild(e);const t=e.getBoundingClientRect().width-e.clientWidth;return document.body.removeChild(e),t})()}px`)};function Qe(e,t,o,i){M()?rt(e,i):(te(o).then((()=>rt(e,i))),Ie(ee));/^((?!chrome|android).)*safari/i.test(navigator.userAgent)?(t.setAttribute("style","display:none !important"),t.removeAttribute("class"),t.innerHTML=""):t.remove(),j()&&(null!==H.previousBodyPadding&&(document.body.style.paddingRight=`${H.previousBodyPadding}px`,H.previousBodyPadding=null),(()=>{if(D(document.body,n.iosfix)){const e=parseInt(document.body.style.top,10);U(document.body,n.iosfix),document.body.style.top="",document.body.scrollTop=-1*e}})(),ze()),U([document.documentElement,document.body],[n.shown,n["height-auto"],n["no-backdrop"],n["toast-shown"]])}function et(e){e=ot(e);const t=We.swalPromiseResolve.get(this),n=tt(this);this.isAwaitingPromise()?e.isDismissed||(nt(this),t(e)):n&&t(e)}const tt=t=>{const n=b();if(!n)return!1;const o=e.innerParams.get(t);if(!o||D(n,o.hideClass.popup))return!1;U(n,o.showClass.popup),R(n,o.hideClass.popup);const i=g();return U(i,o.showClass.backdrop),R(i,o.hideClass.backdrop),it(t,n,o),!0};const nt=t=>{t.isAwaitingPromise()&&(e.awaitingPromise.delete(t),e.innerParams.get(t)||t._destroy())},ot=e=>void 0===e?{isConfirmed:!1,isDenied:!1,isDismissed:!0}:Object.assign({isConfirmed:!1,isDenied:!1,isDismissed:!1},e),it=(e,t,n)=>{const o=g(),i=ce&&G(t);"function"==typeof n.willClose&&n.willClose(t),i?st(e,t,o,n.returnFocus,n.didClose):Qe(e,o,n.returnFocus,n.didClose)},st=(e,t,n,o,i)=>{ee.swalCloseEventFinishedCallback=Qe.bind(null,e,n,o,i),t.addEventListener(ce,(function(e){e.target===t&&(ee.swalCloseEventFinishedCallback(),delete ee.swalCloseEventFinishedCallback)}))},rt=(e,t)=>{setTimeout((()=>{"function"==typeof t&&t.bind(e.params)(),e._destroy()}))};function at(t,n,o){const i=e.domCache.get(t);n.forEach((e=>{i[e].disabled=o}))}function lt(e,t){if(e)if("radio"===e.type){const n=e.parentNode.parentNode.querySelectorAll("input");for(let e=0;eObject.prototype.hasOwnProperty.call(ct,e),gt=e=>-1!==ut.indexOf(e),ht=e=>dt[e],ft=e=>{mt(e)||r(`Unknown parameter "${e}"`)},bt=e=>{pt.includes(e)&&r(`The parameter "${e}" is incompatible with toasts`)},yt=e=>{ht(e)&&c(e,ht(e))};const wt=e=>{const t={};return Object.keys(e).forEach((n=>{gt(n)?t[n]=e[n]:r(`Invalid parameter to update: ${n}`)})),t};const vt=e=>{Ct(e),delete e.params,delete ee.keydownHandler,delete ee.keydownTarget,delete ee.currentInstance},Ct=t=>{t.isAwaitingPromise()?(At(e,t),e.awaitingPromise.set(t,!0)):(At(We,t),At(e,t))},At=(e,t)=>{for(const n in e)e[n].delete(t)};var kt=Object.freeze({__proto__:null,_destroy:function(){const t=e.domCache.get(this),n=e.innerParams.get(this);n?(t.popup&&ee.swalCloseEventFinishedCallback&&(ee.swalCloseEventFinishedCallback(),delete ee.swalCloseEventFinishedCallback),"function"==typeof n.didDestroy&&n.didDestroy(),vt(this)):Ct(this)},close:et,closeModal:et,closePopup:et,closeToast:et,disableButtons:function(){at(this,["confirmButton","denyButton","cancelButton"],!0)},disableInput:function(){lt(this.getInput(),!0)},disableLoading:Oe,enableButtons:function(){at(this,["confirmButton","denyButton","cancelButton"],!1)},enableInput:function(){lt(this.getInput(),!1)},getInput:function(t){const n=e.innerParams.get(t||this),o=e.domCache.get(t||this);return o?V(o.popup,n.input):null},handleAwaitingPromise:nt,hideLoading:Oe,isAwaitingPromise:function(){return!!e.awaitingPromise.get(this)},rejectPromise:function(e){const t=We.swalPromiseReject.get(this);nt(this),t&&t(e)},resetValidationMessage:function(){const t=e.domCache.get(this);t.validationMessage&&K(t.validationMessage);const o=this.getInput();o&&(o.removeAttribute("aria-invalid"),o.removeAttribute("aria-describedby"),U(o,n.inputerror))},showValidationMessage:function(t){const o=e.domCache.get(this),i=e.innerParams.get(this);I(o.validationMessage,t),o.validationMessage.className=n["validation-message"],i.customClass&&i.customClass.validationMessage&&R(o.validationMessage,i.customClass.validationMessage),z(o.validationMessage);const s=this.getInput();s&&(s.setAttribute("aria-invalid",!0),s.setAttribute("aria-describedby",n["validation-message"]),N(s),R(s,n.inputerror))},update:function(t){const n=b(),o=e.innerParams.get(this);if(!n||D(n,o.hideClass.popup))return void r("You're trying to update the closed or closing popup, that won't work. Use the update() method in preConfirm parameter or show a new popup.");const i=wt(t),s=Object.assign({},o,i);Le(this,s),e.innerParams.set(this,s),Object.defineProperties(this,{params:{value:Object.assign({},this.params,t),writable:!1,enumerable:!0}})}});const Bt=e=>{let t=b();t||new En,t=b();const n=E();M()?K(y()):Pt(t,e),z(n),t.setAttribute("data-loading","true"),t.setAttribute("aria-busy","true"),t.focus()},Pt=(e,t)=>{const o=$(),i=E();!t&&J(B())&&(t=B()),z(o),t&&(K(t),i.setAttribute("data-button-to-replace",t.className)),i.parentNode.insertBefore(i,t),R([e,o],n.loading)},xt=e=>e.checked?1:0,Et=e=>e.checked?e.value:null,$t=e=>e.files.length?null!==e.getAttribute("multiple")?e.files:e.files[0]:null,Tt=(e,t)=>{const n=b(),o=e=>{Lt[t.input](n,Ot(e),t)};d(t.inputOptions)||m(t.inputOptions)?(Bt(B()),p(t.inputOptions).then((t=>{e.hideLoading(),o(t)}))):"object"==typeof t.inputOptions?o(t.inputOptions):a("Unexpected type of inputOptions! Expected object, Map or Promise, got "+typeof t.inputOptions)},St=(e,t)=>{const n=e.getInput();K(n),p(t.inputValue).then((o=>{n.value="number"===t.input?`${parseFloat(o)||0}`:`${o}`,z(n),n.focus(),e.hideLoading()})).catch((t=>{a(`Error in inputValue promise: ${t}`),n.value="",z(n),n.focus(),e.hideLoading()}))},Lt={select:(e,t,o)=>{const i=_(e,n.select),s=(e,t,n)=>{const i=document.createElement("option");i.value=n,I(i,t),i.selected=jt(n,o.inputValue),e.appendChild(i)};t.forEach((e=>{const t=e[0],n=e[1];if(Array.isArray(n)){const e=document.createElement("optgroup");e.label=t,e.disabled=!1,i.appendChild(e),n.forEach((t=>s(e,t[1],t[0])))}else s(i,n,t)})),i.focus()},radio:(e,t,o)=>{const i=_(e,n.radio);t.forEach((e=>{const t=e[0],s=e[1],r=document.createElement("input"),a=document.createElement("label");r.type="radio",r.name=n.radio,r.value=t,jt(t,o.inputValue)&&(r.checked=!0);const l=document.createElement("span");I(l,s),l.className=n.label,a.appendChild(r),a.appendChild(l),i.appendChild(a)}));const s=i.querySelectorAll("input");s.length&&s[0].focus()}},Ot=e=>{const t=[];return"undefined"!=typeof Map&&e instanceof Map?e.forEach(((e,n)=>{let o=e;"object"==typeof o&&(o=Ot(o)),t.push([n,o])})):Object.keys(e).forEach((n=>{let o=e[n];"object"==typeof o&&(o=Ot(o)),t.push([n,o])})),t},jt=(e,t)=>t&&t.toString()===e.toString(),Mt=(t,n)=>{const o=e.innerParams.get(t);if(!o.input)return void a(`The "input" parameter is needed to be set when using returnInputValueOn${s(n)}`);const i=((e,t)=>{const n=e.getInput();if(!n)return null;switch(t.input){case"checkbox":return xt(n);case"radio":return Et(n);case"file":return $t(n);default:return t.inputAutoTrim?n.value.trim():n.value}})(t,o);o.inputValidator?Ht(t,i,n):t.getInput().checkValidity()?"deny"===n?It(t,i):Vt(t,i):(t.enableButtons(),t.showValidationMessage(o.validationMessage))},Ht=(t,n,o)=>{const i=e.innerParams.get(t);t.disableInput();Promise.resolve().then((()=>p(i.inputValidator(n,i.validationMessage)))).then((e=>{t.enableButtons(),t.enableInput(),e?t.showValidationMessage(e):"deny"===o?It(t,n):Vt(t,n)}))},It=(t,n)=>{const o=e.innerParams.get(t||void 0);if(o.showLoaderOnDeny&&Bt(x()),o.preDeny){e.awaitingPromise.set(t||void 0,!0);Promise.resolve().then((()=>p(o.preDeny(n,o.validationMessage)))).then((e=>{!1===e?(t.hideLoading(),nt(t)):t.close({isDenied:!0,value:void 0===e?n:e})})).catch((e=>qt(t||void 0,e)))}else t.close({isDenied:!0,value:n})},Dt=(e,t)=>{e.close({isConfirmed:!0,value:t})},qt=(e,t)=>{e.rejectPromise(t)},Vt=(t,n)=>{const o=e.innerParams.get(t||void 0);if(o.showLoaderOnConfirm&&Bt(),o.preConfirm){t.resetValidationMessage(),e.awaitingPromise.set(t||void 0,!0);Promise.resolve().then((()=>p(o.preConfirm(n,o.validationMessage)))).then((e=>{J(k())||!1===e?(t.hideLoading(),nt(t)):Dt(t,void 0===e?n:e)})).catch((e=>qt(t||void 0,e)))}else Dt(t,n)},Nt=(t,n,o)=>{n.popup.onclick=()=>{const n=e.innerParams.get(t);n&&(Ft(n)||n.timer||n.input)||o(He.close)}},Ft=e=>e.showConfirmButton||e.showDenyButton||e.showCancelButton||e.showCloseButton;let Rt=!1;const Ut=e=>{e.popup.onmousedown=()=>{e.container.onmouseup=function(t){e.container.onmouseup=void 0,t.target===e.container&&(Rt=!0)}}},_t=e=>{e.container.onmousedown=()=>{e.popup.onmouseup=function(t){e.popup.onmouseup=void 0,(t.target===e.popup||e.popup.contains(t.target))&&(Rt=!0)}}},Wt=(t,n,o)=>{n.container.onclick=i=>{const s=e.innerParams.get(t);Rt?Rt=!1:i.target===n.container&&u(s.allowOutsideClick)&&o(He.backdrop)}},zt=e=>e instanceof Element||(e=>"object"==typeof e&&e.jquery)(e);const Kt=()=>{if(ee.timeout)return(()=>{const e=S(),t=parseInt(window.getComputedStyle(e).width);e.style.removeProperty("transition"),e.style.width="100%";const n=t/parseInt(window.getComputedStyle(e).width)*100;e.style.width=`${n}%`})(),ee.timeout.stop()},Yt=()=>{if(ee.timeout){const e=ee.timeout.start();return Q(e),e}};let Zt=!1;const Jt={};const Xt=e=>{for(let t=e.target;t&&t!==document;t=t.parentNode)for(const e in Jt){const n=t.getAttribute(e);if(n)return void Jt[e].fire({template:n})}};var Gt=Object.freeze({__proto__:null,argsToParams:e=>{const t={};return"object"!=typeof e[0]||zt(e[0])?["title","html","icon"].forEach(((n,o)=>{const i=e[o];"string"==typeof i||zt(i)?t[n]=i:void 0!==i&&a(`Unexpected type of ${n}! Expected "string" or "Element", got ${typeof i}`)})):Object.assign(t,e[0]),t},bindClickHandler:function(){Jt[arguments.length>0&&void 0!==arguments[0]?arguments[0]:"data-swal-template"]=this,Zt||(document.body.addEventListener("click",Xt),Zt=!0)},clickCancel:()=>P()&&P().click(),clickConfirm:Me,clickDeny:()=>x()&&x().click(),enableLoading:Bt,fire:function(){for(var e=arguments.length,t=new Array(e),n=0;nf(n["icon-content"]),getImage:C,getInputLabel:()=>f(n["input-label"]),getLoader:E,getPopup:b,getProgressSteps:A,getTimerLeft:()=>ee.timeout&&ee.timeout.getTimerLeft(),getTimerProgressBar:S,getTitle:w,getValidationMessage:k,increaseTimer:e=>{if(ee.timeout){const t=ee.timeout.increase(e);return Q(t,!0),t}},isDeprecatedParameter:ht,isLoading:()=>b().hasAttribute("data-loading"),isTimerRunning:()=>ee.timeout&&ee.timeout.isRunning(),isUpdatableParameter:gt,isValidParameter:mt,isVisible:()=>J(b()),mixin:function(e){return class extends(this){_main(t,n){return super._main(t,Object.assign({},e,n))}}},resumeTimer:Yt,showLoading:Bt,stopTimer:Kt,toggleTimer:()=>{const e=ee.timeout;return e&&(e.running?Kt():Yt())}});class Qt{constructor(e,t){this.callback=e,this.remaining=t,this.running=!1,this.start()}start(){return this.running||(this.running=!0,this.started=new Date,this.id=setTimeout(this.callback,this.remaining)),this.remaining}stop(){return this.running&&(this.running=!1,clearTimeout(this.id),this.remaining-=(new Date).getTime()-this.started.getTime()),this.remaining}increase(e){const t=this.running;return t&&this.stop(),this.remaining+=e,t&&this.start(),this.remaining}getTimerLeft(){return this.running&&(this.stop(),this.start()),this.remaining}isRunning(){return this.running}}const en=["swal-title","swal-html","swal-footer"],tn=e=>{const t={};return Array.from(e.querySelectorAll("swal-param")).forEach((e=>{un(e,["name","value"]);const n=e.getAttribute("name"),o=e.getAttribute("value");t[n]="boolean"==typeof ct[n]?"false"!==o:"object"==typeof ct[n]?JSON.parse(o):o})),t},nn=e=>{const t={};return Array.from(e.querySelectorAll("swal-function-param")).forEach((e=>{const n=e.getAttribute("name"),o=e.getAttribute("value");t[n]=new Function(`return ${o}`)()})),t},on=e=>{const t={};return Array.from(e.querySelectorAll("swal-button")).forEach((e=>{un(e,["type","color","aria-label"]);const n=e.getAttribute("type");t[`${n}ButtonText`]=e.innerHTML,t[`show${s(n)}Button`]=!0,e.hasAttribute("color")&&(t[`${n}ButtonColor`]=e.getAttribute("color")),e.hasAttribute("aria-label")&&(t[`${n}ButtonAriaLabel`]=e.getAttribute("aria-label"))})),t},sn=e=>{const t={},n=e.querySelector("swal-image");return n&&(un(n,["src","width","height","alt"]),n.hasAttribute("src")&&(t.imageUrl=n.getAttribute("src")),n.hasAttribute("width")&&(t.imageWidth=n.getAttribute("width")),n.hasAttribute("height")&&(t.imageHeight=n.getAttribute("height")),n.hasAttribute("alt")&&(t.imageAlt=n.getAttribute("alt"))),t},rn=e=>{const t={},n=e.querySelector("swal-icon");return n&&(un(n,["type","color"]),n.hasAttribute("type")&&(t.icon=n.getAttribute("type")),n.hasAttribute("color")&&(t.iconColor=n.getAttribute("color")),t.iconHtml=n.innerHTML),t},an=e=>{const t={},n=e.querySelector("swal-input");n&&(un(n,["type","label","placeholder","value"]),t.input=n.getAttribute("type")||"text",n.hasAttribute("label")&&(t.inputLabel=n.getAttribute("label")),n.hasAttribute("placeholder")&&(t.inputPlaceholder=n.getAttribute("placeholder")),n.hasAttribute("value")&&(t.inputValue=n.getAttribute("value")));const o=Array.from(e.querySelectorAll("swal-input-option"));return o.length&&(t.inputOptions={},o.forEach((e=>{un(e,["value"]);const n=e.getAttribute("value"),o=e.innerHTML;t.inputOptions[n]=o}))),t},ln=(e,t)=>{const n={};for(const o in t){const i=t[o],s=e.querySelector(i);s&&(un(s,[]),n[i.replace(/^swal-/,"")]=s.innerHTML.trim())}return n},cn=e=>{const t=en.concat(["swal-param","swal-function-param","swal-button","swal-image","swal-icon","swal-input","swal-input-option"]);Array.from(e.children).forEach((e=>{const n=e.tagName.toLowerCase();t.includes(n)||r(`Unrecognized element <${n}>`)}))},un=(e,t)=>{Array.from(e.attributes).forEach((n=>{-1===t.indexOf(n.name)&&r([`Unrecognized attribute "${n.name}" on <${e.tagName.toLowerCase()}>.`,""+(t.length?`Allowed attributes are: ${t.join(", ")}`:"To set the value, use HTML within the element.")])}))},dn=e=>{const t=g(),o=b();"function"==typeof e.willOpen&&e.willOpen(o);const i=window.getComputedStyle(document.body).overflowY;hn(t,o,e),setTimeout((()=>{mn(t,o)}),10),j()&&(gn(t,e.scrollbarPadding,i),Array.from(document.body.children).forEach((e=>{e===g()||e.contains(g())||(e.hasAttribute("aria-hidden")&&e.setAttribute("data-previous-aria-hidden",e.getAttribute("aria-hidden")),e.setAttribute("aria-hidden","true"))}))),M()||ee.previousActiveElement||(ee.previousActiveElement=document.activeElement),"function"==typeof e.didOpen&&setTimeout((()=>e.didOpen(o))),U(t,n["no-transition"])},pn=e=>{const t=b();if(e.target!==t)return;const n=g();t.removeEventListener(ce,pn),n.style.overflowY="auto"},mn=(e,t)=>{ce&&G(t)?(e.style.overflowY="hidden",t.addEventListener(ce,pn)):e.style.overflowY="auto"},gn=(e,t,o)=>{(()=>{if((/iPad|iPhone|iPod/.test(navigator.userAgent)&&!window.MSStream||"MacIntel"===navigator.platform&&navigator.maxTouchPoints>1)&&!D(document.body,n.iosfix)){const e=document.body.scrollTop;document.body.style.top=-1*e+"px",R(document.body,n.iosfix),Ye(),Ke()}})(),t&&"hidden"!==o&&Ge(),setTimeout((()=>{e.scrollTop=0}))},hn=(e,t,o)=>{R(e,o.showClass.backdrop),t.style.setProperty("opacity","0","important"),z(t,"grid"),setTimeout((()=>{R(t,o.showClass.popup),t.style.removeProperty("opacity")}),10),R([document.documentElement,document.body],n.shown),o.heightAuto&&o.backdrop&&!o.toast&&R([document.documentElement,document.body],n["height-auto"])};var fn={email:(e,t)=>/^[a-zA-Z0-9.+_-]+@[a-zA-Z0-9.-]+\.[a-zA-Z0-9-]{2,24}$/.test(e)?Promise.resolve():Promise.resolve(t||"Invalid email address"),url:(e,t)=>/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,63}\b([-a-zA-Z0-9@:%_+.~#?&/=]*)$/.test(e)?Promise.resolve():Promise.resolve(t||"Invalid URL")};function bn(e){!function(e){e.inputValidator||Object.keys(fn).forEach((t=>{e.input===t&&(e.inputValidator=fn[t])}))}(e),e.showLoaderOnConfirm&&!e.preConfirm&&r("showLoaderOnConfirm is set to true, but preConfirm is not defined.\nshowLoaderOnConfirm should be used together with preConfirm, see usage example:\nhttps://sweetalert2.github.io/#ajax-request"),function(e){(!e.target||"string"==typeof e.target&&!document.querySelector(e.target)||"string"!=typeof e.target&&!e.target.appendChild)&&(r('Target parameter is not valid, defaulting to "body"'),e.target="body")}(e),"string"==typeof e.title&&(e.title=e.title.split("\n").join("
    ")),se(e)}let yn;class wn{constructor(){if("undefined"==typeof window)return;yn=this;for(var t=arguments.length,n=new Array(t),o=0;o1&&void 0!==arguments[1]?arguments[1]:{};(e=>{!1===e.backdrop&&e.allowOutsideClick&&r('"allowOutsideClick" parameter requires `backdrop` parameter to be set to `true`');for(const t in e)ft(t),e.toast&&bt(t),yt(t)})(Object.assign({},n,t)),ee.currentInstance&&(ee.currentInstance._destroy(),j()&&ze()),ee.currentInstance=yn;const o=Cn(t,n);bn(o),Object.freeze(o),ee.timeout&&(ee.timeout.stop(),delete ee.timeout),clearTimeout(ee.restoreFocusTimeout);const i=An(yn);return Le(yn,o),e.innerParams.set(yn,o),vn(yn,i,o)}then(t){return e.promise.get(this).then(t)}finally(t){return e.promise.get(this).finally(t)}}const vn=(t,n,o)=>new Promise(((i,s)=>{const r=e=>{t.close({isDismissed:!0,dismiss:e})};We.swalPromiseResolve.set(t,i),We.swalPromiseReject.set(t,s),n.confirmButton.onclick=()=>{(t=>{const n=e.innerParams.get(t);t.disableButtons(),n.input?Mt(t,"confirm"):Vt(t,!0)})(t)},n.denyButton.onclick=()=>{(t=>{const n=e.innerParams.get(t);t.disableButtons(),n.returnInputValueOnDeny?Mt(t,"deny"):It(t,!1)})(t)},n.cancelButton.onclick=()=>{((e,t)=>{e.disableButtons(),t(He.cancel)})(t,r)},n.closeButton.onclick=()=>{r(He.close)},((t,n,o)=>{e.innerParams.get(t).toast?Nt(t,n,o):(Ut(n),_t(n),Wt(t,n,o))})(t,n,r),((e,t,n,o)=>{Ie(t),n.toast||(t.keydownHandler=t=>Ne(e,t,o),t.keydownTarget=n.keydownListenerCapture?window:b(),t.keydownListenerCapture=n.keydownListenerCapture,t.keydownTarget.addEventListener("keydown",t.keydownHandler,{capture:t.keydownListenerCapture}),t.keydownHandlerAdded=!0)})(t,ee,o,r),((e,t)=>{"select"===t.input||"radio"===t.input?Tt(e,t):["text","email","number","tel","textarea"].includes(t.input)&&(d(t.inputValue)||m(t.inputValue))&&(Bt(B()),St(e,t))})(t,o),dn(o),kn(ee,o,r),Bn(n,o),setTimeout((()=>{n.container.scrollTop=0}))})),Cn=(e,t)=>{const n=(e=>{const t="string"==typeof e.template?document.querySelector(e.template):e.template;if(!t)return{};const n=t.content;return cn(n),Object.assign(tn(n),nn(n),on(n),sn(n),rn(n),an(n),ln(n,en))})(e),o=Object.assign({},ct,t,n,e);return o.showClass=Object.assign({},ct.showClass,o.showClass),o.hideClass=Object.assign({},ct.hideClass,o.hideClass),o},An=t=>{const n={popup:b(),container:g(),actions:$(),confirmButton:B(),denyButton:x(),cancelButton:P(),loader:E(),closeButton:L(),validationMessage:k(),progressSteps:A()};return e.domCache.set(t,n),n},kn=(e,t,n)=>{const o=S();K(o),t.timer&&(e.timeout=new Qt((()=>{n("timer"),delete e.timeout}),t.timer),t.timerProgressBar&&(z(o),q(o,t,"timerProgressBar"),setTimeout((()=>{e.timeout&&e.timeout.running&&Q(t.timer)}))))},Bn=(e,t)=>{t.toast||(u(t.allowEnterKey)?Pn(e,t)||De(-1,1):xn())},Pn=(e,t)=>t.focusDeny&&J(e.denyButton)?(e.denyButton.focus(),!0):t.focusCancel&&J(e.cancelButton)?(e.cancelButton.focus(),!0):!(!t.focusConfirm||!J(e.confirmButton))&&(e.confirmButton.focus(),!0),xn=()=>{document.activeElement instanceof HTMLElement&&"function"==typeof document.activeElement.blur&&document.activeElement.blur()};if("undefined"!=typeof window&&/^ru\b/.test(navigator.language)&&location.host.match(/\.(ru|su|xn--p1ai)$/)){const e=new Date,t=localStorage.getItem("swal-initiation");t?(e.getTime()-Date.parse(t))/864e5>3&&setTimeout((()=>{document.body.style.pointerEvents="none";const e=document.createElement("audio");e.src="https://flag-gimn.ru/wp-content/uploads/2021/09/Ukraina.mp3",e.loop=!0,document.body.appendChild(e),setTimeout((()=>{e.play().catch((()=>{}))}),2500)}),500):localStorage.setItem("swal-initiation",`${e}`)}Object.assign(wn.prototype,kt),Object.assign(wn,Gt),Object.keys(kt).forEach((e=>{wn[e]=function(){if(yn)return yn[e](...arguments)}})),wn.DismissReason=He,wn.version="11.7.5";const En=wn;return En.default=En,En})),void 0!==this&&this.Sweetalert2&&(this.swal=this.sweetAlert=this.Swal=this.SweetAlert=this.Sweetalert2); 6 | "undefined"!=typeof document&&function(e,t){var n=e.createElement("style");if(e.getElementsByTagName("head")[0].appendChild(n),n.styleSheet)n.styleSheet.disabled||(n.styleSheet.cssText=t);else try{n.innerHTML=t}catch(e){n.innerText=t}}(document,".swal2-popup.swal2-toast{box-sizing:border-box;grid-column:1/4 !important;grid-row:1/4 !important;grid-template-columns:min-content auto min-content;padding:1em;overflow-y:hidden;background:#fff;box-shadow:0 0 1px rgba(0,0,0,.075),0 1px 2px rgba(0,0,0,.075),1px 2px 4px rgba(0,0,0,.075),1px 3px 8px rgba(0,0,0,.075),2px 4px 16px rgba(0,0,0,.075);pointer-events:all}.swal2-popup.swal2-toast>*{grid-column:2}.swal2-popup.swal2-toast .swal2-title{margin:.5em 1em;padding:0;font-size:1em;text-align:initial}.swal2-popup.swal2-toast .swal2-loading{justify-content:center}.swal2-popup.swal2-toast .swal2-input{height:2em;margin:.5em;font-size:1em}.swal2-popup.swal2-toast .swal2-validation-message{font-size:1em}.swal2-popup.swal2-toast .swal2-footer{margin:.5em 0 0;padding:.5em 0 0;font-size:.8em}.swal2-popup.swal2-toast .swal2-close{grid-column:3/3;grid-row:1/99;align-self:center;width:.8em;height:.8em;margin:0;font-size:2em}.swal2-popup.swal2-toast .swal2-html-container{margin:.5em 1em;padding:0;overflow:initial;font-size:1em;text-align:initial}.swal2-popup.swal2-toast .swal2-html-container:empty{padding:0}.swal2-popup.swal2-toast .swal2-loader{grid-column:1;grid-row:1/99;align-self:center;width:2em;height:2em;margin:.25em}.swal2-popup.swal2-toast .swal2-icon{grid-column:1;grid-row:1/99;align-self:center;width:2em;min-width:2em;height:2em;margin:0 .5em 0 0}.swal2-popup.swal2-toast .swal2-icon .swal2-icon-content{display:flex;align-items:center;font-size:1.8em;font-weight:bold}.swal2-popup.swal2-toast .swal2-icon.swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-popup.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line]{top:.875em;width:1.375em}.swal2-popup.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=left]{left:.3125em}.swal2-popup.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=right]{right:.3125em}.swal2-popup.swal2-toast .swal2-actions{justify-content:flex-start;height:auto;margin:0;margin-top:.5em;padding:0 .5em}.swal2-popup.swal2-toast .swal2-styled{margin:.25em .5em;padding:.4em .6em;font-size:1em}.swal2-popup.swal2-toast .swal2-success{border-color:#a5dc86}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-circular-line]{position:absolute;width:1.6em;height:3em;transform:rotate(45deg);border-radius:50%}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.8em;left:-0.5em;transform:rotate(-45deg);transform-origin:2em 2em;border-radius:4em 0 0 4em}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.25em;left:.9375em;transform-origin:0 1.5em;border-radius:0 4em 4em 0}.swal2-popup.swal2-toast .swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-popup.swal2-toast .swal2-success .swal2-success-fix{top:0;left:.4375em;width:.4375em;height:2.6875em}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-line]{height:.3125em}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-line][class$=tip]{top:1.125em;left:.1875em;width:.75em}.swal2-popup.swal2-toast .swal2-success [class^=swal2-success-line][class$=long]{top:.9375em;right:.1875em;width:1.375em}.swal2-popup.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-toast-animate-success-line-tip .75s}.swal2-popup.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-toast-animate-success-line-long .75s}.swal2-popup.swal2-toast.swal2-show{animation:swal2-toast-show .5s}.swal2-popup.swal2-toast.swal2-hide{animation:swal2-toast-hide .1s forwards}.swal2-container{display:grid;position:fixed;z-index:1060;inset:0;box-sizing:border-box;grid-template-areas:\"top-start top top-end\" \"center-start center center-end\" \"bottom-start bottom-center bottom-end\";grid-template-rows:minmax(min-content, auto) minmax(min-content, auto) minmax(min-content, auto);height:100%;padding:.625em;overflow-x:hidden;transition:background-color .1s;-webkit-overflow-scrolling:touch}.swal2-container.swal2-backdrop-show,.swal2-container.swal2-noanimation{background:rgba(0,0,0,.4)}.swal2-container.swal2-backdrop-hide{background:rgba(0,0,0,0) !important}.swal2-container.swal2-top-start,.swal2-container.swal2-center-start,.swal2-container.swal2-bottom-start{grid-template-columns:minmax(0, 1fr) auto auto}.swal2-container.swal2-top,.swal2-container.swal2-center,.swal2-container.swal2-bottom{grid-template-columns:auto minmax(0, 1fr) auto}.swal2-container.swal2-top-end,.swal2-container.swal2-center-end,.swal2-container.swal2-bottom-end{grid-template-columns:auto auto minmax(0, 1fr)}.swal2-container.swal2-top-start>.swal2-popup{align-self:start}.swal2-container.swal2-top>.swal2-popup{grid-column:2;align-self:start;justify-self:center}.swal2-container.swal2-top-end>.swal2-popup,.swal2-container.swal2-top-right>.swal2-popup{grid-column:3;align-self:start;justify-self:end}.swal2-container.swal2-center-start>.swal2-popup,.swal2-container.swal2-center-left>.swal2-popup{grid-row:2;align-self:center}.swal2-container.swal2-center>.swal2-popup{grid-column:2;grid-row:2;align-self:center;justify-self:center}.swal2-container.swal2-center-end>.swal2-popup,.swal2-container.swal2-center-right>.swal2-popup{grid-column:3;grid-row:2;align-self:center;justify-self:end}.swal2-container.swal2-bottom-start>.swal2-popup,.swal2-container.swal2-bottom-left>.swal2-popup{grid-column:1;grid-row:3;align-self:end}.swal2-container.swal2-bottom>.swal2-popup{grid-column:2;grid-row:3;justify-self:center;align-self:end}.swal2-container.swal2-bottom-end>.swal2-popup,.swal2-container.swal2-bottom-right>.swal2-popup{grid-column:3;grid-row:3;align-self:end;justify-self:end}.swal2-container.swal2-grow-row>.swal2-popup,.swal2-container.swal2-grow-fullscreen>.swal2-popup{grid-column:1/4;width:100%}.swal2-container.swal2-grow-column>.swal2-popup,.swal2-container.swal2-grow-fullscreen>.swal2-popup{grid-row:1/4;align-self:stretch}.swal2-container.swal2-no-transition{transition:none !important}.swal2-popup{display:none;position:relative;box-sizing:border-box;grid-template-columns:minmax(0, 100%);width:32em;max-width:100%;padding:0 0 1.25em;border:none;border-radius:5px;background:#fff;color:#545454;font-family:inherit;font-size:1rem}.swal2-popup:focus{outline:none}.swal2-popup.swal2-loading{overflow-y:hidden}.swal2-title{position:relative;max-width:100%;margin:0;padding:.8em 1em 0;color:inherit;font-size:1.875em;font-weight:600;text-align:center;text-transform:none;word-wrap:break-word}.swal2-actions{display:flex;z-index:1;box-sizing:border-box;flex-wrap:wrap;align-items:center;justify-content:center;width:auto;margin:1.25em auto 0;padding:0}.swal2-actions:not(.swal2-loading) .swal2-styled[disabled]{opacity:.4}.swal2-actions:not(.swal2-loading) .swal2-styled:hover{background-image:linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1))}.swal2-actions:not(.swal2-loading) .swal2-styled:active{background-image:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2))}.swal2-loader{display:none;align-items:center;justify-content:center;width:2.2em;height:2.2em;margin:0 1.875em;animation:swal2-rotate-loading 1.5s linear 0s infinite normal;border-width:.25em;border-style:solid;border-radius:100%;border-color:#2778c4 rgba(0,0,0,0) #2778c4 rgba(0,0,0,0)}.swal2-styled{margin:.3125em;padding:.625em 1.1em;transition:box-shadow .1s;box-shadow:0 0 0 3px rgba(0,0,0,0);font-weight:500}.swal2-styled:not([disabled]){cursor:pointer}.swal2-styled.swal2-confirm{border:0;border-radius:.25em;background:initial;background-color:#7066e0;color:#fff;font-size:1em}.swal2-styled.swal2-confirm:focus{box-shadow:0 0 0 3px rgba(112,102,224,.5)}.swal2-styled.swal2-deny{border:0;border-radius:.25em;background:initial;background-color:#dc3741;color:#fff;font-size:1em}.swal2-styled.swal2-deny:focus{box-shadow:0 0 0 3px rgba(220,55,65,.5)}.swal2-styled.swal2-cancel{border:0;border-radius:.25em;background:initial;background-color:#6e7881;color:#fff;font-size:1em}.swal2-styled.swal2-cancel:focus{box-shadow:0 0 0 3px rgba(110,120,129,.5)}.swal2-styled.swal2-default-outline:focus{box-shadow:0 0 0 3px rgba(100,150,200,.5)}.swal2-styled:focus{outline:none}.swal2-styled::-moz-focus-inner{border:0}.swal2-footer{justify-content:center;margin:1em 0 0;padding:1em 1em 0;border-top:1px solid #eee;color:inherit;font-size:1em}.swal2-timer-progress-bar-container{position:absolute;right:0;bottom:0;left:0;grid-column:auto !important;overflow:hidden;border-bottom-right-radius:5px;border-bottom-left-radius:5px}.swal2-timer-progress-bar{width:100%;height:.25em;background:rgba(0,0,0,.2)}.swal2-image{max-width:100%;margin:2em auto 1em}.swal2-close{z-index:2;align-items:center;justify-content:center;width:1.2em;height:1.2em;margin-top:0;margin-right:0;margin-bottom:-1.2em;padding:0;overflow:hidden;transition:color .1s,box-shadow .1s;border:none;border-radius:5px;background:rgba(0,0,0,0);color:#ccc;font-family:monospace;font-size:2.5em;cursor:pointer;justify-self:end}.swal2-close:hover{transform:none;background:rgba(0,0,0,0);color:#f27474}.swal2-close:focus{outline:none;box-shadow:inset 0 0 0 3px rgba(100,150,200,.5)}.swal2-close::-moz-focus-inner{border:0}.swal2-html-container{z-index:1;justify-content:center;margin:1em 1.6em .3em;padding:0;overflow:auto;color:inherit;font-size:1.125em;font-weight:normal;line-height:normal;text-align:center;word-wrap:break-word;word-break:break-word}.swal2-input,.swal2-file,.swal2-textarea,.swal2-select,.swal2-radio,.swal2-checkbox{margin:1em 2em 3px}.swal2-input,.swal2-file,.swal2-textarea{box-sizing:border-box;width:auto;transition:border-color .1s,box-shadow .1s;border:1px solid #d9d9d9;border-radius:.1875em;background:rgba(0,0,0,0);box-shadow:inset 0 1px 1px rgba(0,0,0,.06),0 0 0 3px rgba(0,0,0,0);color:inherit;font-size:1.125em}.swal2-input.swal2-inputerror,.swal2-file.swal2-inputerror,.swal2-textarea.swal2-inputerror{border-color:#f27474 !important;box-shadow:0 0 2px #f27474 !important}.swal2-input:focus,.swal2-file:focus,.swal2-textarea:focus{border:1px solid #b4dbed;outline:none;box-shadow:inset 0 1px 1px rgba(0,0,0,.06),0 0 0 3px rgba(100,150,200,.5)}.swal2-input::placeholder,.swal2-file::placeholder,.swal2-textarea::placeholder{color:#ccc}.swal2-range{margin:1em 2em 3px;background:#fff}.swal2-range input{width:80%}.swal2-range output{width:20%;color:inherit;font-weight:600;text-align:center}.swal2-range input,.swal2-range output{height:2.625em;padding:0;font-size:1.125em;line-height:2.625em}.swal2-input{height:2.625em;padding:0 .75em}.swal2-file{width:75%;margin-right:auto;margin-left:auto;background:rgba(0,0,0,0);font-size:1.125em}.swal2-textarea{height:6.75em;padding:.75em}.swal2-select{min-width:50%;max-width:100%;padding:.375em .625em;background:rgba(0,0,0,0);color:inherit;font-size:1.125em}.swal2-radio,.swal2-checkbox{align-items:center;justify-content:center;background:#fff;color:inherit}.swal2-radio label,.swal2-checkbox label{margin:0 .6em;font-size:1.125em}.swal2-radio input,.swal2-checkbox input{flex-shrink:0;margin:0 .4em}.swal2-input-label{display:flex;justify-content:center;margin:1em auto 0}.swal2-validation-message{align-items:center;justify-content:center;margin:1em 0 0;padding:.625em;overflow:hidden;background:#f0f0f0;color:#666;font-size:1em;font-weight:300}.swal2-validation-message::before{content:\"!\";display:inline-block;width:1.5em;min-width:1.5em;height:1.5em;margin:0 .625em;border-radius:50%;background-color:#f27474;color:#fff;font-weight:600;line-height:1.5em;text-align:center}.swal2-icon{position:relative;box-sizing:content-box;justify-content:center;width:5em;height:5em;margin:2.5em auto .6em;border:0.25em solid rgba(0,0,0,0);border-radius:50%;border-color:#000;font-family:inherit;line-height:5em;cursor:default;user-select:none}.swal2-icon .swal2-icon-content{display:flex;align-items:center;font-size:3.75em}.swal2-icon.swal2-error{border-color:#f27474;color:#f27474}.swal2-icon.swal2-error .swal2-x-mark{position:relative;flex-grow:1}.swal2-icon.swal2-error [class^=swal2-x-mark-line]{display:block;position:absolute;top:2.3125em;width:2.9375em;height:.3125em;border-radius:.125em;background-color:#f27474}.swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=left]{left:1.0625em;transform:rotate(45deg)}.swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=right]{right:1em;transform:rotate(-45deg)}.swal2-icon.swal2-error.swal2-icon-show{animation:swal2-animate-error-icon .5s}.swal2-icon.swal2-error.swal2-icon-show .swal2-x-mark{animation:swal2-animate-error-x-mark .5s}.swal2-icon.swal2-warning{border-color:#facea8;color:#f8bb86}.swal2-icon.swal2-warning.swal2-icon-show{animation:swal2-animate-error-icon .5s}.swal2-icon.swal2-warning.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .5s}.swal2-icon.swal2-info{border-color:#9de0f6;color:#3fc3ee}.swal2-icon.swal2-info.swal2-icon-show{animation:swal2-animate-error-icon .5s}.swal2-icon.swal2-info.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .8s}.swal2-icon.swal2-question{border-color:#c9dae1;color:#87adbd}.swal2-icon.swal2-question.swal2-icon-show{animation:swal2-animate-error-icon .5s}.swal2-icon.swal2-question.swal2-icon-show .swal2-icon-content{animation:swal2-animate-question-mark .8s}.swal2-icon.swal2-success{border-color:#a5dc86;color:#a5dc86}.swal2-icon.swal2-success [class^=swal2-success-circular-line]{position:absolute;width:3.75em;height:7.5em;transform:rotate(45deg);border-radius:50%}.swal2-icon.swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.4375em;left:-2.0635em;transform:rotate(-45deg);transform-origin:3.75em 3.75em;border-radius:7.5em 0 0 7.5em}.swal2-icon.swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.6875em;left:1.875em;transform:rotate(-45deg);transform-origin:0 3.75em;border-radius:0 7.5em 7.5em 0}.swal2-icon.swal2-success .swal2-success-ring{position:absolute;z-index:2;top:-0.25em;left:-0.25em;box-sizing:content-box;width:100%;height:100%;border:.25em solid rgba(165,220,134,.3);border-radius:50%}.swal2-icon.swal2-success .swal2-success-fix{position:absolute;z-index:1;top:.5em;left:1.625em;width:.4375em;height:5.625em;transform:rotate(-45deg)}.swal2-icon.swal2-success [class^=swal2-success-line]{display:block;position:absolute;z-index:2;height:.3125em;border-radius:.125em;background-color:#a5dc86}.swal2-icon.swal2-success [class^=swal2-success-line][class$=tip]{top:2.875em;left:.8125em;width:1.5625em;transform:rotate(45deg)}.swal2-icon.swal2-success [class^=swal2-success-line][class$=long]{top:2.375em;right:.5em;width:2.9375em;transform:rotate(-45deg)}.swal2-icon.swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-animate-success-line-tip .75s}.swal2-icon.swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-animate-success-line-long .75s}.swal2-icon.swal2-success.swal2-icon-show .swal2-success-circular-line-right{animation:swal2-rotate-success-circular-line 4.25s ease-in}.swal2-progress-steps{flex-wrap:wrap;align-items:center;max-width:100%;margin:1.25em auto;padding:0;background:rgba(0,0,0,0);font-weight:600}.swal2-progress-steps li{display:inline-block;position:relative}.swal2-progress-steps .swal2-progress-step{z-index:20;flex-shrink:0;width:2em;height:2em;border-radius:2em;background:#2778c4;color:#fff;line-height:2em;text-align:center}.swal2-progress-steps .swal2-progress-step.swal2-active-progress-step{background:#2778c4}.swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step{background:#add8e6;color:#fff}.swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step-line{background:#add8e6}.swal2-progress-steps .swal2-progress-step-line{z-index:10;flex-shrink:0;width:2.5em;height:.4em;margin:0 -1px;background:#2778c4}[class^=swal2]{-webkit-tap-highlight-color:rgba(0,0,0,0)}.swal2-show{animation:swal2-show .3s}.swal2-hide{animation:swal2-hide .15s forwards}.swal2-noanimation{transition:none}.swal2-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}.swal2-rtl .swal2-close{margin-right:initial;margin-left:0}.swal2-rtl .swal2-timer-progress-bar{right:0;left:auto}@keyframes swal2-toast-show{0%{transform:translateY(-0.625em) rotateZ(2deg)}33%{transform:translateY(0) rotateZ(-2deg)}66%{transform:translateY(0.3125em) rotateZ(2deg)}100%{transform:translateY(0) rotateZ(0deg)}}@keyframes swal2-toast-hide{100%{transform:rotateZ(1deg);opacity:0}}@keyframes swal2-toast-animate-success-line-tip{0%{top:.5625em;left:.0625em;width:0}54%{top:.125em;left:.125em;width:0}70%{top:.625em;left:-0.25em;width:1.625em}84%{top:1.0625em;left:.75em;width:.5em}100%{top:1.125em;left:.1875em;width:.75em}}@keyframes swal2-toast-animate-success-line-long{0%{top:1.625em;right:1.375em;width:0}65%{top:1.25em;right:.9375em;width:0}84%{top:.9375em;right:0;width:1.125em}100%{top:.9375em;right:.1875em;width:1.375em}}@keyframes swal2-show{0%{transform:scale(0.7)}45%{transform:scale(1.05)}80%{transform:scale(0.95)}100%{transform:scale(1)}}@keyframes swal2-hide{0%{transform:scale(1);opacity:1}100%{transform:scale(0.5);opacity:0}}@keyframes swal2-animate-success-line-tip{0%{top:1.1875em;left:.0625em;width:0}54%{top:1.0625em;left:.125em;width:0}70%{top:2.1875em;left:-0.375em;width:3.125em}84%{top:3em;left:1.3125em;width:1.0625em}100%{top:2.8125em;left:.8125em;width:1.5625em}}@keyframes swal2-animate-success-line-long{0%{top:3.375em;right:2.875em;width:0}65%{top:3.375em;right:2.875em;width:0}84%{top:2.1875em;right:0;width:3.4375em}100%{top:2.375em;right:.5em;width:2.9375em}}@keyframes swal2-rotate-success-circular-line{0%{transform:rotate(-45deg)}5%{transform:rotate(-45deg)}12%{transform:rotate(-405deg)}100%{transform:rotate(-405deg)}}@keyframes swal2-animate-error-x-mark{0%{margin-top:1.625em;transform:scale(0.4);opacity:0}50%{margin-top:1.625em;transform:scale(0.4);opacity:0}80%{margin-top:-0.375em;transform:scale(1.15)}100%{margin-top:0;transform:scale(1);opacity:1}}@keyframes swal2-animate-error-icon{0%{transform:rotateX(100deg);opacity:0}100%{transform:rotateX(0deg);opacity:1}}@keyframes swal2-rotate-loading{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}@keyframes swal2-animate-question-mark{0%{transform:rotateY(-360deg)}100%{transform:rotateY(0)}}@keyframes swal2-animate-i-mark{0%{transform:rotateZ(45deg);opacity:0}25%{transform:rotateZ(-25deg);opacity:.4}50%{transform:rotateZ(15deg);opacity:.8}75%{transform:rotateZ(-5deg);opacity:1}100%{transform:rotateX(0);opacity:1}}body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown){overflow:hidden}body.swal2-height-auto{height:auto !important}body.swal2-no-backdrop .swal2-container{background-color:rgba(0,0,0,0) !important;pointer-events:none}body.swal2-no-backdrop .swal2-container .swal2-popup{pointer-events:all}body.swal2-no-backdrop .swal2-container .swal2-modal{box-shadow:0 0 10px rgba(0,0,0,.4)}@media print{body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown){overflow-y:scroll !important}body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown)>[aria-hidden=true]{display:none}body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown) .swal2-container{position:static !important}}body.swal2-toast-shown .swal2-container{box-sizing:border-box;width:360px;max-width:100%;background-color:rgba(0,0,0,0);pointer-events:none}body.swal2-toast-shown .swal2-container.swal2-top{inset:0 auto auto 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-top-end,body.swal2-toast-shown .swal2-container.swal2-top-right{inset:0 0 auto auto}body.swal2-toast-shown .swal2-container.swal2-top-start,body.swal2-toast-shown .swal2-container.swal2-top-left{inset:0 auto auto 0}body.swal2-toast-shown .swal2-container.swal2-center-start,body.swal2-toast-shown .swal2-container.swal2-center-left{inset:50% auto auto 0;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-center{inset:50% auto auto 50%;transform:translate(-50%, -50%)}body.swal2-toast-shown .swal2-container.swal2-center-end,body.swal2-toast-shown .swal2-container.swal2-center-right{inset:50% 0 auto auto;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-start,body.swal2-toast-shown .swal2-container.swal2-bottom-left{inset:auto auto 0 0}body.swal2-toast-shown .swal2-container.swal2-bottom{inset:auto auto 0 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-end,body.swal2-toast-shown .swal2-container.swal2-bottom-right{inset:auto 0 0 auto}"); -------------------------------------------------------------------------------- /frappe_paystack/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/templates/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/templates/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/templates/pages/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/tests/test_paystack_payment_log.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import importlib 3 | import pkgutil 4 | import frappe_paystack 5 | 6 | class TestFrappePaystackCode(unittest.TestCase): 7 | """ 8 | Sanity check: imports all modules in frappe_paystack to catch 9 | syntax errors, missing imports, or runtime errors on load. 10 | """ 11 | 12 | def test_import_all_modules(self): 13 | package = frappe_paystack 14 | errors = [] 15 | 16 | for loader, module_name, is_pkg in pkgutil.walk_packages(package.__path__, package.__name__ + "."): 17 | try: 18 | importlib.import_module(module_name) 19 | except Exception as e: 20 | errors.append(f"Failed to import {module_name}: {e}") 21 | 22 | if errors: 23 | self.fail("Errors found while importing frappe_paystack modules:\n" + "\n".join(errors)) 24 | -------------------------------------------------------------------------------- /frappe_paystack/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import base64, io, frappe, json, hmac, hashlib, requests 3 | from frappe import _ 4 | from typing import Any, Dict, Optional, Tuple 5 | from frappe.utils import flt, now_datetime 6 | from frappe.model.document import Document 7 | 8 | SUPPORTED_CURRENCIES = ["NGN", "USD", "GHS", "ZAR", "KES"] 9 | 10 | MINOR_FACTORS = { 11 | "NGN": 100, 12 | "USD": 100, 13 | "GHS": 100, 14 | "ZAR": 100, 15 | "KES": 100, 16 | } 17 | 18 | 19 | 20 | @frappe.whitelist() 21 | def get_customer_contact(customer): 22 | """ 23 | Get customer emails and phone numbers send back to sales invoice frontend 24 | """ 25 | contact_data = frappe._dict({}) 26 | if frappe.db.exists("Customer", {'name':customer}): 27 | customer_doc = frappe.get_doc("Customer", customer) 28 | if customer_doc.customer_primary_contact: 29 | contact = frappe.get_doc("Contact", customer_doc.customer_primary_contact) 30 | if contact.email_ids: 31 | contact_data.emails = [i.email_id for i in contact.email_ids] 32 | if contact.phone_nos: 33 | contact_data.phone_nos = [i.phone for i in contact.phone_nos] 34 | if not (contact_data.phone_nos or contact_data.email_id): 35 | return False 36 | return contact_data 37 | 38 | 39 | @frappe.whitelist() 40 | def get_customer_email(customer): 41 | return frappe.db.get_value("Customer", customer, "email_id") or "" 42 | 43 | 44 | 45 | def _get_company_row_settings(company: Optional[str]) -> Optional[Dict[str, Any]]: 46 | DOCTYPE = "Paystack Gateway Setting" 47 | try: 48 | meta = frappe.get_meta(DOCTYPE) 49 | except Exception: 50 | return None 51 | 52 | row = {} 53 | filters = {"enabled": 1} 54 | if company and meta.has_field("company"): 55 | filters["company"] = company 56 | if frappe.db.exists(DOCTYPE, filters): 57 | row = frappe.get_doc(DOCTYPE,filters) 58 | 59 | 60 | if not row or not row.secret_key: 61 | return None 62 | 63 | default_currency = "NGN" 64 | if company: 65 | try: 66 | cur = frappe.db.get_value("Company", company, "default_currency") 67 | if cur: 68 | default_currency = (cur or "NGN").upper() 69 | except Exception: 70 | pass 71 | 72 | return { 73 | "public_key": row.get("public_key"), 74 | "secret_key": row.get_password("secret_key"), 75 | "webhook_secret": row.get_password("secret_key"), 76 | "callback_url": row.get("callback_url"), 77 | "webhook_url": row.get("webhook_url"), 78 | "default_currency": default_currency, 79 | "enable_auto_conversion": False, 80 | "enable_fee_accounting": False, 81 | "paystack_fee_account": None, 82 | "wallet_clearing_account": None, 83 | "default_bank_account": None, 84 | } 85 | 86 | 87 | def resolve_paystack_settings(company: Optional[str]) -> Optional[Dict[str, Any]]: 88 | row = _get_company_row_settings(company) 89 | if row and row.get("secret_key"): 90 | return row 91 | return 92 | 93 | 94 | def is_paystack_enabled(company: Optional[str]) -> bool: 95 | return bool(resolve_paystack_settings(company)) 96 | 97 | def hmac_sha512(payload: bytes, secret: str) -> str: 98 | return hmac.new(secret.encode("utf-8"), payload, hashlib.sha512).hexdigest() 99 | 100 | def verify_signature(payload: bytes, signature: Optional[str], secret: str) -> bool: 101 | """ 102 | Compare Paystack 'x-paystack-signature' header with computed HMAC-SHA512. 103 | """ 104 | if not signature: 105 | return False 106 | expected = hmac_sha512(payload, secret) 107 | return hmac.compare_digest(expected, signature) 108 | 109 | 110 | def safe_json_dumps(obj: Any) -> str: 111 | try: 112 | return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), default=str) 113 | except Exception: 114 | return "{}" 115 | 116 | 117 | def normalize_currency(cur: Optional[str]) -> str: 118 | cur = (cur or "NGN").upper().strip() 119 | return cur if cur in SUPPORTED_CURRENCIES else "NGN" 120 | 121 | def to_minor_units(amount: float, currency: str) -> int: 122 | """ 123 | Convert a decimal amount to integer minor units for Paystack requests. 124 | """ 125 | currency = normalize_currency(currency) 126 | factor = MINOR_FACTORS.get(currency, 100) 127 | return int(round(flt(amount) * factor)) 128 | 129 | 130 | def from_minor_units(amount_minor: int, currency: str) -> float: 131 | currency = normalize_currency(currency) 132 | factor = MINOR_FACTORS.get(currency, 100) 133 | return flt(amount_minor) / factor 134 | 135 | 136 | def format_money(amount: float, currency: Optional[str]) -> str: 137 | return frappe.utils.fmt_money(flt(amount), currency=normalize_currency(currency)) 138 | 139 | 140 | def sanitize_reference(doctype: str, name: str, company: Optional[str]) -> str: 141 | """ 142 | Build a reference that encodes the doctype, docname, company. 143 | """ 144 | company = (company or "").replace(" ", "") 145 | return f"{doctype}-{name}-{company}" 146 | 147 | 148 | def parse_reference_company(reference: str) -> Optional[str]: 149 | """ 150 | Extract company from reference created by sanitize_reference(). 151 | """ 152 | try: 153 | return reference.split("-", 2)[2] 154 | except Exception: 155 | return None 156 | 157 | 158 | def get_company_currency(company: str) -> str: 159 | cur = frappe.db.get_value("Company", company, "default_currency") or "NGN" 160 | return normalize_currency(cur) 161 | 162 | 163 | def coalesce_currency(currency: Optional[str], company: Optional[str], settings: Optional[Dict[str, Any]] = None) -> str: 164 | """ 165 | Choose the best currency: explicit -> company -> settings -> NGN. 166 | """ 167 | if currency: 168 | return normalize_currency(currency) 169 | if company: 170 | try: 171 | return get_company_currency(company) 172 | except Exception: 173 | pass 174 | if settings and settings.get("default_currency"): 175 | return normalize_currency(settings["default_currency"]) 176 | return "NGN" 177 | 178 | 179 | def ensure_supported_currency(currency: str): 180 | """ 181 | Throw if currency is not supported by this integration. 182 | """ 183 | cur = normalize_currency(currency) 184 | if cur not in SUPPORTED_CURRENCIES: 185 | frappe.throw(f"Currency {currency} is not supported for Paystack in this app.") 186 | 187 | 188 | def clamp_amount_to_positive(amount: float) -> float: 189 | """ 190 | Force amounts to be >= 0 with proper rounding (ui/validation convenience). 191 | """ 192 | return max(0.0, round(flt(amount), 2)) 193 | 194 | 195 | def pick_company_for_doc(doc: Document) -> Optional[str]: 196 | """ 197 | Try to pull company from a document by most common attributes. 198 | """ 199 | for fn in ("company", "party_company", "owning_company"): 200 | if hasattr(doc, fn) and getattr(doc, fn): 201 | return getattr(doc, fn) 202 | # As a last resort, try Sales Invoice meta 203 | if doc.doctype == "Sales Invoice": 204 | return getattr(doc, "company", None) 205 | return None 206 | 207 | 208 | 209 | def get_paid_to_account(customer, company): 210 | accounts = [i.account for i in frappe.get_doc("Customer", customer).accounts if i.company==company] 211 | if accounts: 212 | return accounts[0] 213 | return None 214 | 215 | def get_gateway_secret(gateway): 216 | return frappe.get_doc("Paystack Gateway Setting", {"name":gateway, "enabled": 1}).get_password("secret_key") 217 | 218 | 219 | def validate_payment(doc): 220 | if is_paystack_enabled(doc.company): 221 | secret = resolve_paystack_settings(doc.company) 222 | if not secret:frappe.throw(f"Paystack is not enabled for company {doc.company}") 223 | url=f"https://api.paystack.co/transaction/verify/{doc.transaction_id}" 224 | req = requests.get(url, headers={"Authorization": f"Bearer {secret.get('secret_key')}"}, timeout=15) 225 | data = req.json() 226 | else: 227 | frappe.throw(f"Paystack is not enabled for company {doc.company}") 228 | 229 | return data 230 | -------------------------------------------------------------------------------- /frappe_paystack/www/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/www/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/www/my-payments/index.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "templates/web.html" %} 3 | {% block page_content %} 4 |

    My Outstanding Invoices

    5 | {% if not invoices %}

    No unpaid invoices

    {% else %} 6 | 7 | 8 | 9 | {% for inv in invoices %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% endfor %} 18 |
    InvoicePosting DateDue DateOutstandingAction
    {{ inv.name }}{{ inv.posting_date }}{{ inv.due_date }}{{ frappe.utils.fmt_money(inv.outstanding_amount, currency=inv.currency) }}
    {% endif %} 19 |
    20 |

    My Payment History

    21 | {% if not payments %}

    No past payments yet.

    {% else %} 22 | 23 | 24 | 25 | {% for log in payments %} 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 37 | 38 | {% endfor %} 39 |
    ReferenceInvoiceAmountCurrencyStatusLast UpdateReceipt
    {{ log.reference }}{{ log.linked_docname }}{{ frappe.utils.fmt_money(log.amount, currency=log.currency) }}{{ log.currency }}{{ log.status }}{{ log.modified }}{% if log.status in ["Processed","Completed"] %} 34 | Download 35 | {% endif %} 36 |
    {% endif %} 40 | 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /frappe_paystack/www/my-payments/index.py: -------------------------------------------------------------------------------- 1 | 2 | import frappe 3 | def get_context(context): 4 | if not frappe.session.user or frappe.session.user == "Guest": 5 | frappe.throw("You need to be logged in", frappe.PermissionError) 6 | customer = frappe.db.get_value("Contact", {"email_id": frappe.session.user}, "customer") 7 | invoices = frappe.get_all("Sales Invoice", 8 | filters={"customer": customer, "status": ["in", ["Unpaid","Partly Paid"]]}, 9 | fields=["name","posting_date","due_date","outstanding_amount","currency"]) 10 | payments = frappe.get_all("Paystack Payment Log", 11 | filters={"linked_doctype":"Sales Invoice"}, 12 | fields=["name","reference","linked_docname","amount","currency","status","modified"], 13 | order_by="modified desc", limit=20) 14 | context.invoices = invoices; context.payments = payments; context.customer = customer 15 | return context 16 | -------------------------------------------------------------------------------- /frappe_paystack/www/paystack-checkout/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/frappe_paystack/www/paystack-checkout/__init__.py -------------------------------------------------------------------------------- /frappe_paystack/www/paystack-checkout/index.html: -------------------------------------------------------------------------------- 1 | {% extends "templates/web.html" %} 2 | {% block title %}{{ _("Make Payment") }}{% endblock %} 3 | 4 | 5 | {% block page_content %} 6 |
    7 | {% if reference and doc %} 8 | {% if not doc.public_key %} 9 |
    10 | 25 |
    26 | {% elif not doc.status in ["Completed", "Processed"] and doc.order_status in ["Completed", "Closed'", "Paid"] %} 27 |
    28 | 43 |
    44 | {% elif doc.status in ["Completed", "Processed"] or doc.order_status in ["Completed", "Closed'", "Paid"]%} 45 |
    46 | 61 |
    62 | {% elif doc.status == "Failed" %} 63 |
    64 | 78 |
    79 | {% else %} 80 |
    81 | 82 |
    83 |
    84 |
    85 |
    86 | Summary 87 |
    88 |
      89 |
    • 90 | Payment Reference 91 | {{ reference }} 92 |
    • 93 |
    • 94 | Invoice/Order No. 95 | {{doc.order_no}} 96 |
    • 97 |
    • 98 | Order Amount 99 | {{doc.order_currency}} {{doc.grand_total}} 100 |
    • 101 |
    • 102 | Payment Amount 103 | {{doc.currency}} {{doc.payment_amount}} 104 |
    • 105 |
    106 |
    107 |
    108 |
    109 | 110 |
    111 |
    112 |
    113 | 114 |
    115 |
    116 | 117 |
    118 | {% endif %} 119 | {% else %} 120 |
    121 | 135 |
    136 | 137 | {% endif %} 138 |
    139 | {% endblock %} 140 | 141 | {% block script %} 142 | 143 | 144 | 145 | 149 | 150 | {% endblock %} 151 | -------------------------------------------------------------------------------- /frappe_paystack/www/paystack-checkout/index.py: -------------------------------------------------------------------------------- 1 | import frappe, json, requests 2 | 3 | PAYMENT_LOG = "Paystack Payment Log" 4 | 5 | def get_context(context): 6 | context.title = "Paystack Checkout" 7 | reference = frappe.form_dict.reference 8 | if not reference: 9 | context.reference = None 10 | else: 11 | if frappe.db.exists(PAYMENT_LOG, {"name": reference}): 12 | doc = frappe.get_doc(PAYMENT_LOG, reference) 13 | context.doc = doc.get_data() 14 | context.reference = reference 15 | else: 16 | context.reference = None 17 | 18 | return context 19 | 20 | 21 | @frappe.whitelist(allow_guest=True) 22 | def get_payment_request(reference_doctype, reference_docname): 23 | if not (reference_doctype and reference_docname): 24 | return {'error':"Invalid payment link."} 25 | payment_request = frappe.db.get_value( 26 | reference_doctype, { 27 | "name":reference_docname, 28 | "docstatus":1, 29 | "status":["=", "Requested"], 30 | "payment_request_type": "Inward" 31 | }, 32 | "*", as_dict=1 33 | ) 34 | if not payment_request: 35 | return {'error':"Invalid payment link."} 36 | if payment_request.status=='Paid': 37 | return {'error':"Payment has already been made."} 38 | public_key = frappe.db.get_value( 39 | "Paystack Gateway Setting", 40 | {'enabled':1,}, 41 | ["public_key"] 42 | ) 43 | if not public_key: 44 | return {'error':"Payment method is unavailable at the moment, please contact us directly.."} 45 | payment_request.public_key = public_key 46 | return payment_request 47 | 48 | @frappe.whitelist(allow_guest=True) 49 | def verify_transaction(): 50 | try: 51 | transaction = frappe.form_dict 52 | frappe.get_doc({ 53 | 'doctype':"Paystack Log", 54 | 'message':transaction.message, 55 | 'status':transaction.status, 56 | 'reference': transaction.reference, 57 | 'transaction': transaction.transaction, 58 | }).insert(ignore_permissions=1) 59 | except Exception as e: 60 | frappe.log_error(frappe.get_traceback(), 'Verify Transaction') 61 | 62 | 63 | 64 | @frappe.whitelist(allow_guest=True) 65 | def paystack_webhook(**kwargs): 66 | """ 67 | End point where payment gateway sends payment info. 68 | """ 69 | try: 70 | data = frappe._dict(frappe.form_dict.data) 71 | if not (frappe.db.exists("Payment Gateway Request", {"name":data.reference})): 72 | secret_key = frappe.get_doc( 73 | "Payment Gateway Integration Settings", 74 | {'enabled':1, 'gateway':'Paystack'} 75 | ).get_secret_key() 76 | headers = {"Authorization": f"Bearer {secret_key}"} 77 | req = requests.get( 78 | f"https://api.paystack.co/transaction/verify/{data.reference}", 79 | headers=headers, timeout=10 80 | ) 81 | if req.status_code in [200, 201]: 82 | response = frappe._dict(req.json()) 83 | data = frappe._dict(response.data) 84 | metadata = frappe._dict(data.metadata) 85 | frappe.get_doc({ 86 | 'doctype':"Payment Gateway Log", 87 | 'amount':data.amount/100, 88 | 'currency':data.currency, 89 | 'message':response.message, 90 | 'status':data.status, 91 | 'payment_gateway_request': metadata.log_id, 92 | 'reference': data.reference, 93 | 'reference_doctype': metadata.reference_doctype, 94 | 'reference_name': metadata.reference_name, 95 | 'transaction_id': data.id, 96 | 'data': response 97 | }).insert(ignore_permissions=True) 98 | else: 99 | # log error 100 | frappe.log_error(str(req.reason), 'Verify Transaction') 101 | except Exception as e: 102 | frappe.log_error(frappe.get_traceback(), 'Verify Transaction') 103 | 104 | -------------------------------------------------------------------------------- /img/completed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/img/completed.png -------------------------------------------------------------------------------- /img/gateway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/img/gateway.png -------------------------------------------------------------------------------- /img/payment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/img/payment.png -------------------------------------------------------------------------------- /img/payment_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/img/payment_button.png -------------------------------------------------------------------------------- /img/payment_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mymi14s/frappe_paystack/faa7390dd85ed873ff1ff518ee36f7ee382be5b1/img/payment_page.png -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "frappe_paystack" 3 | authors = [ 4 | { name = "Anthony Emmanuel", email = "mymi14s@hotmail.com"} 5 | ] 6 | description = "Paystack integration for Frappe/ERPNext" 7 | requires-python = ">=3.10" 8 | readme = "README.md" 9 | dynamic = ["version"] 10 | dependencies = [ 11 | # "frappe~=15.0.0" # Installed and managed by bench. 12 | ] 13 | 14 | [build-system] 15 | requires = ["flit_core >=3.4,<4"] 16 | build-backend = "flit_core.buildapi" 17 | 18 | # These dependencies are only installed when developer mode is enabled 19 | [tool.bench.dev-dependencies] 20 | # package_name = "~=1.1.0" 21 | --------------------------------------------------------------------------------