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.
This commit is contained in:
MMG
2026-02-06 11:36:42 +00:00
commit c93b260937
14 changed files with 570 additions and 0 deletions

140
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@@ -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,
}

View File

@@ -0,0 +1 @@
from . import main

View File

@@ -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'

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Payment methods for PayDunya provider -->
<!-- Carte bancaire / Bank Card -->
<record id="payment_method_paydunya_card" model="payment.method">
<field name="name">Carte bancaire</field>
<field name="code">card</field>
<field name="primary_payment_method_id" ref="payment.payment_method_card"/>
</record>
<!-- Wave -->
<record id="payment_method_paydunya_wave" model="payment.method">
<field name="name">Wave</field>
<field name="code">wave-senegal</field>
</record>
<!-- Orange Money / OM -->
<record id="payment_method_paydunya_om" model="payment.method">
<field name="name">Orange Money</field>
<field name="code">orange-money-senegal</field>
</record>
<!-- Link methods to PayDunya provider -->
<record id="payment_provider_paydunya" model="payment.provider">
<field name="payment_method_ids" eval="[(6, 0, [
ref('payment_method_paydunya_card'),
ref('payment_method_paydunya_wave'),
ref('payment_method_paydunya_om')
])]"/>
</record>
</odoo>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_provider_paydunya" model="payment.provider">
<field name="name">PayDunya</field>
<field name="code">paydunya</field>
<field name="sequence">10</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import payment_provider
from . import payment_transaction

View File

@@ -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'

View File

@@ -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

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="paydunya_redirect_form">
<t t-call="website.layout">
<div class="container my-5">
<div class="oe_paydunya_redirect">
<h3>Redirection vers PayDunya...</h3>
<form id="paydunya_redirect_form" t-att-action="redirect_url" method="post">
<input type="hidden" name="token" t-att-value="paydunya_token"/>
<noscript>
<button type="submit" class="btn btn-primary">Payer</button>
</noscript>
</form>
<t t-if="redirect_url">
<script type="text/javascript">
(function () {
const f = document.getElementById('paydunya_redirect_form');
if (f) { f.submit(); }
})();
</script>
</t>
</div>
</div>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form view for PayDunya payment provider -->
<record id="payment_provider_paydunya_form" model="ir.ui.view">
<field name="name">payment.provider.paydunya.form</field>
<field name="model">payment.provider</field>
<field name="inherit_id" ref="payment.payment_provider_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='credentials']" position="inside">
<group id="paydunya_credentials" invisible='code != "paydunya"'>
<h2>PayDunya Credentials</h2>
<field name="environment"/>
<field name="paydunya_master_key" password="True"/>
<field name="paydunya_public_key"/>
<field name="paydunya_private_key" password="True"/>
<field name="paydunya_token" password="True"/>
</group>
</xpath>
</field>
</record>
</odoo>

25
docker-compose.yml Normal file
View File

@@ -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

7
odoo.conf Normal file
View File

@@ -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