diff --git a/addons/payment_paydunya/controllers/main.py b/addons/payment_paydunya/controllers/main.py index 77124ce..0cd4393 100644 --- a/addons/payment_paydunya/controllers/main.py +++ b/addons/payment_paydunya/controllers/main.py @@ -9,42 +9,63 @@ _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') - if not token: - return http.request.redirect('/') + 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') + + tx_model = http.request.env['payment.transaction'].sudo() + tx = False + if tx_id: + try: + tx = tx_model.search([('id', '=', int(tx_id))], limit=1) + except (TypeError, ValueError): + tx = False provider = http.request.env['payment.provider'].sudo().search([('code', '=', 'paydunya')], limit=1) - if not provider: + if not provider and token: _logger.warning('PayDunya return called but no provider found') return http.request.redirect('/') - # Build check URL using the confirm endpoint - base = provider._get_paydunya_api_base() - confirm_url = base + '/checkout-invoice/confirm/{}'.format(token) + data = {} + if token and provider: + # 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) - 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 '' - } + 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: + # 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) + except Exception as e: + _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: 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('/') + + # Déclenche la mise à jour de la transaction avec les données confirmées. try: - # GET request to verify status - resp = requests.get(confirm_url, headers=headers, timeout=15) - data = resp.json() - _logger.info('PayDunya confirm response: %s', data) - except Exception as e: - _logger.exception('Error verifying PayDunya invoice: %s', e) - data = {'token': token, 'status': 'failed'} - - # Trigger transaction handling with the confirmed data - try: - tx_model = http.request.env['payment.transaction'].sudo() handled = tx_model._handle_notification_data(data) - if handled: + tx = tx or tx_model._get_tx_from_notification(data) + if tx and tx.state == 'done': + order = tx._paydunya_get_related_sale_order(sale_order_id=sale_order_id) + if order: + http.request.session['sale_last_order_id'] = order.id + http.request.session['sale_transaction_id'] = tx.id return http.request.redirect('/shop/confirmation') + if handled: + return http.request.redirect('/shop/cart') except Exception: _logger.exception('Error handling PayDunya notification data') @@ -52,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. - - PayDunya sends data as application/x-www-form-urlencoded with 'data' key containing JSON. + """Traite le callback IPN envoyé par PayDunya. + + PayDunya peut envoyer des données en `application/x-www-form-urlencoded` + dans une clé `data` contenant du JSON. """ try: payload = {} @@ -84,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' diff --git a/addons/payment_paydunya/models/payment_transaction.py b/addons/payment_paydunya/models/payment_transaction.py index a8f356a..5a7ceac 100644 --- a/addons/payment_paydunya/models/payment_transaction.py +++ b/addons/payment_paydunya/models/payment_transaction.py @@ -2,7 +2,7 @@ import logging import requests import hashlib import json -from odoo import models +from odoo import models, fields _logger = logging.getLogger(__name__) @@ -10,13 +10,182 @@ _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): - """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": ""} or {"data": {...}} + # Le callback peut envoyer {"data": ""} ou {"data": {...}}. if 'data' in payload: wrapped = payload.get('data') if isinstance(wrapped, str): @@ -27,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 = ( @@ -43,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: @@ -68,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) @@ -80,14 +249,19 @@ class PaymentTransaction(models.Model): 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') - return_url = base_url + '/payment/paydunya/return' - cancel_url = base_url + '/payment/paydunya/cancel' - callback_url = base_url + '/payment/paydunya/callback' + 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 - # 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), @@ -100,10 +274,15 @@ class PaymentTransaction(models.Model): '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 '', + }, } - # Add customer info if available + # Ajoute les informations client si disponibles. if self.partner_id: payload['invoice']['customer'] = { 'name': self.partner_id.name or '', @@ -113,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', @@ -123,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 '', @@ -135,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, @@ -163,27 +342,37 @@ 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): + 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): - """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. + """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 - # 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') @@ -198,12 +387,12 @@ class PaymentTransaction(models.Model): expected_hash, provided_hash) return False - tx = self._get_tx_from_notification(invoice_data) + tx = self._get_tx_from_notification(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) + # Extrait le statut (pending, completed, cancelled, failed). status = invoice_data.get('status') or ( invoice_data.get('invoice', {}).get('status') ) @@ -214,22 +403,49 @@ class PaymentTransaction(models.Model): status = str(status).lower() if status == 'completed': - if hasattr(tx, '_set_transaction_done'): + 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_transaction_cancel'): + 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: mark as pending - if hasattr(tx, '_set_transaction_pending'): + # 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'