fix(payment_paydunya): fiabilise retour PayDunya et post-traitement paiement #3

Merged
Mamadou merged 2 commits from fix/payment-paydunya into main 2026-02-11 09:13:55 +00:00
2 changed files with 44 additions and 42 deletions
Showing only changes of commit 5a2e34211d - Show all commits

View File

@@ -9,7 +9,7 @@ _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."""
"""Traite le retour utilisateur PayDunya après tentative de paiement."""
token = kwargs.get('token') or http.request.params.get('token')
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')
@@ -29,7 +29,7 @@ class PaydunyaController(http.Controller):
data = {}
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()
confirm_url = base + '/checkout-invoice/confirm/{}'.format(token)
@@ -41,7 +41,7 @@ class PaydunyaController(http.Controller):
}
try:
# GET request to verify status
# Vérifie le statut de paiement côté PayDunya.
resp = requests.get(confirm_url, headers=headers, timeout=15)
data = resp.json()
_logger.info('PayDunya confirm response: %s', data)
@@ -49,12 +49,12 @@ class PaydunyaController(http.Controller):
_logger.exception('Error verifying PayDunya invoice: %s', e)
data = {'token': token, 'status': 'failed', 'sale_order_id': sale_order_id, 'tx_id': tx_id}
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}
else:
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:
handled = tx_model._handle_notification_data(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)
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')
_logger.info('PayDunya payment cancelled: token=%s', token)
return http.request.redirect('/shop/cart')
@http.route('/payment/paydunya/callback', type='http', auth='public', methods=['GET', 'POST'], csrf=False)
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:
payload = {}
@@ -105,5 +106,5 @@ class PaydunyaController(http.Controller):
except Exception:
_logger.exception('Error handling PayDunya callback')
# Always return 200 OK to PayDunya
# Retourne toujours HTTP 200 à PayDunya.
return 'OK'

View File

@@ -11,7 +11,7 @@ class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
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()
if 'payment_method_line_id' not in self._fields:
return False
@@ -39,7 +39,7 @@ class PaymentTransaction(models.Model):
return True
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):
return None
@@ -64,7 +64,7 @@ class PaymentTransaction(models.Model):
return None
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):
return None
@@ -88,7 +88,7 @@ class PaymentTransaction(models.Model):
return 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()
if 'sale.order' not in self.env:
return False
@@ -114,7 +114,7 @@ class PaymentTransaction(models.Model):
return self.env['sale.order']
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()
if 'sale.order' not in self.env:
return False
@@ -145,8 +145,8 @@ class PaymentTransaction(models.Model):
'payment_date': fields.Date.context_today(self),
})
# Force a valid journal/method line to avoid
# "Please define a payment method line on your payment."
# Force un journal + une méthode valides pour éviter
# l'erreur "Please define a payment method line on your payment."
if not wizard.journal_id:
company = posted_invoices[:1].company_id
journal = self.env['account.journal'].search([
@@ -180,12 +180,12 @@ class PaymentTransaction(models.Model):
return True
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
if not isinstance(payload, dict):
return {}
# Callback can send {"data": "<json>"} or {"data": {...}}
# Le callback peut envoyer {"data": "<json>"} ou {"data": {...}}.
if 'data' in payload:
wrapped = payload.get('data')
if isinstance(wrapped, str):
@@ -196,14 +196,14 @@ class PaymentTransaction(models.Model):
elif isinstance(wrapped, dict):
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):
payload = payload['response_data']
return payload if isinstance(payload, dict) else {}
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()
selected_code = (
@@ -212,7 +212,7 @@ class PaymentTransaction(models.Model):
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:
pm_id = processing_values.get('payment_method_id')
if pm_id:
@@ -237,10 +237,10 @@ class PaymentTransaction(models.Model):
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."""
"""Crée la facture côté PayDunya et renvoie les données de redirection."""
self.ensure_one()
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)
if not provider:
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
callback_url = base_url + '/payment/paydunya/callback' + query
# Build payload according to PayDunya documentation
# Required: invoice.total_amount, store.name
# Construit le payload conformément à la documentation PayDunya.
# Champs requis: invoice.total_amount, store.name.
payload = {
'invoice': {
'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:
payload['invoice']['customer'] = {
'name': self.partner_id.name or '',
@@ -292,7 +292,7 @@ class PaymentTransaction(models.Model):
channel = self._get_paydunya_channel(processing_values or {})
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]
_logger.info(
'PayDunya checkout channel: selected=%s resolved=%s',
@@ -302,7 +302,7 @@ class PaymentTransaction(models.Model):
channel,
)
# Authentication headers
# En-têtes d'authentification API PayDunya.
headers = {
'Content-Type': 'application/json',
'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)
data = resp.json()
# Check response_code (success = '00')
# Vérifie le code de réponse (succès = '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
# Extrait le token de la réponse.
token = data.get('token')
# response_text is the checkout URL
# response_text contient l'URL de checkout.
redirect_url = data.get('response_text')
if token:
# store reference to match notifications
# Stocke la référence provider pour faire le matching des 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.
# Renvoie les valeurs attendues par le template de redirection.
return {
'api_url': redirect_url,
'token': token,
@@ -342,7 +342,7 @@ class PaymentTransaction(models.Model):
return {}
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)
tx_id = self._paydunya_extract_tx_id(invoice_data)
if not tx_id and isinstance(data, dict):
@@ -361,17 +361,18 @@ class PaymentTransaction(models.Model):
return tx or None
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).
PayDunya sends data as application/x-www-form-urlencoded with key 'data' containing JSON.
Valide le hash selon la documentation PayDunya (SHA-512 du MASTER-KEY).
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)
if not invoice_data:
_logger.warning('PayDunya: invalid notification payload: %s', data)
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')
if not provided_hash and isinstance(data, dict):
provided_hash = data.get('hash')
@@ -391,7 +392,7 @@ class PaymentTransaction(models.Model):
_logger.warning('PayDunya: no transaction found for notification: %s', invoice_data)
return False
# Extract status (valid values: pending, completed, cancelled, failed)
# Extrait le statut (pending, completed, cancelled, failed).
status = invoice_data.get('status') or (
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)
return True
# fallback: mark as pending
# Fallback: marque la transaction en attente.
if hasattr(tx, '_set_pending'):
tx._set_pending()
elif hasattr(tx, '_set_transaction_pending'):