From c88c42127ac02231ea129958c2bbfa20b417e4c0 Mon Sep 17 00:00:00 2001 From: mthiam Date: Tue, 10 Feb 2026 23:06:33 +0100 Subject: [PATCH] fix(payment_paydunya): fiabilise retour PayDunya et post-traitement paiement --- addons/payment_paydunya/controllers/main.py | 65 +++-- .../models/payment_transaction.py | 235 +++++++++++++++++- 2 files changed, 268 insertions(+), 32 deletions(-) diff --git a/addons/payment_paydunya/controllers/main.py b/addons/payment_paydunya/controllers/main.py index 77124ce..adc1e65 100644 --- a/addons/payment_paydunya/controllers/main.py +++ b/addons/payment_paydunya/controllers/main.py @@ -11,40 +11,61 @@ class PaydunyaController(http.Controller): def paydunya_return(self, **kwargs): """Handle return from PayDunya after payment attempt.""" 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: + # Build check URL using the confirm endpoint + 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: - # 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'} + 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', 'sale_order_id': sale_order_id, 'tx_id': tx_id} + elif tx: + # Fallback when PayDunya did not return token: rely on tx current state. + data = {'tx_id': tx.id, 'sale_order_id': sale_order_id} + else: + return http.request.redirect('/') # 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') diff --git a/addons/payment_paydunya/models/payment_transaction.py b/addons/payment_paydunya/models/payment_transaction.py index a8f356a..80924b7 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,6 +10,175 @@ _logger = logging.getLogger(__name__) 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.""" + 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): + """Extract a sale order id from return/callback payload.""" + 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): + """Extract an Odoo transaction id from return/callback payload.""" + 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): + """Locate the sale order linked to this 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): + """Confirm sale order and create/post invoice when transaction is paid.""" + 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 a valid journal/method line to avoid + # "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.""" payload = data @@ -80,11 +249,16 @@ 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 @@ -100,7 +274,12 @@ 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 @@ -165,8 +344,17 @@ class PaymentTransaction(models.Model): def _get_tx_from_notification(self, data): """Find the transaction corresponding to a PayDunya notification payload.""" 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) @@ -198,7 +386,7 @@ 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 @@ -214,22 +402,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'): + if hasattr(tx, '_set_pending'): + tx._set_pending() + elif hasattr(tx, '_set_transaction_pending'): tx._set_transaction_pending() else: tx.state = 'pending'