diff --git a/routes/universign.py b/routes/universign.py index 1b73574..49271be 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -532,41 +532,70 @@ async def sync_all_transactions( async def webhook_universign( request: Request, session: AsyncSession = Depends(get_session) ): + """ + CORRECTION : Extraction correcte du transaction_id selon la structure réelle d'Universign + """ try: payload = await request.json() - # 🔍 LOG COMPLET du payload pour déboguer + # 📋 LOG COMPLET du payload pour débogage logger.info( - f"📥 Webhook Universign reçu - Payload complet: {json.dumps(payload, indent=2)}" + f"📥 Webhook Universign reçu - Type: {payload.get('type', 'unknown')}" ) + logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}") - # Extraction du transaction_id selon la structure Universign + # ✅ EXTRACTION CORRECTE DU TRANSACTION_ID transaction_id = None - # Universign envoie généralement : - # - "object": "transaction" - # - "id": "tr_xxx" (le vrai ID de transaction) - # - "event": "evt_xxx" (l'ID de l'événement) + # 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante) + # Exemple : transaction.lifecycle.created, transaction.lifecycle.started, etc. + if payload.get("type", "").startswith("transaction.") and "payload" in payload: + # Le transaction_id est dans payload.object.id + nested_object = payload.get("payload", {}).get("object", {}) + if nested_object.get("object") == "transaction": + transaction_id = nested_object.get("id") + logger.info( + f"✅ Transaction ID extrait de payload.object.id: {transaction_id}" + ) - if payload.get("object") == "transaction": - transaction_id = payload.get("id") # C'est ici le vrai ID + # 🔍 Structure 2 : Action événements (action.opened, action.completed) + elif payload.get("type", "").startswith("action."): + # Le transaction_id est directement dans payload.object.transaction_id + transaction_id = ( + payload.get("payload", {}).get("object", {}).get("transaction_id") + ) + logger.info( + f"✅ Transaction ID extrait de payload.object.transaction_id: {transaction_id}" + ) + + # 🔍 Structure 3 : Transaction directe (fallback) + elif payload.get("object") == "transaction": + transaction_id = payload.get("id") + logger.info(f"✅ Transaction ID extrait direct: {transaction_id}") + + # 🔍 Structure 4 : Ancien format (pour rétro-compatibilité) elif "transaction" in payload: - # Parfois dans un objet "transaction" transaction_id = payload.get("transaction", {}).get("id") - elif "data" in payload: - # Ou dans "data" - transaction_id = payload.get("data", {}).get("id") + logger.info( + f"✅ Transaction ID extrait de transaction.id: {transaction_id}" + ) + # ❌ Échec d'extraction if not transaction_id: logger.error( - f"❌ Transaction ID introuvable dans webhook. Payload: {payload}" + f"❌ Transaction ID introuvable dans webhook\n" + f"Type d'événement: {payload.get('type', 'unknown')}\n" + f"Clés racine: {list(payload.keys())}\n" + f"Payload simplifié: {json.dumps({k: v if k != 'payload' else '...' for k, v in payload.items()})}" ) return { "status": "error", "message": "Transaction ID manquant dans webhook", + "event_type": payload.get("type"), + "event_id": payload.get("id"), }, 400 - logger.info(f"🔍 Transaction ID extrait: {transaction_id}") + logger.info(f"🎯 Transaction ID identifié: {transaction_id}") # Vérifier si la transaction existe localement query = select(UniversignTransaction).where( @@ -577,30 +606,48 @@ async def webhook_universign( if not tx: logger.warning( - f"⚠️ Transaction {transaction_id} inconnue en local - création en attente" + f"⚠️ Transaction {transaction_id} inconnue en local\n" + f"Type d'événement: {payload.get('type')}\n" + f"Elle sera synchronisée au prochain polling" ) - # Ne pas échouer, juste logger return { "status": "accepted", "message": f"Transaction {transaction_id} non trouvée localement, sera synchronisée au prochain polling", + "transaction_id": transaction_id, + "event_type": payload.get("type"), } + # Traiter le webhook success, error = await sync_service.process_webhook( session, payload, transaction_id ) if not success: logger.error(f"❌ Erreur traitement webhook: {error}") - return {"status": "error", "message": error}, 500 + return { + "status": "error", + "message": error, + "transaction_id": transaction_id, + }, 500 + + # ✅ Succès + logger.info( + f"✅ Webhook traité avec succès\n" + f"Transaction: {transaction_id}\n" + f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n" + f"Type d'événement: {payload.get('type')}" + ) return { "status": "processed", "transaction_id": transaction_id, "local_status": tx.local_status.value if tx else None, + "event_type": payload.get("type"), + "event_id": payload.get("id"), } except Exception as e: - logger.error(f"💥 Erreur webhook: {e}", exc_info=True) + logger.error(f"💥 Erreur critique webhook: {e}", exc_info=True) return {"status": "error", "message": str(e)}, 500 diff --git a/services/universign_sync.py b/services/universign_sync.py index 38aaf6a..83fec08 100644 --- a/services/universign_sync.py +++ b/services/universign_sync.py @@ -90,155 +90,6 @@ class UniversignSyncService: logger.error(f"Erreur fetch {transaction_id}: {e}", exc_info=True) return None - async def sync_transaction( - self, - session: AsyncSession, - transaction: UniversignTransaction, - force: bool = False, - ) -> Tuple[bool, Optional[str]]: - """ - Synchronise une transaction avec Universign - - CORRECTION : Met à jour correctement le statut local selon le statut distant - """ - - # Si statut final et pas de force, skip - if is_final_status(transaction.local_status.value) and not force: - logger.debug( - f"⏭️ Skip {transaction.transaction_id}: statut final {transaction.local_status.value}" - ) - transaction.needs_sync = False - await session.commit() - return True, None - - # Récupération du statut distant - logger.info(f"🔄 Synchronisation: {transaction.transaction_id}") - - result = self.fetch_transaction_status(transaction.transaction_id) - - if not result: - error = "Échec récupération données Universign" - logger.error(f"❌ {error}: {transaction.transaction_id}") - await self._log_sync_attempt(session, transaction, "polling", False, error) - transaction.sync_attempts += 1 - await session.commit() - return False, error - - universign_data = result["transaction"] - universign_status_raw = universign_data.get("state", "draft") - - logger.info(f"📊 Statut Universign brut: {universign_status_raw}") - - # Convertir le statut Universign en statut local - new_local_status = map_universign_to_local(universign_status_raw) - previous_local_status = transaction.local_status.value - - logger.info( - f"🔄 Mapping: {universign_status_raw} (Universign) → " - f"{new_local_status} (Local) | Actuel: {previous_local_status}" - ) - - # Vérifier si la transition est autorisée - if not is_transition_allowed(previous_local_status, new_local_status): - logger.warning( - f"⚠️ Transition refusée: {previous_local_status} → {new_local_status}" - ) - new_local_status = resolve_status_conflict( - previous_local_status, new_local_status - ) - logger.info(f"✅ Résolution conflit: statut résolu = {new_local_status}") - - status_changed = previous_local_status != new_local_status - - if status_changed: - logger.info( - f"📝 CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" - ) - else: - logger.debug(f"⏸️ Pas de changement de statut") - - # Mise à jour du statut Universign brut - try: - transaction.universign_status = UniversignTransactionStatus( - universign_status_raw - ) - except ValueError: - logger.warning(f"⚠️ Statut Universign inconnu: {universign_status_raw}") - transaction.universign_status = ( - UniversignTransactionStatus.COMPLETED - if new_local_status == "SIGNE" - else UniversignTransactionStatus.FAILED - ) - - # ✅ CORRECTION PRINCIPALE : Mise à jour du statut local - transaction.local_status = LocalDocumentStatus(new_local_status) - transaction.universign_status_updated_at = datetime.now() - - # Mise à jour des dates selon le nouveau statut - if new_local_status == "EN_COURS" and not transaction.sent_at: - transaction.sent_at = datetime.now() - logger.info("📅 Date d'envoi mise à jour") - - if new_local_status == "SIGNE" and not transaction.signed_at: - transaction.signed_at = datetime.now() - logger.info("✅ Date de signature mise à jour") - - if new_local_status == "REFUSE" and not transaction.refused_at: - transaction.refused_at = datetime.now() - logger.info("❌ Date de refus mise à jour") - - if new_local_status == "EXPIRE" and not transaction.expired_at: - transaction.expired_at = datetime.now() - logger.info("⏰ Date d'expiration mise à jour") - - # Mise à jour des URLs - if universign_data.get("documents") and len(universign_data["documents"]) > 0: - first_doc = universign_data["documents"][0] - if first_doc.get("url"): - transaction.document_url = first_doc["url"] - logger.info("🔗 URL du document mise à jour") - - # Synchroniser les signataires - await self._sync_signers(session, transaction, universign_data) - - # Mise à jour des métadonnées de sync - transaction.last_synced_at = datetime.now() - transaction.sync_attempts += 1 - transaction.needs_sync = not is_final_status(new_local_status) - transaction.sync_error = None - - # Log de la tentative - await self._log_sync_attempt( - session=session, - transaction=transaction, - sync_type="polling", - success=True, - error_message=None, - previous_status=previous_local_status, - new_status=new_local_status, - changes=json.dumps( - { - "status_changed": status_changed, - "universign_raw": universign_status_raw, - "response_time_ms": result.get("response_time_ms"), - } - ), - ) - - await session.commit() - - # Exécuter les actions post-changement de statut - if status_changed: - logger.info(f"🎬 Exécution actions pour statut: {new_local_status}") - await self._execute_status_actions(session, transaction, new_local_status) - - logger.info( - f"✅ Sync terminée: {transaction.transaction_id} | " - f"{previous_local_status} → {new_local_status}" - ) - - return True, None - async def sync_all_pending( self, session: AsyncSession, max_transactions: int = 50 ) -> Dict[str, int]: @@ -304,26 +155,37 @@ class UniversignSyncService: return stats + # CORRECTION 1 : process_webhook dans universign_sync.py async def process_webhook( self, session: AsyncSession, payload: Dict, transaction_id: str = None ) -> Tuple[bool, Optional[str]]: """ - Traite un webhook Universign - - Args: - session: Session SQLAlchemy - payload: Payload du webhook - transaction_id: ID de transaction (optionnel si déjà dans payload) + Traite un webhook Universign - CORRECTION : meilleure gestion des payloads """ try: # Si transaction_id n'est pas fourni, essayer de l'extraire if not transaction_id: - transaction_id = payload.get("id") or payload.get("transaction_id") + # Même logique que dans universign.py + if ( + payload.get("type", "").startswith("transaction.") + and "payload" in payload + ): + nested_object = payload.get("payload", {}).get("object", {}) + if nested_object.get("object") == "transaction": + transaction_id = nested_object.get("id") + elif payload.get("type", "").startswith("action."): + transaction_id = ( + payload.get("payload", {}) + .get("object", {}) + .get("transaction_id") + ) + elif payload.get("object") == "transaction": + transaction_id = payload.get("id") if not transaction_id: return False, "Transaction ID manquant" - event_type = payload.get("event") or payload.get("type", "webhook") + event_type = payload.get("type", "webhook") logger.info( f"📨 Traitement webhook: transaction={transaction_id}, event={event_type}" @@ -369,7 +231,9 @@ class UniversignSyncService: error_message=error, previous_status=old_status, new_status=transaction.local_status.value, - changes=json.dumps(payload), + changes=json.dumps( + payload, default=str + ), # ✅ Ajout default=str pour éviter les erreurs JSON ) await session.commit() @@ -380,53 +244,268 @@ class UniversignSyncService: logger.error(f"💥 Erreur traitement webhook: {e}", exc_info=True) return False, str(e) + # CORRECTION 2 : _sync_signers - Ne pas écraser les signers existants async def _sync_signers( self, session: AsyncSession, transaction: UniversignTransaction, universign_data: Dict, ): - signers_data = universign_data.get("signers", []) - - # Ne pas toucher aux signers existants si Universign n'en retourne pas + """ + CORRECTION : Synchronise les signataires sans perdre les données locales + """ + # Récupérer les participants depuis différents endroits possibles + signers_data = universign_data.get("participants", []) if not signers_data: + signers_data = universign_data.get("signers", []) + + # ⚠️ IMPORTANT : Ne pas toucher aux signers si Universign n'en retourne pas + if not signers_data: + logger.debug( + "Aucun signataire dans les données Universign, conservation des données locales" + ) return - # Mettre à jour les signers existants ou en créer de nouveaux + # Créer un mapping email -> signer existant existing_signers = {s.email: s for s in transaction.signers} for idx, signer_data in enumerate(signers_data): email = signer_data.get("email", "") + if not email: + logger.warning(f"Signataire sans email à l'index {idx}, ignoré") + continue + if email in existing_signers: - # Mise à jour du signer existant + # ✅ Mise à jour du signer existant (ne pas écraser si None) signer = existing_signers[email] - signer.status = UniversignSignerStatus( - signer_data.get("status", "waiting") - ) - signer.viewed_at = ( - self._parse_date(signer_data.get("viewed_at")) or signer.viewed_at - ) - signer.signed_at = ( - self._parse_date(signer_data.get("signed_at")) or signer.signed_at - ) - signer.refused_at = ( - self._parse_date(signer_data.get("refused_at")) or signer.refused_at - ) + + # Mise à jour du statut + new_status = signer_data.get("status") or signer_data.get("state") + if new_status: + try: + signer.status = UniversignSignerStatus(new_status) + except ValueError: + logger.warning( + f"Statut inconnu pour signer {email}: {new_status}" + ) + + # Mise à jour des dates (ne pas écraser si déjà renseignées) + viewed_at = self._parse_date(signer_data.get("viewed_at")) + if viewed_at and not signer.viewed_at: + signer.viewed_at = viewed_at + + signed_at = self._parse_date(signer_data.get("signed_at")) + if signed_at and not signer.signed_at: + signer.signed_at = signed_at + + refused_at = self._parse_date(signer_data.get("refused_at")) + if refused_at and not signer.refused_at: + signer.refused_at = refused_at + + # Mise à jour du nom si manquant + if signer_data.get("name") and not signer.name: + signer.name = signer_data.get("name") + else: - # Nouveau signer - signer = UniversignSigner( - id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", - transaction_id=transaction.id, - email=email, - name=signer_data.get("name"), - status=UniversignSignerStatus(signer_data.get("status", "waiting")), - order_index=idx, - viewed_at=self._parse_date(signer_data.get("viewed_at")), - signed_at=self._parse_date(signer_data.get("signed_at")), - refused_at=self._parse_date(signer_data.get("refused_at")), + # ✅ Nouveau signer + try: + status = signer_data.get("status") or signer_data.get( + "state", "waiting" + ) + signer = UniversignSigner( + id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", + transaction_id=transaction.id, + email=email, + name=signer_data.get("name"), + status=UniversignSignerStatus(status), + order_index=idx, + viewed_at=self._parse_date(signer_data.get("viewed_at")), + signed_at=self._parse_date(signer_data.get("signed_at")), + refused_at=self._parse_date(signer_data.get("refused_at")), + ) + session.add(signer) + logger.info(f"➕ Nouveau signataire ajouté: {email}") + except Exception as e: + logger.error(f"Erreur création signer {email}: {e}") + + # CORRECTION 3 : Amélioration du logging dans sync_transaction + async def sync_transaction( + self, + session: AsyncSession, + transaction: UniversignTransaction, + force: bool = False, + ) -> Tuple[bool, Optional[str]]: + """ + CORRECTION : Meilleur logging et gestion d'erreurs + """ + + # Si statut final et pas de force, skip + if is_final_status(transaction.local_status.value) and not force: + logger.debug( + f"⏭️ Skip {transaction.transaction_id}: statut final {transaction.local_status.value}" + ) + transaction.needs_sync = False + await session.commit() + return True, None + + # Récupération du statut distant + logger.info(f"🔄 Synchronisation: {transaction.transaction_id}") + + result = self.fetch_transaction_status(transaction.transaction_id) + + if not result: + error = "Échec récupération données Universign" + logger.error(f"❌ {error}: {transaction.transaction_id}") + + # ✅ CORRECTION : Incrémenter les tentatives MÊME en cas d'échec + transaction.sync_attempts += 1 + transaction.sync_error = error + + await self._log_sync_attempt(session, transaction, "polling", False, error) + await session.commit() + return False, error + + try: + universign_data = result["transaction"] + universign_status_raw = universign_data.get("state", "draft") + + logger.info(f"📊 Statut Universign brut: {universign_status_raw}") + + # Convertir le statut + new_local_status = map_universign_to_local(universign_status_raw) + previous_local_status = transaction.local_status.value + + logger.info( + f"🔄 Mapping: {universign_status_raw} (Universign) → " + f"{new_local_status} (Local) | Actuel: {previous_local_status}" + ) + + # Vérifier la transition + if not is_transition_allowed(previous_local_status, new_local_status): + logger.warning( + f"⚠️ Transition refusée: {previous_local_status} → {new_local_status}" ) - session.add(signer) + new_local_status = resolve_status_conflict( + previous_local_status, new_local_status + ) + logger.info( + f"✅ Résolution conflit: statut résolu = {new_local_status}" + ) + + status_changed = previous_local_status != new_local_status + + if status_changed: + logger.info( + f"🔔 CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" + ) + + # Mise à jour du statut Universign brut + try: + transaction.universign_status = UniversignTransactionStatus( + universign_status_raw + ) + except ValueError: + logger.warning(f"⚠️ Statut Universign inconnu: {universign_status_raw}") + # Fallback intelligent + if new_local_status == "SIGNE": + transaction.universign_status = ( + UniversignTransactionStatus.COMPLETED + ) + elif new_local_status == "REFUSE": + transaction.universign_status = UniversignTransactionStatus.REFUSED + elif new_local_status == "EXPIRE": + transaction.universign_status = UniversignTransactionStatus.EXPIRED + else: + transaction.universign_status = UniversignTransactionStatus.STARTED + + # ✅ Mise à jour du statut local + transaction.local_status = LocalDocumentStatus(new_local_status) + transaction.universign_status_updated_at = datetime.now() + + # Mise à jour des dates + if new_local_status == "EN_COURS" and not transaction.sent_at: + transaction.sent_at = datetime.now() + logger.info("📅 Date d'envoi mise à jour") + + if new_local_status == "SIGNE" and not transaction.signed_at: + transaction.signed_at = datetime.now() + logger.info("✅ Date de signature mise à jour") + + if new_local_status == "REFUSE" and not transaction.refused_at: + transaction.refused_at = datetime.now() + logger.info("❌ Date de refus mise à jour") + + if new_local_status == "EXPIRE" and not transaction.expired_at: + transaction.expired_at = datetime.now() + logger.info("⏰ Date d'expiration mise à jour") + + # Mise à jour des URLs + if ( + universign_data.get("documents") + and len(universign_data["documents"]) > 0 + ): + first_doc = universign_data["documents"][0] + if first_doc.get("url"): + transaction.document_url = first_doc["url"] + + # Synchroniser les signataires + await self._sync_signers(session, transaction, universign_data) + + # Mise à jour des métadonnées de sync + transaction.last_synced_at = datetime.now() + transaction.sync_attempts += 1 + transaction.needs_sync = not is_final_status(new_local_status) + transaction.sync_error = None # ✅ Effacer l'erreur précédente + + # Log de la tentative + await self._log_sync_attempt( + session=session, + transaction=transaction, + sync_type="polling", + success=True, + error_message=None, + previous_status=previous_local_status, + new_status=new_local_status, + changes=json.dumps( + { + "status_changed": status_changed, + "universign_raw": universign_status_raw, + "response_time_ms": result.get("response_time_ms"), + }, + default=str, # ✅ Éviter les erreurs de sérialisation + ), + ) + + await session.commit() + + # Exécuter les actions post-changement + if status_changed: + logger.info(f"🎬 Exécution actions pour statut: {new_local_status}") + await self._execute_status_actions( + session, transaction, new_local_status + ) + + logger.info( + f"✅ Sync terminée: {transaction.transaction_id} | " + f"{previous_local_status} → {new_local_status}" + ) + + return True, None + + except Exception as e: + error_msg = f"Erreur lors de la synchronisation: {str(e)}" + logger.error(f"❌ {error_msg}", exc_info=True) + + transaction.sync_error = error_msg[:1000] # Tronquer si trop long + transaction.sync_attempts += 1 + + await self._log_sync_attempt( + session, transaction, "polling", False, error_msg + ) + await session.commit() + + return False, error_msg async def _log_sync_attempt( self,