# paypal_processor.py from flask import Flask, request import logging import requests import json import os # Import os to get BOT_TOKEN # Import necessary functions from your project structure # Adjust the path if paypal_processor.py is not in the root 'app' directory # Assuming it can access the other modules directly as in the docker setup: from helpers import generate_table_image, format_last_participants_list, get_paypal_access_token from database import ( get_user_by_invoice_id, confirm_reserved_numbers, get_raffle_name, get_raffle, get_remaining_numbers_amount, get_main_message_id, store_update_message_id, get_update_message_id, get_last_n_other_participants, get_remaining_numbers, delete_reservation_timestamp, cancel_reservation_by_id ) from config import BOT_TOKEN, BOT_NAME, WEBHOOK_ID, TYC_DOCUMENT_URL, REVERSE_CHANNELS, NEWRELIC_API_KEY, PAYPAL_URL, ADMIN_IDS from newrelic_telemetry_sdk import Log, LogClient app = Flask(__name__) # Enable logging logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO ) client = LogClient(license_key=NEWRELIC_API_KEY, host="log-api.eu.newrelic.com") # Define a handler that sends records to New Relic class NewRelicHandler(logging.Handler): def emit(self, record): try: log = Log( message=self.format(record), level=record.levelname, timestamp_ms=int(record.created * 1000), attributes={ "logger": record.name, "app_name": BOT_NAME, "docker_container": "paypal_processor" } ) client.send(log) except Exception: self.handleError(record) nr_handler = NewRelicHandler() nr_handler.setFormatter(logging.Formatter("%(message)s")) root_logger = logging.getLogger() root_logger.addHandler(nr_handler) logger = logging.getLogger(__name__) # Define the Telegram API URL base TELEGRAM_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" def edit_telegram_message_caption(chat_id, message_id, caption, photo_path=None, parse_mode=None, **kwargs): """ Helper to edit message caption. If photo_path is provided, it attempts to re-send the photo with the new caption (Telegram doesn't directly support editing media content of a message, only its caption or reply_markup). However, for this use case, we are editing the caption of an *existing* photo. """ payload = {'chat_id': chat_id, 'message_id': message_id, 'caption': caption} if parse_mode: payload['parse_mode'] = parse_mode # For inline keyboards, add reply_markup=json.dumps(keyboard_dict) payload.update(kwargs) try: response = requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", data=payload) response.raise_for_status() logger.info(f"Edited caption for message {message_id} in chat {chat_id}") return response.json().get('result', {}).get('message_id', message_id) # Return new or old message_id except requests.exceptions.RequestException as e: logger.error(f"Error editing caption for message {message_id} in chat {chat_id}: {e}") if e.response is not None: logger.error(f"Edit caption Response status: {e.response.status_code}, body: {e.response.text}") return None # Indicate failure def send_telegram_message(chat_id, text, **kwargs): """Helper to send text messages via requests.""" payload = {'chat_id': chat_id, 'text': text, **kwargs} try: response = requests.post(f"{TELEGRAM_API_URL}/sendMessage", data=payload) response.raise_for_status() logger.info(f"Sent message to chat_id {chat_id}") return True except requests.exceptions.RequestException as e: logger.error(f"Error sending message to chat_id {chat_id}: {e}") if e.response is not None: logger.error(f"Response status: {e.response.status_code}, body: {e.response.text}") return False def send_telegram_photo(chat_id, photo_path, caption=None, keyboard=None, parse_mode=None, **kwargs): """Helper to send photos via requests.""" files = {'photo': open(photo_path, 'rb')} data = {'chat_id': chat_id} if caption: data['caption'] = caption if keyboard: # Add inline keyboard if provided data['reply_markup'] = json.dumps(keyboard) if parse_mode: # Add parse_mode to data if provided data['parse_mode'] = parse_mode data.update(kwargs) try: response = requests.post(f"{TELEGRAM_API_URL}/sendPhoto", data=data, files=files) response.raise_for_status() logger.info(f"Sent photo to chat_id {chat_id}") message_data = response.json().get('result') return message_data except requests.exceptions.RequestException as e: logger.error(f"Error sending photo to chat_id {chat_id}: {e}") if e.response is not None: logger.error(f"Response status: {e.response.status_code}, body: {e.response.text}") return False finally: # Ensure file is closed even if request fails if 'photo' in files and files['photo']: files['photo'].close() # --- CORRECTED FUNCTION --- def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): """Processes verified PayPal payment data.""" logger.info(f"Processing payment for Invoice: {invoice_id}, Status: {payment_status}, Amount: {payment_amount_str}") # 1. Get participant data using the invoice ID participant_data = get_user_by_invoice_id(invoice_id) # 2. Check if participant data exists and is pending if not participant_data: # This means the invoice ID wasn't found OR the step wasn't 'waiting_for_payment' # It could be already completed, cancelled, or expired. Ignore the webhook. logger.warning(f"No pending participant found for Invoice ID: {invoice_id}. Payment ignored.") return # 3. Extract data directly from the fetched Row object user_id = participant_data['user_id'] current_user_name = participant_data['user_name'] or f"User_{user_id}" # Use fallback name raffle_id = participant_data['raffle_id'] numbers_str = participant_data['numbers'] raffle_details = get_raffle(raffle_id) price_per_number = raffle_details['price'] channel_id_to_announce = raffle_details['channel_id'] update_message_id = raffle_details['update_message_id'] numbers = numbers_str.split(',') if numbers_str else [] if not numbers: logger.error(f"Invoice ID {invoice_id} found, but participant {user_id} has no numbers associated. Skipping.") # Maybe notify admin? return # 4. Validate Payment Status # if payment_status != "Completed": # logger.warning(f"Payment status for Invoice ID {invoice_id} is '{payment_status}', not 'Completed'. Payment not processed.") # # Optionally notify the user that payment is pending/failed # send_telegram_message( # user_id, # f"El estado de tu pago para la factura {invoice_id} es '{payment_status}'. " # f"El sorteo solo se confirma con pagos 'Completed'. Contacta con un administrador si crees que es un error." # ) # return # 5. Validate Payment Amount try: payment_amount = float(payment_amount_str) except (ValueError, TypeError): logger.error(f"Invalid payment amount received for Invoice ID {invoice_id}: '{payment_amount_str}'. Cannot validate.") send_telegram_message( user_id, f"Error procesando el pago para la factura {invoice_id}. El monto recibido ('{payment_amount_str}') no es válido. " f"Por favor, contacta con un administrador." ) return expected_amount = len(numbers) * price_per_number # Use a small tolerance for float comparison if abs(payment_amount - expected_amount) > 0.01: logger.error(f"Payment amount mismatch for Invoice ID {invoice_id}. Expected: {expected_amount:.2f}, Received: {payment_amount:.2f}") send_telegram_message( user_id, f"⚠️ La cantidad donada ({payment_amount:.2f}€) para la factura {invoice_id} no coincide con el total esperado ({expected_amount:.2f}€) para los números {', '.join(numbers)}. " f"Por favor, contacta con un administrador." ) # Do NOT confirm the numbers if amount is wrong return # 6. Confirm the numbers in the database if confirm_reserved_numbers(user_id, raffle_id, invoice_id): logger.info(f"Successfully confirmed numbers {numbers} for user {user_id} (Name: {current_user_name}), raffle {raffle_id} with Invoice {invoice_id}.") raffle_name = get_raffle_name(raffle_id) # Get raffle name for user message # Send confirmation to user send_telegram_message( user_id, f"✅ ¡Donación confirmada para la factura {invoice_id}!\n\n" f"Te has apuntado con éxito al sorteo '{raffle_name}' con las participaciones: {', '.join(numbers)}." # Raffle name can be added here if desired, requires one more DB call or adding it to get_user_by_invoice_id ) # Generate table image if generate_table_image(raffle_id): image_path = f"/app/data/raffles/raffle_table_{raffle_id}.png" # Get general raffle details (like description) for announcement caption raffle_details_general = get_raffle(raffle_id) # Fetches name, description, image_id if not raffle_details_general: logger.error(f"Could not fetch general raffle details for ID {raffle_id} after payment confirmation.") return # Or handle more gracefully raffle_description_for_announce = raffle_details_general['description'] remaining_numbers_amount = get_remaining_numbers_amount(raffle_id) # If it's the last number, update the main message and delete the participate button if remaining_numbers_amount == 0: keyboard = None main_announcement = f"🎯🏆🎯 Sorteo '{raffle_name}' terminado 🎯🏆🎯\n\n" main_announcement += f"{raffle_details['description']}\n\n" main_announcement += f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" main_announcement += f"💵 Donación mínima: {raffle_details['price']}€\n" main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" main_message_id = get_main_message_id(raffle_id) requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", json={ "chat_id": channel_id_to_announce, "message_id": main_message_id, "caption": main_announcement, "reply_markup": keyboard, "parse_mode": "HTML" }) else: url = f"https://t.me/{BOT_NAME}?start=join_{raffle_id}" keyboard = { "inline_keyboard": [ [ {"text": "✅ ¡Participar Ahora! ✅", "url": url} ] ] } main_announcement = f"🏆 Sorteo '{raffle_name}' en progreso 🏆\n\n" main_announcement += f"{raffle_details['description']}\n\n" main_announcement += f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" main_announcement += f"💵 Donación mínima: {raffle_details['price']}€\n" main_announcement += f"🗒️ Quedan {remaining_numbers_amount} participaciones disponibles. ¡Date prisa! 🗒️\n\n" main_announcement += f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" main_message_id = get_main_message_id(raffle_id) requests.post(f"{TELEGRAM_API_URL}/editMessageCaption", json={ "chat_id": channel_id_to_announce, "message_id": main_message_id, "caption": main_announcement, "reply_markup": keyboard, "parse_mode": "HTML" }) last_other_participants = get_last_n_other_participants(raffle_id, n=4) last_participants_text = format_last_participants_list(last_other_participants) escaped_current_user_name = current_user_name numbers_text = "" if len(numbers) > 1: numbers_text = f"con las participaciones: {', '.join(numbers)}" else: numbers_text = f"con la participación: {', '.join(numbers)}" new_participant_line = f"🗳️ @{escaped_current_user_name} se ha unido al sorteo {numbers_text}. ¡Mucha suerte! 🗳️" remaining_numbers_text = "" if remaining_numbers_amount > 10: remaining_numbers_text = f"🗒️ Todavía hay {remaining_numbers_amount} participaciones disponibles. 🗒️" elif remaining_numbers_amount == 1: remaining_numbers = get_remaining_numbers(raffle_id) remaining_numbers_text = f"⏰⏰⏰ ¡Última participación! ⏰⏰⏰\n\n" remaining_numbers_text += f"Queda la participación: {remaining_numbers[0]}" elif remaining_numbers_amount == 0: remaining_numbers_text = "⌛ ¡Ya no hay participaciones! ⌛\n\n" remaining_numbers_text += "¡El resultado del sorteo se dará a conocer a las 21:45h!" else: remaining_numbers = get_remaining_numbers(raffle_id) remaining_numbers_text = f"🔔🔔🔔 ¡Últimas {remaining_numbers_amount} participaciones disponibles! 🔔🔔🔔\n\n" remaining_numbers_text += f"Quedan las participaciones: {', '.join(remaining_numbers)}" caption = ( f"{new_participant_line}\n\n" f"{last_participants_text}\n\n" f"{remaining_numbers_text}\n\n" f"{raffle_description_for_announce}\n\n" f"🔎 Ver detalles: https://t.me/{REVERSE_CHANNELS.get(channel_id_to_announce)}/{get_main_message_id(raffle_id)}\n\n" f"🌍 Envío internacional: {'Sí ✅' if raffle_details['international_shipping'] else 'No ❌'}\n" f"💵 Donación mínima: {price_per_number}€\n\n" f"📜 Normas y condiciones: {TYC_DOCUMENT_URL} \n\n" ) update_message_id = get_update_message_id(raffle_id) sent_or_edited_message_id = None if update_message_id: logger.info(f"Attempting to edit message {update_message_id} in channel {channel_id_to_announce}") # Try deleting old message first try: delete_payload = {'chat_id': channel_id_to_announce, 'message_id': update_message_id} delete_response = requests.post(f"{TELEGRAM_API_URL}/deleteMessage", data=delete_payload) if delete_response.status_code == 200: logger.info(f"Successfully deleted old message {update_message_id} in channel {channel_id_to_announce}") else: logger.warning(f"Failed to delete old message {update_message_id} in channel {channel_id_to_announce}: {delete_response.text}. Will send new.") except Exception as e_del: logger.warning(f"Error deleting old message {update_message_id}: {e_del}. Will send new.") # Always send new photo after delete attempt, ensures updated image is shown new_msg_info = send_telegram_photo(channel_id_to_announce, image_path, caption=caption, keyboard=keyboard, parse_mode='HTML') if new_msg_info and isinstance(new_msg_info, dict) and 'message_id' in new_msg_info: # If send_telegram_photo returns message object sent_or_edited_message_id = new_msg_info['message_id'] elif isinstance(new_msg_info, bool) and new_msg_info is True: # If it just returns True/False # We can't get message_id this way. Need send_telegram_photo to return it. logger.warning("send_telegram_photo did not return message_id, cannot store for future edits.") else: # Sending new failed logger.error(f"Failed to send new photo to channel {channel_id_to_announce} after deleting old.") else: # No previous message, send new logger.info(f"No previous message found for raffle {raffle_id} in channel {channel_id_to_announce}. Sending new.") new_msg_info = send_telegram_photo(channel_id_to_announce, image_path, caption=caption, keyboard=keyboard, parse_mode='HTML') # Similar logic to get sent_or_edited_message_id as above if new_msg_info and isinstance(new_msg_info, dict) and 'message_id' in new_msg_info: sent_or_edited_message_id = new_msg_info['message_id'] elif isinstance(new_msg_info, bool) and new_msg_info is True: logger.warning("send_telegram_photo did not return message_id for new message.") if sent_or_edited_message_id: store_update_message_id(raffle_id, sent_or_edited_message_id) # Send image confirmation to user (price not needed in this caption) user_caption = f"¡Apuntado satisfactoriamente al sorteo '{raffle_name}'! Tus participaciones son: {', '.join(numbers)}" send_telegram_photo(user_id, image_path, caption=user_caption) else: logger.error(f"Failed to generate raffle table image for {raffle_id} after payment.") else: # This case means the DB update failed, which is serious if payment was valid. logger.critical(f"CRITICAL: Failed to execute confirm_reserved_numbers for user {user_id}, raffle {raffle_id}, invoice {invoice_id} AFTER successful payment validation.") # Notify admin and possibly the user about the inconsistency send_telegram_message( user_id, f"Error al procesar la factura {invoice_id}. " f"Por favor, contacta con un administrador y dale tu ID de factura." ) # Notify admin (replace ADMIN_CHAT_ID with actual ID or list) # admin_chat_id = "YOUR_ADMIN_CHAT_ID" # send_telegram_message(admin_chat_id, f"CRITICAL DB Error: Failed to confirm numbers for invoice {invoice_id}, user {user_id}, raffle {raffle_id}. Payment was valid. Manual check needed.") def capture_order(order_id, access_token): url = f"{PAYPAL_URL}/v2/checkout/orders/{order_id}/capture" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {access_token}" } response = requests.post(url, headers=headers) response.raise_for_status() return response.json() @app.route("/paypal-webhook", methods=["POST"]) def paypal_webhook(): # 1. Raw JSON body body = request.get_data(as_text=True) # 2. Required headers headers = { "paypal-auth-algo": request.headers.get("PAYPAL-AUTH-ALGO"), "paypal-cert-url": request.headers.get("PAYPAL-CERT-URL"), "paypal-transmission-id": request.headers.get("PAYPAL-TRANSMISSION-ID"), "paypal-transmission-sig": request.headers.get("PAYPAL-TRANSMISSION-SIG"), "paypal-transmission-time": request.headers.get("PAYPAL-TRANSMISSION-TIME"), } # 3. Verify signature access_token = get_paypal_access_token() verify_url = f"{PAYPAL_URL}/v1/notifications/verify-webhook-signature" payload = { "auth_algo": headers["paypal-auth-algo"], "cert_url": headers["paypal-cert-url"], "transmission_id": headers["paypal-transmission-id"], "transmission_sig": headers["paypal-transmission-sig"], "transmission_time": headers["paypal-transmission-time"], "webhook_id": WEBHOOK_ID, "webhook_event": request.json } response = requests.post(verify_url, json=payload, headers={"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}) response.raise_for_status() verification_status = response.json()["verification_status"] if verification_status == "SUCCESS": event = request.json event_type = event["event_type"] logger.info(f"EVENT DATA: {event}") if event_type == "CHECKOUT.ORDER.APPROVED": # process approved order logger.info(f"✅ Order approved: {event['resource']['id']}. Capturing payment...") # Capture payment resource = event["resource"] invoice_id = resource.get("id") # capture ID delete_reservation_timestamp(invoice_id) response = capture_order(invoice_id, access_token) if response.get("status") == "COMPLETED": logger.info(f"Payment for order {invoice_id} captured successfully.") else: logger.error(f"Failed to capture payment for order {invoice_id}. Response: {response}") elif event_type == "PAYMENT.CAPTURE.COMPLETED": logger.info(f"✅ Payment completed: {event['resource']['id']}") resource = event["resource"] invoice_id = resource["supplementary_data"]["related_ids"]["order_id"] payment_status = resource.get("status") payment_amount = resource["amount"]["value"] if not all([invoice_id, payment_status, payment_amount]): logger.warning(f"Missing one or more required fields in VERIFIED IPN data: {resource}") return "Missing Fields", 200 # Acknowledge receipt but don't process # Process the valid payment receive_paypal_payment(invoice_id, payment_status, payment_amount) elif event_type == "PAYMENT.CAPTURE.PENDING": logger.info(f"⏳ Payment pending: {event['resource']['id']}. Awaiting completion.") # Optionally notify user about pending status resource = event["resource"] invoice_id = resource["supplementary_data"]["related_ids"]["order_id"] payment_status = resource.get("status") payment_amount = resource["amount"]["value"] status_details = resource["status_details"]["reason"] delete_reservation_timestamp(invoice_id) if status_details == "PENDING_REVIEW": logger.info(f"Payment for invoice {invoice_id} is pending review. Notifying admins.") for admin_id in ADMIN_IDS: send_telegram_message( admin_id, f"⚠️ El pago para la factura {invoice_id} está pendiente. " f"Estado actual: '{payment_status}'. " f"Detalles: '{status_details}'. " f"Por favor, revisa manualmente si es necesario." ) elif status_details == "REFUNDED": logger.info(f"Payment for invoice {invoice_id} has been refunded. Notifying admins.") for admin_id in ADMIN_IDS: send_telegram_message( admin_id, f"⚠️ El pago para la factura {invoice_id} ha sido reembolsado. " f"Estado actual: '{payment_status}'. " f"Detalles: '{status_details}'. " f"Por favor, revisa manualmente si es necesario." ) elif event_type == "PAYMENT.CAPTURE.DENIED": logger.info(f"❌ Payment denied: {event['resource']['id']}. Notifying user.") resource = event["resource"] invoice_id = resource["supplementary_data"]["related_ids"]["order_id"] payment_status = resource.get("status") payment_amount = resource["amount"]["value"] participant_data = get_user_by_invoice_id(invoice_id) if participant_data: user_id = participant_data['user_id'] cancel_reservation_by_id(participant_data['id']) send_telegram_message( user_id, f"❌ Tu pago para la factura {invoice_id} ha sido denegado. " f"Por favor, inténtalo de nuevo o contacta con un administrador si crees que es un error." ) else: logger.warning(f"Could not find participant data for denied payment invoice {invoice_id}.") else: logger.info(f"ℹ️ Received event: {event_type}") else: logger.info("⚠️ Webhook verification failed") return "", 200 if __name__ == "__main__": # Make sure BOT_TOKEN is loaded if running directly (e.g., via dotenv) # from dotenv import load_dotenv # load_dotenv() # BOT_TOKEN = os.getenv("BOT_TOKEN") # Ensure BOT_TOKEN is available if not BOT_TOKEN: print("Error: BOT_TOKEN environment variable not set.") exit(1) TELEGRAM_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" # Set global URL app.run(port=5000, debug=False, host="0.0.0.0") # Disable debug in production