Files
odoopaydunya/addons/payment_paydunya/models/payment_transaction.py

454 lines
18 KiB
Python

import logging
import requests
import hashlib
import json
from odoo import models, fields
_logger = logging.getLogger(__name__)
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
def _paydunya_assign_transaction_payment_method_line(self):
"""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
if self.payment_method_line_id:
return True
journal = False
if 'journal_id' in self._fields and self.journal_id:
journal = self.journal_id
if not journal:
journal = self.env['account.journal'].search([
('company_id', '=', self.company_id.id),
('type', 'in', ['bank', 'cash']),
('inbound_payment_method_line_ids', '!=', False),
], limit=1)
method_line = journal.inbound_payment_method_line_ids[:1] if journal else False
if not method_line:
return False
vals = {'payment_method_line_id': method_line.id}
if 'journal_id' in self._fields and journal:
vals['journal_id'] = journal.id
self.write(vals)
return True
def _paydunya_extract_sale_order_id(self, payload):
"""Extrait l'ID de commande depuis la charge utile retour/callback."""
if not isinstance(payload, dict):
return None
candidates = []
for key in ('sale_order_id', 'order_id'):
value = payload.get(key)
if value:
candidates.append(value)
custom_data = payload.get('custom_data')
if isinstance(custom_data, dict):
for key in ('odoo_sale_order_id', 'sale_order_id', 'order_id'):
value = custom_data.get(key)
if value:
candidates.append(value)
for value in candidates:
try:
return int(value)
except (TypeError, ValueError):
continue
return None
def _paydunya_extract_tx_id(self, payload):
"""Extrait l'ID de transaction Odoo depuis la charge utile retour/callback."""
if not isinstance(payload, dict):
return None
candidates = []
for key in ('tx_id', 'transaction_id'):
value = payload.get(key)
if value:
candidates.append(value)
custom_data = payload.get('custom_data')
if isinstance(custom_data, dict):
value = custom_data.get('odoo_tx_id')
if value:
candidates.append(value)
for value in candidates:
try:
return int(value)
except (TypeError, ValueError):
continue
return None
def _paydunya_get_related_sale_order(self, sale_order_id=None):
"""Retrouve la commande liée à la transaction."""
self.ensure_one()
if 'sale.order' not in self.env:
return False
sale_order = self.env['sale.order']
if sale_order_id:
sale_order = self.env['sale.order'].sudo().browse(int(sale_order_id))
if sale_order.exists():
return sale_order
if hasattr(self, 'sale_order_ids') and self.sale_order_ids:
return self.sale_order_ids[0].sudo()
ref = (self.reference or '').strip()
if ref:
possible_names = [ref]
for separator in ('-', '_'):
if separator in ref:
possible_names.append(ref.split(separator, 1)[0])
sale_order = self.env['sale.order'].sudo().search([('name', 'in', list(set(possible_names)))], limit=1)
if sale_order:
return sale_order
return self.env['sale.order']
def _paydunya_finalize_sale_order(self, sale_order_id=None):
"""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
sale_order = self._paydunya_get_related_sale_order(sale_order_id=sale_order_id)
if not sale_order:
_logger.info('PayDunya: no sale order linked to transaction %s', self.reference)
return False
if sale_order.state in ('draft', 'sent'):
sale_order.action_confirm()
invoices = sale_order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
if not invoices:
invoices = sale_order._create_invoices()
draft_invoices = invoices.filtered(lambda inv: inv.state == 'draft')
if draft_invoices:
draft_invoices.action_post()
posted_invoices = invoices.filtered(lambda inv: inv.state == 'posted' and inv.amount_residual > 0)
if posted_invoices and 'account.payment.register' in self.env:
try:
register_ctx = {
'active_model': 'account.move',
'active_ids': posted_invoices.ids,
}
wizard = self.env['account.payment.register'].with_context(**register_ctx).create({
'payment_date': fields.Date.context_today(self),
})
# 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([
('company_id', '=', company.id),
('type', 'in', ['bank', 'cash']),
('inbound_payment_method_line_ids', '!=', False),
], limit=1)
if journal:
wizard.journal_id = journal
if not wizard.payment_method_line_id:
method_line = (
wizard.available_payment_method_line_ids[:1]
or wizard.journal_id.inbound_payment_method_line_ids[:1]
)
if method_line:
wizard.payment_method_line_id = method_line.id
wizard._create_payments()
except Exception:
_logger.exception(
'PayDunya: failed to auto-register invoice payment for transaction %s',
self.reference,
)
_logger.info(
'PayDunya: finalized sale order %s for transaction %s',
sale_order.name,
self.reference,
)
return True
def _normalize_paydunya_notification_data(self, data):
"""Normalise les payloads PayDunya (callback/IPN et confirmation)."""
payload = data
if not isinstance(payload, dict):
return {}
# Le callback peut envoyer {"data": "<json>"} ou {"data": {...}}.
if 'data' in payload:
wrapped = payload.get('data')
if isinstance(wrapped, str):
try:
payload = json.loads(wrapped)
except Exception:
payload = payload
elif isinstance(wrapped, dict):
payload = wrapped
# 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):
"""Détermine le canal PayDunya à forcer selon le moyen de paiement choisi."""
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)
)
# Le checkout Odoo envoie souvent seulement `payment_method_id`.
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):
"""Crée la facture côté PayDunya et renvoie les données de redirection."""
self.ensure_one()
provider = False
# 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)
if not provider:
return {}
base = provider._get_paydunya_api_base()
create_url = base + '/checkout-invoice/create'
sale_order = self._paydunya_get_related_sale_order()
sale_order_id = sale_order.id if sale_order else False
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
query = '?tx_id={}'.format(self.id)
if sale_order_id:
query += '&sale_order_id={}'.format(sale_order_id)
return_url = base_url + '/payment/paydunya/return' + query
cancel_url = base_url + '/payment/paydunya/cancel' + query
callback_url = base_url + '/payment/paydunya/callback' + query
# Construit le payload conformément à la documentation PayDunya.
# Champs requis: 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,
},
'custom_data': {
'odoo_tx_id': str(self.id),
'odoo_sale_order_id': str(sale_order_id or ''),
'odoo_reference': self.reference or '',
},
}
# Ajoute les informations client si disponibles.
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 un seul canal pour rediriger vers l'opérateur sélectionné.
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,
)
# En-têtes d'authentification API PayDunya.
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()
# 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 {}
# Extrait le token de la réponse.
token = data.get('token')
# response_text contient l'URL de checkout.
redirect_url = data.get('response_text')
if token:
# 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)
# Renvoie les valeurs attendues par le template de redirection.
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):
"""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):
tx_id = self._paydunya_extract_tx_id(data)
if tx_id:
tx = self.search([('id', '=', tx_id)], limit=1)
if tx:
return tx
token = invoice_data.get('invoice', {}).get('token') or invoice_data.get('token')
if not token and isinstance(data, dict):
token = data.get('token') or data.get('invoice_token')
if not token:
return None
tx = self.search([('provider_reference', '=', token)], limit=1)
return tx or None
def _handle_notification_data(self, data):
"""Traite une notification PayDunya et met à jour l'état de transaction.
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
# 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')
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 str(provided_hash).lower() != 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(data)
if not tx:
_logger.warning('PayDunya: no transaction found for notification: %s', invoice_data)
return False
# Extrait le statut (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':
sale_order_id = self._paydunya_extract_sale_order_id(invoice_data)
tx._paydunya_assign_transaction_payment_method_line()
if hasattr(tx, '_set_done'):
tx._set_done()
elif hasattr(tx, '_set_transaction_done'):
tx._set_transaction_done()
else:
tx.state = 'done'
if hasattr(tx, '_post_process'):
try:
tx._post_process()
except Exception:
_logger.exception(
'PayDunya: _post_process failed for transaction %s; keeping done state',
tx.reference,
)
if 'is_post_processed' in tx._fields:
tx.is_post_processed = True
tx._paydunya_finalize_sale_order(sale_order_id=sale_order_id)
_logger.info('PayDunya: Transaction %s marked as done', tx.reference)
return True
if status in ('cancelled', 'failed'):
if hasattr(tx, '_set_canceled'):
tx._set_canceled()
elif hasattr(tx, '_set_transaction_cancel'):
tx._set_transaction_cancel()
else:
tx.state = 'cancel'
if hasattr(tx, '_post_process'):
try:
tx._post_process()
except Exception:
_logger.exception(
'PayDunya: _post_process failed for cancelled/failed transaction %s',
tx.reference,
)
_logger.info('PayDunya: Transaction %s marked as cancelled/failed', tx.reference)
return True
# Fallback: marque la transaction en attente.
if hasattr(tx, '_set_pending'):
tx._set_pending()
elif 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