217 lines
8.4 KiB
Python
217 lines
8.4 KiB
Python
import logging
|
|
import requests
|
|
import hashlib
|
|
from odoo import models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PaymentTransaction(models.Model):
|
|
_inherit = 'payment.transaction'
|
|
|
|
def _get_paydunya_channel(self, processing_values):
|
|
"""Return the PayDunya channel to force based on selected payment method."""
|
|
self.ensure_one()
|
|
|
|
selected_code = (
|
|
processing_values.get('payment_method_code')
|
|
or processing_values.get('payment_method')
|
|
or getattr(getattr(self, 'payment_method_id', False), 'code', False)
|
|
)
|
|
|
|
# Odoo checkout often sends only payment_method_id; resolve it to code.
|
|
if not selected_code:
|
|
pm_id = processing_values.get('payment_method_id')
|
|
if pm_id:
|
|
try:
|
|
payment_method = self.env['payment.method'].browse(int(pm_id))
|
|
if payment_method.exists():
|
|
selected_code = payment_method.code
|
|
except (TypeError, ValueError):
|
|
selected_code = None
|
|
|
|
if not selected_code:
|
|
return None
|
|
|
|
channel_map = {
|
|
'wave-senegal': 'wave-senegal',
|
|
'orange-money-senegal': 'orange-money-senegal',
|
|
'om': 'orange-money-senegal',
|
|
'orange-money': 'orange-money-senegal',
|
|
'wave': 'wave-senegal',
|
|
'card': 'card',
|
|
}
|
|
return channel_map.get(str(selected_code).lower())
|
|
|
|
def _get_specific_rendering_values(self, processing_values):
|
|
"""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 '',
|
|
}
|
|
|
|
channel = self._get_paydunya_channel(processing_values or {})
|
|
if channel:
|
|
# Force a single payment channel so PayDunya redirects to the selected operator flow.
|
|
payload['invoice']['channels'] = [channel]
|
|
_logger.info(
|
|
'PayDunya checkout channel: selected=%s resolved=%s',
|
|
processing_values.get('payment_method_code')
|
|
or processing_values.get('payment_method')
|
|
or processing_values.get('payment_method_id'),
|
|
channel,
|
|
)
|
|
|
|
# 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.provider_reference = token
|
|
_logger.info('PayDunya invoice created: token=%s, url=%s', token, redirect_url)
|
|
# Return the rendering values expected by the redirect form template.
|
|
return {
|
|
'api_url': redirect_url,
|
|
'token': token,
|
|
}
|
|
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([('provider_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
|