From c93b260937af921d2ab0ee731fe9d229a96d81bc Mon Sep 17 00:00:00 2001 From: MMG Date: Fri, 6 Feb 2026 11:36:42 +0000 Subject: [PATCH] Add PayDunya payment provider integration - Implemented PayDunya payment provider with necessary models, controllers, and views. - Added configuration files for Docker and Odoo setup. - Included .gitignore for Python and Odoo specific files. --- .gitignore | 140 ++++++++++++++ addons/payment_paydunya/__init__.py | 2 + addons/payment_paydunya/__manifest__.py | 17 ++ .../payment_paydunya/controllers/__init__.py | 1 + addons/payment_paydunya/controllers/main.py | 80 ++++++++ .../data/payment_method_data.xml | 32 ++++ .../data/payment_provider_data.xml | 8 + addons/payment_paydunya/models/__init__.py | 2 + .../models/payment_provider.py | 32 ++++ .../models/payment_transaction.py | 174 ++++++++++++++++++ .../views/payment_paydunya_templates.xml | 29 +++ .../views/payment_paydunya_views.xml | 21 +++ docker-compose.yml | 25 +++ odoo.conf | 7 + 14 files changed, 570 insertions(+) create mode 100644 .gitignore create mode 100644 addons/payment_paydunya/__init__.py create mode 100644 addons/payment_paydunya/__manifest__.py create mode 100644 addons/payment_paydunya/controllers/__init__.py create mode 100644 addons/payment_paydunya/controllers/main.py create mode 100644 addons/payment_paydunya/data/payment_method_data.xml create mode 100644 addons/payment_paydunya/data/payment_provider_data.xml create mode 100644 addons/payment_paydunya/models/__init__.py create mode 100644 addons/payment_paydunya/models/payment_provider.py create mode 100644 addons/payment_paydunya/models/payment_transaction.py create mode 100644 addons/payment_paydunya/views/payment_paydunya_templates.xml create mode 100644 addons/payment_paydunya/views/payment_paydunya_views.xml create mode 100644 docker-compose.yml create mode 100644 odoo.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bbf71e --- /dev/null +++ b/.gitignore @@ -0,0 +1,140 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE settings +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Odoo specific +data/odoo-db/ +*.pyc +*~ +.*.swp +addons/*/static/description/icon.png + +# Docker +.dockerignore diff --git a/addons/payment_paydunya/__init__.py b/addons/payment_paydunya/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/payment_paydunya/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/payment_paydunya/__manifest__.py b/addons/payment_paydunya/__manifest__.py new file mode 100644 index 0000000..e118a70 --- /dev/null +++ b/addons/payment_paydunya/__manifest__.py @@ -0,0 +1,17 @@ +{ + 'name': 'PayDunya Odoo', + 'version': '1.0.0', + 'summary': 'PayDunya payment provider', + 'description': 'Payment acquirer integration for PayDunya', + 'category': 'Accounting/Payment Providers', + 'author': 'MMG', + 'depends': ['payment'], + 'data': [ + 'data/payment_provider_data.xml', + 'data/payment_method_data.xml', + 'views/payment_paydunya_views.xml', + 'views/payment_paydunya_templates.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/addons/payment_paydunya/controllers/__init__.py b/addons/payment_paydunya/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/payment_paydunya/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/payment_paydunya/controllers/main.py b/addons/payment_paydunya/controllers/main.py new file mode 100644 index 0000000..d87aad4 --- /dev/null +++ b/addons/payment_paydunya/controllers/main.py @@ -0,0 +1,80 @@ +import logging +import requests +from odoo import http + +_logger = logging.getLogger(__name__) + + +class PaydunyaController(http.Controller): + @http.route('/payment/paydunya/return', type='http', auth='public', methods=['GET'], csrf=False) + def paydunya_return(self, **kwargs): + """Handle return from PayDunya after payment attempt.""" + token = kwargs.get('token') or http.request.params.get('token') + if not token: + return http.request.redirect('/') + + provider = http.request.env['payment.provider'].sudo().search([('code', '=', 'paydunya')], limit=1) + if not provider: + _logger.warning('PayDunya return called but no provider found') + return http.request.redirect('/') + + # Build check URL using the confirm endpoint + base = provider._get_paydunya_api_base() + confirm_url = base + '/checkout-invoice/confirm/{}'.format(token) + + headers = { + 'Content-Type': 'application/json', + 'PAYDUNYA-MASTER-KEY': provider.paydunya_master_key or '', + 'PAYDUNYA-PRIVATE-KEY': provider.paydunya_private_key or '', + 'PAYDUNYA-TOKEN': provider.paydunya_token or '' + } + + try: + # GET request to verify status + resp = requests.get(confirm_url, headers=headers, timeout=15) + data = resp.json() + _logger.info('PayDunya confirm response: %s', data) + except Exception as e: + _logger.exception('Error verifying PayDunya invoice: %s', e) + data = {'token': token, 'status': 'failed'} + + # Trigger transaction handling with the confirmed data + try: + tx_model = http.request.env['payment.transaction'].sudo() + handled = tx_model._handle_notification_data(data) + if handled: + return http.request.redirect('/payment/process') + except Exception: + _logger.exception('Error handling PayDunya notification data') + + return http.request.redirect('/') + + @http.route('/payment/paydunya/cancel', type='http', auth='public', methods=['GET'], csrf=False) + def paydunya_cancel(self, **kwargs): + """Handle cancellation from PayDunya.""" + token = kwargs.get('token') or http.request.params.get('token') + _logger.info('PayDunya payment cancelled: token=%s', token) + return http.request.redirect('/shop/cart') + + @http.route('/payment/paydunya/callback', type='http', auth='public', methods=['POST'], csrf=False) + def paydunya_callback(self, **kwargs): + """Handle IPN callback from PayDunya. + + PayDunya sends data as application/x-www-form-urlencoded with 'data' key containing JSON. + """ + try: + # The 'data' parameter contains the JSON-encoded transaction info + import json + data_str = http.request.params.get('data') + if data_str: + data = json.loads(data_str) + _logger.info('PayDunya IPN callback received: %s', data) + + tx_model = http.request.env['payment.transaction'].sudo() + tx_model._handle_notification_data(data) + except Exception: + _logger.exception('Error handling PayDunya callback') + + # Always return 200 OK to PayDunya + return 'OK' + diff --git a/addons/payment_paydunya/data/payment_method_data.xml b/addons/payment_paydunya/data/payment_method_data.xml new file mode 100644 index 0000000..320c7eb --- /dev/null +++ b/addons/payment_paydunya/data/payment_method_data.xml @@ -0,0 +1,32 @@ + + + + + + + Carte bancaire + card + + + + + + Wave + wave-senegal + + + + + Orange Money + orange-money-senegal + + + + + + + \ No newline at end of file diff --git a/addons/payment_paydunya/data/payment_provider_data.xml b/addons/payment_paydunya/data/payment_provider_data.xml new file mode 100644 index 0000000..1d28e78 --- /dev/null +++ b/addons/payment_paydunya/data/payment_provider_data.xml @@ -0,0 +1,8 @@ + + + + PayDunya + paydunya + 10 + + diff --git a/addons/payment_paydunya/models/__init__.py b/addons/payment_paydunya/models/__init__.py new file mode 100644 index 0000000..9afbccd --- /dev/null +++ b/addons/payment_paydunya/models/__init__.py @@ -0,0 +1,2 @@ +from . import payment_provider +from . import payment_transaction diff --git a/addons/payment_paydunya/models/payment_provider.py b/addons/payment_paydunya/models/payment_provider.py new file mode 100644 index 0000000..68d9399 --- /dev/null +++ b/addons/payment_paydunya/models/payment_provider.py @@ -0,0 +1,32 @@ +import logging +from odoo import models, fields + +_logger = logging.getLogger(__name__) + + +class PaymentProvider(models.Model): + _inherit = 'payment.provider' + + # Override code field to add paydunya option + code = fields.Selection( + selection_add=[('paydunya', 'PayDunya')], + ondelete={'paydunya': 'cascade'} + ) + + # PayDunya specific credentials + paydunya_master_key = fields.Char('PayDunya Master Key') + paydunya_public_key = fields.Char('PayDunya Public Key') + paydunya_private_key = fields.Char('PayDunya Private Key') + paydunya_token = fields.Char('PayDunya Token') + environment = fields.Selection([ + ('sandbox', 'Sandbox'), + ('production', 'Production') + ], string='Environment', default='sandbox') + + def _get_paydunya_api_base(self): + """Return the correct API base URL for PayDunya based on environment.""" + self.ensure_one() + if self.environment == 'production': + return 'https://app.paydunya.com/api/v1' + return 'https://app.paydunya.com/sandbox-api/v1' + diff --git a/addons/payment_paydunya/models/payment_transaction.py b/addons/payment_paydunya/models/payment_transaction.py new file mode 100644 index 0000000..f8082de --- /dev/null +++ b/addons/payment_paydunya/models/payment_transaction.py @@ -0,0 +1,174 @@ +import logging +import requests +import hashlib +from odoo import models + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + def _get_specific_rendering_values(self, **kwargs): + """Create invoice on PayDunya and return rendering values for redirection.""" + self.ensure_one() + provider = False + # try common fields used across Odoo versions + provider = getattr(self, 'provider_id', False) or getattr(self, 'acquirer_id', False) + if not provider: + provider = self.env['payment.provider'].search([('code', '=', 'paydunya')], limit=1) + if not provider: + return {} + + base = provider._get_paydunya_api_base() + create_url = base + '/checkout-invoice/create' + + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + return_url = base_url + '/payment/paydunya/return' + cancel_url = base_url + '/payment/paydunya/cancel' + callback_url = base_url + '/payment/paydunya/callback' + + # Build payload according to PayDunya documentation + # Required: invoice.total_amount, store.name + payload = { + 'invoice': { + 'total_amount': int(self.amount), + 'description': self.reference or self.name or 'Payment', + }, + 'store': { + 'name': self.env.company.name or 'Store', + }, + 'actions': { + 'return_url': return_url, + 'cancel_url': cancel_url, + 'callback_url': callback_url, + } + } + + # Add customer info if available + if self.partner_id: + payload['invoice']['customer'] = { + 'name': self.partner_id.name or '', + 'email': self.partner_id.email or '', + 'phone': self.partner_id.phone or '', + } + + # Authentication headers + headers = { + 'Content-Type': 'application/json', + 'PAYDUNYA-MASTER-KEY': provider.paydunya_master_key or '', + 'PAYDUNYA-PRIVATE-KEY': provider.paydunya_private_key or '', + 'PAYDUNYA-TOKEN': provider.paydunya_token or '' + } + + try: + resp = requests.post(create_url, json=payload, headers=headers, timeout=15) + data = resp.json() + + # Check response_code (success = '00') + response_code = data.get('response_code') + if response_code != '00': + _logger.warning('PayDunya API error: %s - %s', response_code, data.get('response_text')) + return {} + + # Extract token from response + token = data.get('token') + # response_text is the checkout URL + redirect_url = data.get('response_text') + + if token: + # store reference to match notifications + self.acquirer_reference = token + self._cr.commit() + _logger.info('PayDunya invoice created: token=%s, url=%s', token, redirect_url) + # Return the template name and rendering values expected by Odoo + return { + 'rendering_template': 'payment_paydunya.paydunya_redirect_form', + 'rendering_values': { + 'paydunya_token': token, + 'redirect_url': redirect_url, + } + } + else: + _logger.warning('PayDunya: no token in response: %s', data) + except Exception as e: + _logger.exception('Error creating PayDunya invoice: %s', e) + + return {} + + def _get_tx_from_notification(self, data): + """Find the transaction corresponding to a PayDunya notification payload.""" + # Extract invoice data if wrapped + if isinstance(data, dict) and 'data' in data: + invoice_data = data['data'] + else: + invoice_data = data + + token = invoice_data.get('invoice', {}).get('token') or invoice_data.get('token') + if not token: + return None + tx = self.search([('acquirer_reference', '=', token)], limit=1) + return tx or None + + def _handle_notification_data(self, data): + """Handle PayDunya notification payload and update transaction state. + + Validates the hash according to PayDunya documentation (SHA-512 of MASTER-KEY). + PayDunya sends data as application/x-www-form-urlencoded with key 'data' containing JSON. + """ + # Extract the actual data if wrapped + if isinstance(data, dict) and 'data' in data: + invoice_data = data['data'] + else: + invoice_data = data + + # Verify hash to ensure the callback is from PayDunya + provided_hash = invoice_data.get('hash') + if provided_hash: + provider = self.env['payment.provider'].search([('code', '=', 'paydunya')], limit=1) + if provider and provider.paydunya_master_key: + expected_hash = hashlib.sha512( + provider.paydunya_master_key.encode() + ).hexdigest() + if provided_hash != expected_hash: + _logger.warning('PayDunya: Hash mismatch! Possible security issue. Expected %s, got %s', + expected_hash, provided_hash) + return False + + tx = self._get_tx_from_notification(invoice_data) + if not tx: + _logger.warning('PayDunya: no transaction found for notification: %s', invoice_data) + return False + + # Extract status (valid values: pending, completed, cancelled, failed) + status = invoice_data.get('status') or ( + invoice_data.get('invoice', {}).get('status') + ) + + if not status: + _logger.warning('PayDunya: notification without status: %s', invoice_data) + return False + + status = str(status).lower() + if status == 'completed': + if hasattr(tx, '_set_transaction_done'): + tx._set_transaction_done() + else: + tx.state = 'done' + _logger.info('PayDunya: Transaction %s marked as done', tx.reference) + return True + if status in ('cancelled', 'failed'): + if hasattr(tx, '_set_transaction_cancel'): + tx._set_transaction_cancel() + else: + tx.state = 'cancel' + _logger.info('PayDunya: Transaction %s marked as cancelled/failed', tx.reference) + return True + + # fallback: mark as pending + if hasattr(tx, '_set_transaction_pending'): + tx._set_transaction_pending() + else: + tx.state = 'pending' + _logger.info('PayDunya: Transaction %s marked as pending', tx.reference) + return True diff --git a/addons/payment_paydunya/views/payment_paydunya_templates.xml b/addons/payment_paydunya/views/payment_paydunya_templates.xml new file mode 100644 index 0000000..9970859 --- /dev/null +++ b/addons/payment_paydunya/views/payment_paydunya_templates.xml @@ -0,0 +1,29 @@ + + + + \ No newline at end of file diff --git a/addons/payment_paydunya/views/payment_paydunya_views.xml b/addons/payment_paydunya/views/payment_paydunya_views.xml new file mode 100644 index 0000000..cdfbdb3 --- /dev/null +++ b/addons/payment_paydunya/views/payment_paydunya_views.xml @@ -0,0 +1,21 @@ + + + + + payment.provider.paydunya.form + payment.provider + + + + +

PayDunya Credentials

+ + + + + +
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9b52f14 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + web: + image: odoo:18.0 + depends_on: + - db + ports: + - "9000:8069" + volumes: + - ./addons:/mnt/extra-addons + - ./odoo.conf:/etc/odoo/odoo.conf + environment: + - HOST=db + - USER=odoo + - PASSWORD=odoo + + db: + image: postgres:15 + environment: + - POSTGRES_DB=postgres + - POSTGRES_PASSWORD=odoo + - POSTGRES_USER=odoo + ports: + - "6432:5432" + volumes: + - ./data/odoo-db:/var/lib/postgresql/data \ No newline at end of file diff --git a/odoo.conf b/odoo.conf new file mode 100644 index 0000000..d71c3e9 --- /dev/null +++ b/odoo.conf @@ -0,0 +1,7 @@ +[options] +addons_path = /mnt/extra-addons,/usr/lib/python3/dist-packages/odoo/addons +db_host = db +db_port = 5432 +db_user = odoo +db_password = odoo +admin_passwd = passer \ No newline at end of file