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": ""} 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