fix(payment_paydunya): fiabilise retour PayDunya et post-traitement paiement #3
@@ -9,7 +9,7 @@ _logger = logging.getLogger(__name__)
|
|||||||
class PaydunyaController(http.Controller):
|
class PaydunyaController(http.Controller):
|
||||||
@http.route('/payment/paydunya/return', type='http', auth='public', methods=['GET'], csrf=False)
|
@http.route('/payment/paydunya/return', type='http', auth='public', methods=['GET'], csrf=False)
|
||||||
def paydunya_return(self, **kwargs):
|
def paydunya_return(self, **kwargs):
|
||||||
"""Handle return from PayDunya after payment attempt."""
|
"""Traite le retour utilisateur PayDunya après tentative de paiement."""
|
||||||
token = kwargs.get('token') or http.request.params.get('token')
|
token = kwargs.get('token') or http.request.params.get('token')
|
||||||
tx_id = kwargs.get('tx_id') or http.request.params.get('tx_id')
|
tx_id = kwargs.get('tx_id') or http.request.params.get('tx_id')
|
||||||
sale_order_id = kwargs.get('sale_order_id') or http.request.params.get('sale_order_id')
|
sale_order_id = kwargs.get('sale_order_id') or http.request.params.get('sale_order_id')
|
||||||
@@ -29,7 +29,7 @@ class PaydunyaController(http.Controller):
|
|||||||
|
|
||||||
data = {}
|
data = {}
|
||||||
if token and provider:
|
if token and provider:
|
||||||
# Build check URL using the confirm endpoint
|
# Construit l'URL de vérification via l'endpoint de confirmation.
|
||||||
base = provider._get_paydunya_api_base()
|
base = provider._get_paydunya_api_base()
|
||||||
confirm_url = base + '/checkout-invoice/confirm/{}'.format(token)
|
confirm_url = base + '/checkout-invoice/confirm/{}'.format(token)
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class PaydunyaController(http.Controller):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# GET request to verify status
|
# Vérifie le statut de paiement côté PayDunya.
|
||||||
resp = requests.get(confirm_url, headers=headers, timeout=15)
|
resp = requests.get(confirm_url, headers=headers, timeout=15)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
_logger.info('PayDunya confirm response: %s', data)
|
_logger.info('PayDunya confirm response: %s', data)
|
||||||
@@ -49,12 +49,12 @@ class PaydunyaController(http.Controller):
|
|||||||
_logger.exception('Error verifying PayDunya invoice: %s', e)
|
_logger.exception('Error verifying PayDunya invoice: %s', e)
|
||||||
data = {'token': token, 'status': 'failed', 'sale_order_id': sale_order_id, 'tx_id': tx_id}
|
data = {'token': token, 'status': 'failed', 'sale_order_id': sale_order_id, 'tx_id': tx_id}
|
||||||
elif tx:
|
elif tx:
|
||||||
# Fallback when PayDunya did not return token: rely on tx current state.
|
# Fallback: si PayDunya ne renvoie pas de token, on s'appuie sur la transaction.
|
||||||
data = {'tx_id': tx.id, 'sale_order_id': sale_order_id}
|
data = {'tx_id': tx.id, 'sale_order_id': sale_order_id}
|
||||||
else:
|
else:
|
||||||
return http.request.redirect('/')
|
return http.request.redirect('/')
|
||||||
|
|
||||||
# Trigger transaction handling with the confirmed data
|
# Déclenche la mise à jour de la transaction avec les données confirmées.
|
||||||
try:
|
try:
|
||||||
handled = tx_model._handle_notification_data(data)
|
handled = tx_model._handle_notification_data(data)
|
||||||
tx = tx or tx_model._get_tx_from_notification(data)
|
tx = tx or tx_model._get_tx_from_notification(data)
|
||||||
@@ -73,16 +73,17 @@ class PaydunyaController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/payment/paydunya/cancel', type='http', auth='public', methods=['GET'], csrf=False)
|
@http.route('/payment/paydunya/cancel', type='http', auth='public', methods=['GET'], csrf=False)
|
||||||
def paydunya_cancel(self, **kwargs):
|
def paydunya_cancel(self, **kwargs):
|
||||||
"""Handle cancellation from PayDunya."""
|
"""Traite l'annulation de paiement renvoyée par PayDunya."""
|
||||||
token = kwargs.get('token') or http.request.params.get('token')
|
token = kwargs.get('token') or http.request.params.get('token')
|
||||||
_logger.info('PayDunya payment cancelled: token=%s', token)
|
_logger.info('PayDunya payment cancelled: token=%s', token)
|
||||||
return http.request.redirect('/shop/cart')
|
return http.request.redirect('/shop/cart')
|
||||||
|
|
||||||
@http.route('/payment/paydunya/callback', type='http', auth='public', methods=['GET', 'POST'], csrf=False)
|
@http.route('/payment/paydunya/callback', type='http', auth='public', methods=['GET', 'POST'], csrf=False)
|
||||||
def paydunya_callback(self, **kwargs):
|
def paydunya_callback(self, **kwargs):
|
||||||
"""Handle IPN callback from PayDunya.
|
"""Traite le callback IPN envoyé par PayDunya.
|
||||||
|
|
||||||
PayDunya sends data as application/x-www-form-urlencoded with 'data' key containing JSON.
|
PayDunya peut envoyer des données en `application/x-www-form-urlencoded`
|
||||||
|
dans une clé `data` contenant du JSON.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
payload = {}
|
payload = {}
|
||||||
@@ -105,5 +106,5 @@ class PaydunyaController(http.Controller):
|
|||||||
except Exception:
|
except Exception:
|
||||||
_logger.exception('Error handling PayDunya callback')
|
_logger.exception('Error handling PayDunya callback')
|
||||||
|
|
||||||
# Always return 200 OK to PayDunya
|
# Retourne toujours HTTP 200 à PayDunya.
|
||||||
return 'OK'
|
return 'OK'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PaymentTransaction(models.Model):
|
|||||||
_inherit = 'payment.transaction'
|
_inherit = 'payment.transaction'
|
||||||
|
|
||||||
def _paydunya_assign_transaction_payment_method_line(self):
|
def _paydunya_assign_transaction_payment_method_line(self):
|
||||||
"""Ensure transaction has a valid journal/payment method line for account payment."""
|
"""Assigne un journal et une méthode de paiement valides à la transaction."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if 'payment_method_line_id' not in self._fields:
|
if 'payment_method_line_id' not in self._fields:
|
||||||
return False
|
return False
|
||||||
@@ -39,7 +39,7 @@ class PaymentTransaction(models.Model):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _paydunya_extract_sale_order_id(self, payload):
|
def _paydunya_extract_sale_order_id(self, payload):
|
||||||
"""Extract a sale order id from return/callback payload."""
|
"""Extrait l'ID de commande depuis la charge utile retour/callback."""
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ class PaymentTransaction(models.Model):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _paydunya_extract_tx_id(self, payload):
|
def _paydunya_extract_tx_id(self, payload):
|
||||||
"""Extract an Odoo transaction id from return/callback payload."""
|
"""Extrait l'ID de transaction Odoo depuis la charge utile retour/callback."""
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ class PaymentTransaction(models.Model):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _paydunya_get_related_sale_order(self, sale_order_id=None):
|
def _paydunya_get_related_sale_order(self, sale_order_id=None):
|
||||||
"""Locate the sale order linked to this transaction."""
|
"""Retrouve la commande liée à la transaction."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if 'sale.order' not in self.env:
|
if 'sale.order' not in self.env:
|
||||||
return False
|
return False
|
||||||
@@ -114,7 +114,7 @@ class PaymentTransaction(models.Model):
|
|||||||
return self.env['sale.order']
|
return self.env['sale.order']
|
||||||
|
|
||||||
def _paydunya_finalize_sale_order(self, sale_order_id=None):
|
def _paydunya_finalize_sale_order(self, sale_order_id=None):
|
||||||
"""Confirm sale order and create/post invoice when transaction is paid."""
|
"""Confirme la commande et crée/poste la facture quand le paiement est validé."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if 'sale.order' not in self.env:
|
if 'sale.order' not in self.env:
|
||||||
return False
|
return False
|
||||||
@@ -145,8 +145,8 @@ class PaymentTransaction(models.Model):
|
|||||||
'payment_date': fields.Date.context_today(self),
|
'payment_date': fields.Date.context_today(self),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Force a valid journal/method line to avoid
|
# Force un journal + une méthode valides pour éviter
|
||||||
# "Please define a payment method line on your payment."
|
# l'erreur "Please define a payment method line on your payment."
|
||||||
if not wizard.journal_id:
|
if not wizard.journal_id:
|
||||||
company = posted_invoices[:1].company_id
|
company = posted_invoices[:1].company_id
|
||||||
journal = self.env['account.journal'].search([
|
journal = self.env['account.journal'].search([
|
||||||
@@ -180,12 +180,12 @@ class PaymentTransaction(models.Model):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _normalize_paydunya_notification_data(self, data):
|
def _normalize_paydunya_notification_data(self, data):
|
||||||
"""Normalize PayDunya notification payload across callback and confirm formats."""
|
"""Normalise les payloads PayDunya (callback/IPN et confirmation)."""
|
||||||
payload = data
|
payload = data
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Callback can send {"data": "<json>"} or {"data": {...}}
|
# Le callback peut envoyer {"data": "<json>"} ou {"data": {...}}.
|
||||||
if 'data' in payload:
|
if 'data' in payload:
|
||||||
wrapped = payload.get('data')
|
wrapped = payload.get('data')
|
||||||
if isinstance(wrapped, str):
|
if isinstance(wrapped, str):
|
||||||
@@ -196,14 +196,14 @@ class PaymentTransaction(models.Model):
|
|||||||
elif isinstance(wrapped, dict):
|
elif isinstance(wrapped, dict):
|
||||||
payload = wrapped
|
payload = wrapped
|
||||||
|
|
||||||
# Confirm endpoint can send {"response_data": {...}}
|
# L'endpoint de confirmation peut envoyer {"response_data": {...}}.
|
||||||
if isinstance(payload, dict) and isinstance(payload.get('response_data'), dict):
|
if isinstance(payload, dict) and isinstance(payload.get('response_data'), dict):
|
||||||
payload = payload['response_data']
|
payload = payload['response_data']
|
||||||
|
|
||||||
return payload if isinstance(payload, dict) else {}
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
def _get_paydunya_channel(self, processing_values):
|
def _get_paydunya_channel(self, processing_values):
|
||||||
"""Return the PayDunya channel to force based on selected payment method."""
|
"""Détermine le canal PayDunya à forcer selon le moyen de paiement choisi."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
||||||
selected_code = (
|
selected_code = (
|
||||||
@@ -212,7 +212,7 @@ class PaymentTransaction(models.Model):
|
|||||||
or getattr(getattr(self, 'payment_method_id', False), 'code', False)
|
or getattr(getattr(self, 'payment_method_id', False), 'code', False)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Odoo checkout often sends only payment_method_id; resolve it to code.
|
# Le checkout Odoo envoie souvent seulement `payment_method_id`.
|
||||||
if not selected_code:
|
if not selected_code:
|
||||||
pm_id = processing_values.get('payment_method_id')
|
pm_id = processing_values.get('payment_method_id')
|
||||||
if pm_id:
|
if pm_id:
|
||||||
@@ -237,10 +237,10 @@ class PaymentTransaction(models.Model):
|
|||||||
return channel_map.get(str(selected_code).lower())
|
return channel_map.get(str(selected_code).lower())
|
||||||
|
|
||||||
def _get_specific_rendering_values(self, processing_values):
|
def _get_specific_rendering_values(self, processing_values):
|
||||||
"""Create invoice on PayDunya and return rendering values for redirection."""
|
"""Crée la facture côté PayDunya et renvoie les données de redirection."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
provider = False
|
provider = False
|
||||||
# try common fields used across Odoo versions
|
# Compatibilité entre versions Odoo pour récupérer le provider.
|
||||||
provider = getattr(self, 'provider_id', False) or getattr(self, 'acquirer_id', False)
|
provider = getattr(self, 'provider_id', False) or getattr(self, 'acquirer_id', False)
|
||||||
if not provider:
|
if not provider:
|
||||||
provider = self.env['payment.provider'].search([('code', '=', 'paydunya')], limit=1)
|
provider = self.env['payment.provider'].search([('code', '=', 'paydunya')], limit=1)
|
||||||
@@ -260,8 +260,8 @@ class PaymentTransaction(models.Model):
|
|||||||
cancel_url = base_url + '/payment/paydunya/cancel' + query
|
cancel_url = base_url + '/payment/paydunya/cancel' + query
|
||||||
callback_url = base_url + '/payment/paydunya/callback' + query
|
callback_url = base_url + '/payment/paydunya/callback' + query
|
||||||
|
|
||||||
# Build payload according to PayDunya documentation
|
# Construit le payload conformément à la documentation PayDunya.
|
||||||
# Required: invoice.total_amount, store.name
|
# Champs requis: invoice.total_amount, store.name.
|
||||||
payload = {
|
payload = {
|
||||||
'invoice': {
|
'invoice': {
|
||||||
'total_amount': int(self.amount),
|
'total_amount': int(self.amount),
|
||||||
@@ -282,7 +282,7 @@ class PaymentTransaction(models.Model):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add customer info if available
|
# Ajoute les informations client si disponibles.
|
||||||
if self.partner_id:
|
if self.partner_id:
|
||||||
payload['invoice']['customer'] = {
|
payload['invoice']['customer'] = {
|
||||||
'name': self.partner_id.name or '',
|
'name': self.partner_id.name or '',
|
||||||
@@ -292,7 +292,7 @@ class PaymentTransaction(models.Model):
|
|||||||
|
|
||||||
channel = self._get_paydunya_channel(processing_values or {})
|
channel = self._get_paydunya_channel(processing_values or {})
|
||||||
if channel:
|
if channel:
|
||||||
# Force a single payment channel so PayDunya redirects to the selected operator flow.
|
# Force un seul canal pour rediriger vers l'opérateur sélectionné.
|
||||||
payload['invoice']['channels'] = [channel]
|
payload['invoice']['channels'] = [channel]
|
||||||
_logger.info(
|
_logger.info(
|
||||||
'PayDunya checkout channel: selected=%s resolved=%s',
|
'PayDunya checkout channel: selected=%s resolved=%s',
|
||||||
@@ -302,7 +302,7 @@ class PaymentTransaction(models.Model):
|
|||||||
channel,
|
channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Authentication headers
|
# En-têtes d'authentification API PayDunya.
|
||||||
headers = {
|
headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'PAYDUNYA-MASTER-KEY': provider.paydunya_master_key or '',
|
'PAYDUNYA-MASTER-KEY': provider.paydunya_master_key or '',
|
||||||
@@ -314,22 +314,22 @@ class PaymentTransaction(models.Model):
|
|||||||
resp = requests.post(create_url, json=payload, headers=headers, timeout=15)
|
resp = requests.post(create_url, json=payload, headers=headers, timeout=15)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
# Check response_code (success = '00')
|
# Vérifie le code de réponse (succès = '00').
|
||||||
response_code = data.get('response_code')
|
response_code = data.get('response_code')
|
||||||
if response_code != '00':
|
if response_code != '00':
|
||||||
_logger.warning('PayDunya API error: %s - %s', response_code, data.get('response_text'))
|
_logger.warning('PayDunya API error: %s - %s', response_code, data.get('response_text'))
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Extract token from response
|
# Extrait le token de la réponse.
|
||||||
token = data.get('token')
|
token = data.get('token')
|
||||||
# response_text is the checkout URL
|
# response_text contient l'URL de checkout.
|
||||||
redirect_url = data.get('response_text')
|
redirect_url = data.get('response_text')
|
||||||
|
|
||||||
if token:
|
if token:
|
||||||
# store reference to match notifications
|
# Stocke la référence provider pour faire le matching des notifications.
|
||||||
self.provider_reference = token
|
self.provider_reference = token
|
||||||
_logger.info('PayDunya invoice created: token=%s, url=%s', token, redirect_url)
|
_logger.info('PayDunya invoice created: token=%s, url=%s', token, redirect_url)
|
||||||
# Return the rendering values expected by the redirect form template.
|
# Renvoie les valeurs attendues par le template de redirection.
|
||||||
return {
|
return {
|
||||||
'api_url': redirect_url,
|
'api_url': redirect_url,
|
||||||
'token': token,
|
'token': token,
|
||||||
@@ -342,7 +342,7 @@ class PaymentTransaction(models.Model):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _get_tx_from_notification(self, data):
|
def _get_tx_from_notification(self, data):
|
||||||
"""Find the transaction corresponding to a PayDunya notification payload."""
|
"""Retrouve la transaction correspondant à une notification PayDunya."""
|
||||||
invoice_data = self._normalize_paydunya_notification_data(data)
|
invoice_data = self._normalize_paydunya_notification_data(data)
|
||||||
tx_id = self._paydunya_extract_tx_id(invoice_data)
|
tx_id = self._paydunya_extract_tx_id(invoice_data)
|
||||||
if not tx_id and isinstance(data, dict):
|
if not tx_id and isinstance(data, dict):
|
||||||
@@ -361,17 +361,18 @@ class PaymentTransaction(models.Model):
|
|||||||
return tx or None
|
return tx or None
|
||||||
|
|
||||||
def _handle_notification_data(self, data):
|
def _handle_notification_data(self, data):
|
||||||
"""Handle PayDunya notification payload and update transaction state.
|
"""Traite une notification PayDunya et met à jour l'état de transaction.
|
||||||
|
|
||||||
Validates the hash according to PayDunya documentation (SHA-512 of MASTER-KEY).
|
Valide le hash selon la documentation PayDunya (SHA-512 du MASTER-KEY).
|
||||||
PayDunya sends data as application/x-www-form-urlencoded with key 'data' containing JSON.
|
PayDunya peut envoyer des données `application/x-www-form-urlencoded`
|
||||||
|
avec une clé `data` contenant du JSON.
|
||||||
"""
|
"""
|
||||||
invoice_data = self._normalize_paydunya_notification_data(data)
|
invoice_data = self._normalize_paydunya_notification_data(data)
|
||||||
if not invoice_data:
|
if not invoice_data:
|
||||||
_logger.warning('PayDunya: invalid notification payload: %s', data)
|
_logger.warning('PayDunya: invalid notification payload: %s', data)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Verify hash to ensure the callback is from PayDunya
|
# Vérifie le hash pour s'assurer que le callback vient bien de PayDunya.
|
||||||
provided_hash = invoice_data.get('hash')
|
provided_hash = invoice_data.get('hash')
|
||||||
if not provided_hash and isinstance(data, dict):
|
if not provided_hash and isinstance(data, dict):
|
||||||
provided_hash = data.get('hash')
|
provided_hash = data.get('hash')
|
||||||
@@ -391,7 +392,7 @@ class PaymentTransaction(models.Model):
|
|||||||
_logger.warning('PayDunya: no transaction found for notification: %s', invoice_data)
|
_logger.warning('PayDunya: no transaction found for notification: %s', invoice_data)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Extract status (valid values: pending, completed, cancelled, failed)
|
# Extrait le statut (pending, completed, cancelled, failed).
|
||||||
status = invoice_data.get('status') or (
|
status = invoice_data.get('status') or (
|
||||||
invoice_data.get('invoice', {}).get('status')
|
invoice_data.get('invoice', {}).get('status')
|
||||||
)
|
)
|
||||||
@@ -441,7 +442,7 @@ class PaymentTransaction(models.Model):
|
|||||||
_logger.info('PayDunya: Transaction %s marked as cancelled/failed', tx.reference)
|
_logger.info('PayDunya: Transaction %s marked as cancelled/failed', tx.reference)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# fallback: mark as pending
|
# Fallback: marque la transaction en attente.
|
||||||
if hasattr(tx, '_set_pending'):
|
if hasattr(tx, '_set_pending'):
|
||||||
tx._set_pending()
|
tx._set_pending()
|
||||||
elif hasattr(tx, '_set_transaction_pending'):
|
elif hasattr(tx, '_set_transaction_pending'):
|
||||||
|
|||||||
Reference in New Issue
Block a user