From 2bfdb539be084a64d7beaa8dc221c8f67a6730aa Mon Sep 17 00:00:00 2001 From: Joan Date: Mon, 15 Sep 2025 22:45:46 +0200 Subject: [PATCH] Did some things... --- app/app.py | 38 +++++- app/config.py | 6 +- app/database.py | 64 ++++++++-- app/handlers.py | 257 ++++++++++++++++++++++------------------ app/helpers.py | 99 ++++++++++++---- app/keyboards.py | 18 +-- app/paypal_processor.py | 104 +++++++++++----- app/requirements.txt | 13 +- docker-compose.yml | 6 + 9 files changed, 410 insertions(+), 195 deletions(-) diff --git a/app/app.py b/app/app.py index 6590e0f..f7067f0 100644 --- a/app/app.py +++ b/app/app.py @@ -16,7 +16,7 @@ from telegram.ext import ( ContextTypes, ) from telegram.error import Forbidden, BadRequest -# REMOVE: from apscheduler.schedulers.asyncio import AsyncIOScheduler +from newrelic_telemetry_sdk import Log, LogClient # Import handlers and db/config from handlers import * @@ -27,8 +27,35 @@ from config import * logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO ) + +# Suppress overly verbose logs from libraries logging.getLogger("httpx").setLevel(logging.WARNING) -# REMOVE: logging.getLogger("apscheduler").setLevel(logging.WARNING) # No longer needed +logging.getLogger("apscheduler").setLevel(logging.WARNING) + +client = LogClient(license_key=NEWRELIC_API_KEY, host="log-api.eu.newrelic.com") + +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": "telerifas" + } + ) + 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__) @@ -65,7 +92,7 @@ async def check_expired_reservations(context: ContextTypes.DEFAULT_TYPE): cancelled_count += 1 # Try to notify the user using context.bot notification_text = ( - f"Las papeletas `{numbers}` que tenías reservadas para la rifa **{raffle_name}** han sido liberadas.\n\n" + f"Las participaciones `{numbers}` que tenías reservadas para el sorteo **{raffle_name}** han sido liberadas.\n\n" f"Puedes volver a reservarlas, ¡pero tienes {RESERVATION_TIMEOUT_MINUTES} minutos para completar el pago!." ) try: @@ -147,7 +174,7 @@ def main(): # --- Handlers (Remain the same) --- # 1. Raffle Creation Conversation Handler raffle_creation_conv = ConversationHandler( - entry_points=[CommandHandler("crear_rifa", new_raffle_start)], + entry_points=[CommandHandler("crear_sorteo", new_raffle_start)], states={ SELECTING_CHANNEL: [CallbackQueryHandler(select_channel, pattern=f"^{SELECT_CHANNEL_PREFIX}.*")], TYPING_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_title)], @@ -156,6 +183,9 @@ def main(): MessageHandler(filters.PHOTO, receive_image), MessageHandler(filters.TEXT & ~filters.COMMAND, incorrect_input_type) ], + INTERNATIONAL_SHIPPING: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, receive_international_shipping) + ], TYPING_PRICE_FOR_CHANNEL: [ MessageHandler(filters.TEXT & ~filters.COMMAND, receive_price_for_channel) ], diff --git a/app/config.py b/app/config.py index 70a0989..34a7388 100644 --- a/app/config.py +++ b/app/config.py @@ -21,9 +21,13 @@ WEBHOOK_URL = os.getenv("WEBHOOK_URL") WEBHOOK_ID = os.getenv("WEBHOOK_ID") RESERVATION_TIMEOUT_MINUTES = 15 TYC_DOCUMENT_URL = os.getenv("TYC_DOCUMENT_URL") +NEWRELIC_API_KEY = os.getenv("NEWRELIC_API_KEY") +PAYPAL_PERCENTAGE_FEE = float(os.getenv("PAYPAL_PERCENTAGE_FEE", "1.90")) # Default 1.90% +PAYPAL_FIXED_FEE = float(os.getenv("PAYPAL_FIXED_FEE", "0.35")) # Default 0.35 EUR +PAYPAL_URL = os.getenv("PAYPAL_URL", "https://api-m.paypal.com") # Default to live URL # Conversation States for Raffle Creation -(SELECTING_CHANNEL, TYPING_TITLE, TYPING_DESCRIPTION, TYPING_PRICE_FOR_CHANNEL, SENDING_IMAGE, CONFIRMING_CREATION) = range(6) +(SELECTING_CHANNEL, TYPING_TITLE, TYPING_DESCRIPTION, TYPING_PRICE_FOR_CHANNEL, INTERNATIONAL_SHIPPING, SENDING_IMAGE, CONFIRMING_CREATION) = range(7) # Conversation States for Editing Raffles (EDIT_SELECT_RAFFLE, EDIT_SELECT_NEW_CHANNELS, EDIT_TYPING_PRICE_FOR_NEW_CHANNELS, EDIT_CONFIRM) = range(6, 10) diff --git a/app/database.py b/app/database.py index d14ae5f..4f086c3 100644 --- a/app/database.py +++ b/app/database.py @@ -21,6 +21,7 @@ def init_db(): channel_id TEXT NOT NULL, main_message_id INTEGER, update_message_id INTEGER, + international_shipping INTEGER DEFAULT 0, active INTEGER DEFAULT 1 ) """) @@ -67,14 +68,14 @@ def connect_db(): # --- Raffle Management --- (remains the same) # ... create_raffle, end_raffle, get_raffle, etc. ... -def create_raffle(name, description, price, image_file_id, channel_id): +def create_raffle(name, description, price, image_file_id, channel_id, international_shipping=0): """Creates a new raffle in the database.""" conn = connect_db() cur = conn.cursor() try: cur.execute( - "INSERT INTO raffles (name, description, price, image_file_id, channel_id) VALUES (?, ?, ?, ?, ?)", - (name, description, price, image_file_id, channel_id) + "INSERT INTO raffles (name, description, price, image_file_id, channel_id, international_shipping) VALUES (?, ?, ?, ?, ?, ?)", + (name, description, price, image_file_id, channel_id, international_shipping) ) raffle_id = cur.lastrowid conn.commit() @@ -171,12 +172,12 @@ def reserve_number(user_id, user_name, raffle_id, number): if str(number) not in numbers_list: numbers_list.append(str(number)) numbers_str = ','.join(sorted(numbers_list)) - cur.execute("UPDATE participants SET numbers=? WHERE id=?", - (numbers_str, participant_id)) + cur.execute("UPDATE participants SET numbers=?, reservation_timestamp=? WHERE id=?", + (numbers_str, int(time.time()), participant_id)) else: cur.execute( - "INSERT INTO participants (user_id, user_name, raffle_id, numbers, step) VALUES (?, ?, ?, ?, ?)", - (user_id, user_name, raffle_id, str(number), "waiting_for_payment") + "INSERT INTO participants (user_id, user_name, raffle_id, numbers, step, reservation_timestamp) VALUES (?, ?, ?, ?, ?, ?)", + (user_id, user_name, raffle_id, str(number), "waiting_for_payment", int(time.time())) ) conn.commit() except Exception as e: @@ -185,6 +186,20 @@ def reserve_number(user_id, user_name, raffle_id, number): finally: conn.close() +def get_total_participations(raffle_id): + """Gets the total number of participations (both reserved and confirmed) for a raffle.""" + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT COUNT(*) FROM participants WHERE raffle_id=?", (raffle_id,)) + count = cur.fetchone() + return count[0] if count else 0 + except Exception as e: + logger.error(f"Error getting total participations for raffle {raffle_id}: {e}") + return 0 + finally: + conn.close() + def remove_reserved_number(participant_id, number): """Removes a specific number from a 'waiting_for_payment' reservation.""" conn = connect_db() @@ -215,17 +230,17 @@ def remove_reserved_number(participant_id, number): finally: conn.close() -def mark_reservation_pending(participant_id, invoice_id, timestamp): +def mark_reservation_pending(participant_id, invoice_id): """Sets the invoice ID and reservation timestamp for a participant moving to pending payment.""" conn = connect_db() cur = conn.cursor() try: cur.execute( "UPDATE participants SET invoice_id=?, reservation_timestamp=? WHERE id=? AND step='waiting_for_payment'", - (invoice_id, int(timestamp), participant_id) + (invoice_id, int(time.time()), participant_id) ) conn.commit() - logger.info(f"Marked reservation pending for participant {participant_id} with invoice {invoice_id} at {timestamp}") + logger.info(f"Marked reservation pending for participant {participant_id} with invoice {invoice_id} at {time.time()}") except Exception as e: logger.error(f"Error marking reservation pending for participant {participant_id}: {e}") conn.rollback() @@ -481,6 +496,19 @@ def get_remaining_numbers_amount(raffle_id): finally: conn.close() +def get_confirmed_numbers(user_id, raffle_id): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute("SELECT numbers FROM participants WHERE user_id=? AND raffle_id=? AND step='completed'", (user_id, raffle_id)) + numbers = cur.fetchone() + return numbers['numbers'].split(',') if numbers and numbers['numbers'] else [] + except Exception as e: + logger.error(f"Error getting confirmed numbers for user {user_id}, raffle {raffle_id}: {e}") + return [] + finally: + conn.close() + def get_remaining_numbers(raffle_id): """Gets a list of all remaining numbers for a raffle, formatted as two digits.""" conn = connect_db() @@ -599,3 +627,19 @@ def store_paypal_access_token(access_token, expires_in): logger.error(f"Error storing PayPal access token in DB: {e}") finally: conn.close() + +def get_all_invoice_ids(raffle_id): + conn = connect_db() + cur = conn.cursor() + try: + cur.execute( + "SELECT invoice_id FROM participants WHERE raffle_id=? AND step='completed' AND invoice_id IS NOT NULL", + (raffle_id,) + ) + rows = cur.fetchall() + return [row['invoice_id'] for row in rows if row['invoice_id']] + except Exception as e: + logger.error(f"Error getting invoice IDs for raffle {raffle_id}: {e}") + return [] + finally: + conn.close() \ No newline at end of file diff --git a/app/handlers.py b/app/handlers.py index 751530a..0973446 100644 --- a/app/handlers.py +++ b/app/handlers.py @@ -21,8 +21,6 @@ from datetime import time as dtime logger = logging.getLogger(__name__) -# --- Conversation Handler for Raffle Creation --- - async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): user = update.message.from_user args = context.args @@ -36,33 +34,37 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): madrid_tz = pytz.timezone("Europe/Madrid") current_time = datetime.now(madrid_tz) if current_time.time() >= dtime(20, 55) and current_time.time() <= dtime(22, 0): - await update.message.reply_text("No puedes unirte a la rifa en este momento.") + await update.message.reply_text("No puedes unirte al sorteo en este momento.") return - + if len(get_reserved_numbers(user.id, raffle_id)) > 0: + await update.message.reply_text("Ya tienes participaciones reservadas para este sorteo. Por favor, completa la donación o espera a que caduquen antes de unirte de nuevo.") + return + # The user wants to join this raffle. # Start the private conversation for number selection. logger.info(f"User {user.id} started bot with join link for raffle {raffle_id}") context.user_data['joining_raffle_id'] = raffle_id keyboard = generate_numbers_keyboard(raffle_id, user.id) await update.message.reply_text( - f"¡Hola! Vamos a unirnos a la rifa '{raffle['name']}'.\n\n" - f"El precio por papeleta es de {raffle['price']}€.\n\n" + f"¡Hola! Vamos a unirnos al sorteo '{raffle['name']}'.\n\n" + f"🌍 Envío internacional: {'Sí ✅' if raffle['international_shipping'] else 'No ❌'}\n" + f"La donación mínima es de {raffle['price']}€.\n\n" "Por favor, selecciona tus números:", reply_markup=keyboard ) else: - await update.message.reply_text("Esta rifa ya no está activa o no tiene números disponibles.") + await update.message.reply_text("Este sorteo ya no está activo o no tiene números disponibles.") except (ValueError, IndexError): await update.message.reply_text("Enlace de participación inválido.") else: # Generic start message - await update.message.reply_text("Hola, soy el bot de rifas. Puedes participar desde los anuncios en los canales.") + await update.message.reply_text("Hola, soy el bot de La Suerte es Tuya. Puedes participar desde los anuncios en los canales.") async def new_raffle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Starts the conversation to create a new raffle. Asks for channels.""" user_id = update.message.from_user.id if user_id not in ADMIN_IDS: - await update.message.reply_text("No tienes permiso para crear rifas.") + await update.message.reply_text("No tienes permiso para crear sorteos.") return ConversationHandler.END if not CHANNELS: @@ -72,8 +74,8 @@ async def new_raffle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) - context.user_data['new_raffle'] = {'channel': ""} # Initialize data for this user keyboard = generate_channel_selection_keyboard() await update.message.reply_text( - "Vamos a crear una nueva rifa.\n\n" - "**Paso 1:** Selecciona el canal donde se publicará la rifa.", + "Vamos a crear un nuevo sorteo.\n\n" + "**Paso 1:** Selecciona el canal donde se publicará el sorteo.", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) @@ -91,7 +93,7 @@ async def select_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> context.user_data['new_raffle']['channel'] = channel_id await query.edit_message_text( - "Canal seleccionad. Ahora, por favor, envía el **título** de la rifa.", + "Canal seleccionad. Ahora, por favor, envía el **título** del sorteo.", parse_mode=ParseMode.MARKDOWN ) return TYPING_TITLE @@ -108,7 +110,7 @@ async def receive_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i return TYPING_TITLE context.user_data['new_raffle']['title'] = title - await update.message.reply_text("Título guardado. Ahora envía la **descripción** de la rifa.", parse_mode=ParseMode.MARKDOWN) + await update.message.reply_text("Título guardado. Ahora envía la **descripción** del sorteo.", parse_mode=ParseMode.MARKDOWN) return TYPING_DESCRIPTION async def receive_description(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: @@ -119,7 +121,7 @@ async def receive_description(update: Update, context: ContextTypes.DEFAULT_TYPE return TYPING_DESCRIPTION context.user_data['new_raffle']['description'] = description - await update.message.reply_text("Descripción guardada. Ahora envía la **imagen** para la rifa.", parse_mode=ParseMode.MARKDOWN) + await update.message.reply_text("Descripción guardada. Ahora envía la **imagen** para el sorteo.", parse_mode=ParseMode.MARKDOWN) return SENDING_IMAGE async def receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: @@ -132,7 +134,24 @@ async def receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i context.user_data['new_raffle']['image_file_id'] = photo_file_id await update.message.reply_text( - "Imagen guardada.\nAhora, introduce el precio por número para el canal seleccionado (solo el número, ej: 5).", + "Imagen guardada. Ahora, ¿el sorteo permite envíos internacionales? Responde con 'Sí' o 'No'.", + parse_mode=ParseMode.MARKDOWN + ) + return INTERNATIONAL_SHIPPING + +async def receive_international_shipping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Receives whether international shipping is available.""" + text = update.message.text.strip().lower() + if text in ['sí', 'si', 's', 'yes', 'y']: + context.user_data['new_raffle']['international_shipping'] = 1 + elif text in ['no', 'n']: + context.user_data['new_raffle']['international_shipping'] = 0 + else: + await update.message.reply_text("Por favor, responde con 'Sí' o 'No'.") + return INTERNATIONAL_SHIPPING + + await update.message.reply_text( + "Perfecto. Ahora, introduce el precio por número para el canal seleccionado (solo el número, ej: 5).", parse_mode=ParseMode.MARKDOWN ) return TYPING_PRICE_FOR_CHANNEL @@ -144,8 +163,8 @@ async def receive_price_for_channel(update: Update, context: ContextTypes.DEFAUL try: price = int(price_text) - if not (0 <= price <= 999): # Allow higher prices maybe - raise ValueError("El precio debe ser un número entre 0 y 999.") + if not (1 <= price <= 999): # Allow higher prices maybe + raise ValueError("El precio debe ser un número entre 1 y 999.") except ValueError: channel_alias = REVERSE_CHANNELS.get(channel_id, f"ID:{channel_id}") await update.message.reply_text(f"Precio inválido para {channel_alias}. Debe ser un número (ej: 5). Inténtalo de nuevo.") @@ -164,13 +183,14 @@ async def _show_creation_confirmation(update: Update, context: ContextTypes.DEFA price = raffle_data.get('price', 0) confirmation_text = ( - "¡Perfecto! Revisa los datos de la rifa:\n\n" + "¡Perfecto! Revisa los datos del sortoe:\n\n" f"📌 **Título:** {raffle_data.get('title', 'N/A')}\n" f"📝 **Descripción:** {raffle_data.get('description', 'N/A')}\n" f"📺 **Canal:** {REVERSE_CHANNELS.get(channel_id, channel_id)}\n" - f"💶 **Precio:** {price}€\n" + f"🌍 **Envío internacional:** {'Sí ✅' if raffle_data.get('international_shipping', 0) else 'No ❌'}\n" + f"💶 **Donación mínima:** {price}€\n" f"🖼️ **Imagen:** (Adjunta)\n\n" - "¿Confirmas la creación de esta rifa?" + "¿Confirmas la creación de este sorteo?" ) keyboard = generate_confirmation_keyboard() # Message from which confirmation is triggered is the last price input message. @@ -209,10 +229,10 @@ async def confirm_creation(update: Update, context: ContextTypes.DEFAULT_TYPE) - raffle_id = create_raffle(name, description, price, image_file_id, channel_id) if raffle_id: - await context.bot.send_message(query.from_user.id, f"✅ ¡Rifa '{name}' creada con éxito!") + await context.bot.send_message(query.from_user.id, f"✅ ¡Sorteo '{name}' creado con éxito!") await _announce_raffle_in_channels(context, raffle_id, query.from_user.id, initial_announcement=True) else: - await context.bot.send_message(query.from_user.id, f"❌ Error al guardar la rifa. Nombre '{name}' podría existir.") + await context.bot.send_message(query.from_user.id, f"❌ Error al guardar el sorteo. Nombre '{name}' podría existir.") elif query.data == CANCEL_CREATION_CALLBACK: await query.edit_message_caption("Creación cancelada.", reply_markup=None) @@ -225,11 +245,11 @@ async def cancel_creation_command(update: Update, context: ContextTypes.DEFAULT_ user_id = update.message.from_user.id if 'new_raffle' in context.user_data: context.user_data.pop('new_raffle', None) - await update.message.reply_text("Creación de rifa cancelada.") + await update.message.reply_text("Creación de sorteo cancelada.") logger.info(f"Admin {user_id} cancelled raffle creation via /cancel.") return ConversationHandler.END else: - await update.message.reply_text("No hay ninguna creación de rifa en curso para cancelar.") + await update.message.reply_text("No hay ninguna creación de sorteo en curso para cancelar.") return ConversationHandler.END # Or return current state if applicable # ... (other handlers and functions) ... @@ -249,7 +269,7 @@ async def incorrect_input_type(update: Update, context: ContextTypes.DEFAULT_TYP current_conversation_data = context.user_data['new_raffle'] current_state_key = '_new_raffle_conv_state' current_state = context.user_data[current_state_key] - conversation_type = "creación de rifa" + conversation_type = "creación de sorteo" else: # Not in a known conversation, or state tracking key is different. # This message might be outside any conversation this handler is for. @@ -273,24 +293,24 @@ async def incorrect_input_type(update: Update, context: ContextTypes.DEFAULT_TYP logger.warning(f"User {user_id} sent incorrect input type during {conversation_type} (State: {current_state}). Message: {update.message.text or ''}") # --- Handle incorrect input for RAFFLE CREATION states --- - if conversation_type == "creación de rifa": + if conversation_type == "creación de sorteo": if current_state == SENDING_IMAGE: await update.message.reply_text( - "Por favor, envía una IMAGEN para la rifa, no texto u otro tipo de archivo.\n" + "Por favor, envía una IMAGEN para el sorteo, no texto u otro tipo de archivo.\n" "Si quieres cancelar, usa /cancelar." ) return SENDING_IMAGE # Stay in the image sending state elif current_state == TYPING_TITLE: - await update.message.reply_text("Por favor, envía TEXTO para el título de la rifa. Usa /cancelar para salir.") + await update.message.reply_text("Por favor, envía TEXTO para el título del sorteo. Usa /cancelar para salir.") return TYPING_TITLE elif current_state == TYPING_DESCRIPTION: - await update.message.reply_text("Por favor, envía TEXTO para la descripción de la rifa. Usa /cancelar para salir.") + await update.message.reply_text("Por favor, envía TEXTO para la descripción del sorteo. Usa /cancelar para salir.") return TYPING_DESCRIPTION elif current_state == TYPING_PRICE_FOR_CHANNEL: channel_id_for_price = current_conversation_data.get('current_channel_for_price') channel_alias = REVERSE_CHANNELS.get(channel_id_for_price, f"ID:{channel_id_for_price}") if channel_id_for_price else "el canal actual" await update.message.reply_text( - f"Por favor, envía un NÚMERO para el precio de la rifa en {channel_alias}.\n" + f"Por favor, envía un NÚMERO para el precio del sorteo en {channel_alias}.\n" "Usa /cancelar para salir." ) return TYPING_PRICE_FOR_CHANNEL @@ -299,8 +319,8 @@ async def incorrect_input_type(update: Update, context: ContextTypes.DEFAULT_TYP # Generic fallback message if specific state isn't handled above for some reason await update.message.reply_text( "Entrada no válida para este paso de la conversación.\n" - "Si estás creando una rifa, usa /cancelar para salir.\n" - "Si estás editando una rifa, usa /cancelar_edicion para salir." + "Si estás creando un sorteo, usa /cancelar para salir.\n" + "Si estás editando un sorteo, usa /cancelar_edicion para salir." ) return current_state # Return to the state the conversation was in @@ -384,14 +404,14 @@ async def number_callback(update: Update, context: CallbackContext): page = 0 if number < 50 else 1 except ValueError: logger.warning(f"Invalid number value in callback: {value}") - await query.answer("Papeleta no válida.") + await query.answer("Participación no válida.") return elif action == "random_num": # Handle random number selection try: remaining_free_numbers = get_remaining_numbers(raffle_id) if not remaining_free_numbers: - await query.answer("No hay papeletas disponibles para seleccionar aleatoriamente.") + await query.answer("No hay participaciones disponibles para seleccionar aleatoriamente.") return else: # Select a random number from the available ones @@ -401,7 +421,7 @@ async def number_callback(update: Update, context: CallbackContext): page = 0 if int(number_string) < 50 else 1 except ValueError: logger.warning(f"Invalid random number value in callback: {value}") - await query.answer("Papeleta aleatoria no válido.") + await query.answer("Participación aleatoria no válida.") return logger.debug(f"User {user_id} interacted with number {number_string} for raffle {raffle_id}") @@ -419,30 +439,30 @@ async def number_callback(update: Update, context: CallbackContext): if participant_step == "waiting_for_payment": # User clicked a number they have reserved -> Deselect it remove_reserved_number(participant_db_id, number_string) - await query.answer(f"Has quitado la reserva de la papeleta {number_string}.") + await query.answer(f"Has quitado la reserva de la participación {number_string}.") logger.info(f"User {user_id} deselected reserved number {number_string} for raffle {raffle_id}") # Refresh keyboard keyboard = generate_numbers_keyboard(raffle_id, user_id, page=page) await query.edit_message_reply_markup(reply_markup=keyboard) elif participant_step == "completed": # User clicked a number they have paid for -> Inform them - await query.answer(f"Ya tienes la papeleta {number_string} (pagado).") + await query.answer(f"Ya tienes la participación {number_string} (donada).") logger.debug(f"User {user_id} clicked their own completed number {number_string}") else: # Should not happen with current steps, but catch just in case - await query.answer(f"Estado desconocido para tu papeleta {number_string}.") + await query.answer(f"Estado desconocido para tu participación {number_string}.") logger.warning(f"User {user_id} clicked number {number_string} with unexpected step {participant_step}") else: # User clicked a number taken by someone else - status_msg = "reservada" if participant_step == "waiting_for_payment" else "comprada" - await query.answer(f"La papeleta {number_string} ya ha sido {status_msg} por otro usuario.") + status_msg = "reservada" if participant_step == "waiting_for_payment" else "donada" + await query.answer(f"La participación {number_string} ya ha sido {status_msg} por otro usuario.") logger.debug(f"User {user_id} clicked number {number_string} taken by user {participant_user_id}") else: # Number is free -> Reserve it for the user reserve_number(user_id, username, raffle_id, number_string) - await query.answer(f"Papeleta {number_string} reservada para ti. Confirma tu selección cuando termines.") + await query.answer(f"Participación {number_string} reservada para ti. Confirma tu selección cuando termines.") logger.info(f"User {user_id} reserved number {number_string} for raffle {raffle_id}") # Refresh keyboard to show the lock icon keyboard = generate_numbers_keyboard(raffle_id, user_id, page=page) @@ -465,19 +485,19 @@ async def confirm_callback(update: Update, context: CallbackContext): reserved_numbers = get_reserved_numbers(user_id, raffle_id) # Returns a list of strings if not reserved_numbers: - await query.answer("No has seleccionado ninguna papeleta nueva para confirmar.") + await query.answer("No has seleccionado ninguna participación nueva para confirmar.") return - await query.answer("Generando enlace de pago...") # Give feedback + await query.answer("Generando enlace de donación...") # Give feedback raffle_info = get_raffle(raffle_id) if not raffle_info: logger.error(f"Cannot find raffle {raffle_id} during confirmation for user {user_id}") # Use query.edit_message_caption or text depending on what was sent before try: - await query.edit_message_caption("Error: No se pudo encontrar la información de la rifa.", reply_markup=None) + await query.edit_message_caption("Error: No se pudo encontrar la información del sorteo.", reply_markup=None) except BadRequest: - await query.edit_message_text("Error: No se pudo encontrar la información de la rifa.", reply_markup=None) + await query.edit_message_text("Error: No se pudo encontrar la información del sorteo.", reply_markup=None) return # Get participant DB ID (needed for setting invoice ID) @@ -486,9 +506,9 @@ async def confirm_callback(update: Update, context: CallbackContext): if not participant: logger.error(f"Cannot find participant record for user {user_id}, raffle {raffle_id} in 'waiting_for_payment' step during confirmation.") try: - await query.edit_message_caption("Error: No se encontró tu registro. Selecciona las papeletas de nuevo.", reply_markup=None) + await query.edit_message_caption("Error: No se encontró tu registro. Selecciona las participaciones de nuevo.", reply_markup=None) except BadRequest: - await query.edit_message_text("Error: No se encontró tu registro. Selecciona las papeletas de nuevo.", reply_markup=None) + await query.edit_message_text("Error: No se encontró tu registro. Selecciona las participaciones de nuevo.", reply_markup=None) return participant_db_id = participant['id'] @@ -497,37 +517,16 @@ async def confirm_callback(update: Update, context: CallbackContext): logger.error(f"Price not found for raffle {raffle_id} during confirmation for user {user_id}") await query.answer("Error: Ha habido un problema desconocido, contacta con el administrador.", show_alert=True) return - + total_price = len(reserved_numbers) * price_per_number - # Generate a unique invoice ID for PayPal - #invoice_id = str(uuid.uuid4()) - - current_timestamp = time.time() - paypal_link, invoice_id = create_paypal_order(get_paypal_access_token(), total_price) - mark_reservation_pending(participant_db_id, invoice_id, current_timestamp) - - # Construct PayPal link - # Using _xclick for simple payments. Consider PayPal REST API for more robust integration if needed. - # paypal_link = ( - # f"https://sandbox.paypal.com/cgi-bin/webscr?" - # f"cmd=_xclick&business={PAYPAL_EMAIL}" - # f"&item_name=Numeros Rifa con ID: {raffle_info['id']} ({', '.join(reserved_numbers)})" # Item name for clarity - # f"&amount={total_price:.2f}" # Format price to 2 decimal places - # f"¤cy_code=EUR" - # f"&invoice={invoice_id}" # CRITICAL: This links the payment back - # f"&verify_url={WEBHOOK_URL}" # IMPORTANT: Set your actual webhook URL here! - # # custom field can be used for extra data if needed, e.g., participant_db_id again - # f"&custom={participant_db_id}" - # f"&return=https://t.me/{BOT_NAME}" # Optional: URL after successful payment - # f"&cancel_return=https://t.me/{BOT_NAME}" # Optional: URL if user cancels on PayPal - # ) - + paypal_link, invoice_id = create_paypal_order(get_paypal_access_token(), total_price, raffle_info['id'], reserved_numbers) + mark_reservation_pending(participant_db_id, invoice_id) # Log the PayPal link for debugging logger.info(f"Generated PayPal link for user {user_id}: {paypal_link}") # Define the button text and create the URL button - paypal_button_text = "💳 Pagar con PayPal 💳" + paypal_button_text = "💳 Donar con PayPal 💳" paypal_button = InlineKeyboardButton(paypal_button_text, url=paypal_link) # Create the InlineKeyboardMarkup containing the button @@ -536,13 +535,13 @@ async def confirm_callback(update: Update, context: CallbackContext): # Modify the message text - remove the link placeholder, adjust instruction payment_message = ( f"👍 Selección Confirmada 👍\n\n" - f"Papeletas reservadas: {', '.join(reserved_numbers)}\n" - f"Precio total: {total_price:.2f}€\n\n" + f"Participaciones reservadas: {', '.join(reserved_numbers)}\n" + f"Donación mínima: {total_price:.2f}€\n\n" f"El ID de la factura es: {invoice_id}\n" - f"Pulsa el botón para completar el pago en PayPal:\n" # Adjusted instruction + f"Pulsa el botón para completar la donación en PayPal:\n" # Adjusted instruction # Link is now in the button below - f"⚠️ Tienes {RESERVATION_TIMEOUT_MINUTES} minutos para pagar antes de que las papeletas se liberen.\n\n" - f"Una vez hayas pagado, se te notificará aquí. El pago puede tardar hasta 5 minutos en procesarse, sé paciente." + f"⚠️ Tienes {RESERVATION_TIMEOUT_MINUTES} minutos para pagar antes de que las participaciones se liberen.\n\n" + f"Una vez hayas donado, se te notificará aquí. La donación puede tardar hasta 5 minutos en procesarse, sé paciente." ) try: @@ -574,26 +573,26 @@ async def cancel_callback(update: Update, context: CallbackContext): reserved_numbers = get_reserved_numbers(user_id, raffle_id) if not reserved_numbers: - await query.answer("No tienes ninguna selección de papeletas pendiente para cancelar.") + await query.answer("No tienes ninguna selección de participaciones pendiente para cancelar.") # Optionally, revert the message back to the initial raffle selection prompt or just inform the user. # Let's just edit the message to say nothing is pending. try: - await query.edit_message_caption("No hay papeletas reservadas para cancelar.", reply_markup=None) + await query.edit_message_caption("No hay participaciones reservadas para cancelar.", reply_markup=None) except BadRequest: # If message was text, not photo caption - await query.edit_message_text("No hay papeletas reservadas para cancelar.", reply_markup=None) + await query.edit_message_text("No hay participaciones reservadas para cancelar.", reply_markup=None) return # Cancel the reservation in the database cancelled = cancel_reserved_numbers(user_id, raffle_id) # This function deletes the 'waiting_for_payment' row if cancelled: # Check if the function indicated success (e.g., return True or affected rows) - await query.answer(f"Selección cancelada. Papeletas liberadas: {', '.join(reserved_numbers)}") + await query.answer(f"Selección cancelada. Participaciones liberadas: {', '.join(reserved_numbers)}") logger.info(f"User {user_id} cancelled reservation for raffle {raffle_id}. Numbers: {reserved_numbers}") # Edit the message to confirm cancellation try: - await query.edit_message_caption("Tu selección de papeletas ha sido cancelada y las papeletas han sido liberadas.", reply_markup=None) + await query.edit_message_caption("Tu selección de participaciones ha sido cancelada y las participaciones han sido liberadas.", reply_markup=None) except BadRequest: - await query.edit_message_text("Tu selección de papeletas ha sido cancelada y las papeletas han sido liberadas.", reply_markup=None) + await query.edit_message_text("Tu selección de participaciones ha sido cancelada y las participaciones han sido liberadas.", reply_markup=None) # Optionally, you could send the user back to the raffle selection list or number grid. # For simplicity, we just end the interaction here. else: @@ -609,7 +608,7 @@ async def end_raffle_logic(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, w if not raffle_details: logger.error(f"Attempted to end non-existent raffle ID {raffle_id}") try: - await context.bot.send_message(admin_user_id, f"Error: No se encontró la rifa con ID {raffle_id}.") + await context.bot.send_message(admin_user_id, f"Error: No se encontró el sorteo con ID {raffle_id}.") except Exception as e: logger.error(f"Failed to send error message to admin {admin_user_id}: {e}") return False @@ -619,7 +618,7 @@ async def end_raffle_logic(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, w if not raffle_details['active']: logger.warning(f"Admin {admin_user_id} tried to end already inactive raffle '{raffle_name}' (ID: {raffle_id})") try: - await context.bot.send_message(admin_user_id, f"La rifa '{raffle_name}' ya estaba terminada.") + await context.bot.send_message(admin_user_id, f"El sorteo '{raffle_name}' ya estaba terminado.") except Exception as e: logger.error(f"Failed to send 'already inactive' message to admin {admin_user_id}: {e}") return False @@ -628,7 +627,7 @@ async def end_raffle_logic(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, w if not end_raffle(raffle_id): logger.error(f"Failed to mark raffle ID {raffle_id} as inactive in the database.") try: - await context.bot.send_message(admin_user_id, f"Error al marcar la rifa '{raffle_name}' como terminada en la base de datos.") + await context.bot.send_message(admin_user_id, f"Error al marcar el sorteo '{raffle_name}' como terminado en la base de datos.") except Exception as e: logger.error(f"Failed to send DB error message to admin {admin_user_id}: {e}") return False @@ -641,19 +640,20 @@ async def end_raffle_logic(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, w winners_str = get_winners(raffle_id, winner_numbers) formatted_winner_numbers = ", ".join(f"{n:02}" for n in sorted(winner_numbers)) - announcement = f"🎯🏆🎯 **¡Resultados de la Rifa '{raffle_name}'!** 🎯🏆🎯\n\n" - announcement += f"Detalles de la rifa: https://t.me/{channel_alias}/{get_main_message_id(raffle_id)}\n" - announcement += f"Papeletas ganadoras: **{formatted_winner_numbers}**\n\n" if len(winner_numbers) > 1 else f"Papeleta ganadora: **{formatted_winner_numbers}**\n\n" + announcement = f"🎯🏆🎯 **¡Resultados del Sorteo '{raffle_name}'!** 🎯🏆🎯\n\n" + announcement += f"Detalles del sorteo: https://t.me/{channel_alias}/{get_main_message_id(raffle_id)}\n" + announcement += f"Participaciones ganadoras: **{formatted_winner_numbers}**\n\n" if len(winner_numbers) > 1 else f"Participación ganadora: **{formatted_winner_numbers}**\n\n" if winners_str: # Ensure winners_str is not empty or a "no winners" message itself announcement += f"Ganadores:\n{winners_str}\n\n¡Felicidades!" if len(winner_numbers) > 1 else f"Ganador:\n{winners_str}\n\n¡Felicidades!" else: - announcement += "No hubo ganadores para estas papeletas." if len(winner_numbers) > 1 else "No hubo ganador para esta papeleta." + announcement += "No hubo ganadores para estas participaciones." if len(winner_numbers) > 1 else "No hubo ganador para esta participación." announcement += f"\nPuedes comprobar los resultados en {JUEGOS_ONCE_URL}" - announcement += "\n\nGracias a todos por participar. Mantente atento a futuras rifas." + announcement += "\n\nGracias a todos por participar. Mantente atento a futuros sorteos." - main_announcement = f"🎯🏆🎯 **Rifa '{raffle_name}' Terminada** 🎯🏆🎯\n\n" + main_announcement = f"🎯🏆🎯 **Sorteo '{raffle_name}' terminado** 🎯🏆🎯\n\n" main_announcement += f"{raffle_details['description']}\n\n" - main_announcement += f"💵 **Precio por papeleta:** {raffle_details['price']}€\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) @@ -668,6 +668,31 @@ async def end_raffle_logic(context: ContextTypes.DEFAULT_TYPE, raffle_id: int, w except Exception as e: logger.error(f"Failed to announce winners in channel {channel_alias} (ID: {channel_id_str}): {e}") + # Notify admin of success + try: + for admin_user_id in ADMIN_IDS: + await context.bot.send_message(admin_user_id, f"Sorteo '{raffle_name}' terminado y ganadores anunciados.") + number_of_participations = get_total_participations(raffle_id) + price_per_number = raffle_details['price'] + invoice_ids = get_all_invoice_ids(raffle_id) + for inv_id in invoice_ids: + gross, net, fees = get_paypal_amounts_for_invoice(inv_id) + total_gross += gross + total_net += net + total_fees += fees + await context.bot.send_message( + admin_user_id, + f"Resumen del sorteo '{raffle_name}':\n" + f"- Participaciones totales: {number_of_participations}\n" + f"- Donación mínima por participación: {price_per_number}€\n" + f"- Total recaudado: {total_gross}€\n" + f"- Total en comisiones de PayPal: {total_fees:.2f}€\n" + f"- Beneficio neto estimado: {total_net}€" + ) + + except Exception as e: + logger.error(f"Failed to notify admin {admin_user_id} about ended raffle '{raffle_name}': {e}") + return True # --- Admin Menu Handlers --- @@ -710,8 +735,8 @@ async def admin_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE if data == ADMIN_MENU_CREATE: # Guide admin to use the conversation command await query.edit_message_text( - "Para crear una nueva rifa, por favor, inicia la conversación usando el comando:\n\n" - "/crear_rifa\n\n" + "Para crear un nuevo sorteo, inicia la conversación usando el comando:\n\n" + "/crear_sorteo\n\n" "Pulsa el botón abajo para volver al menú principal.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Volver al Menú", callback_data=ADMIN_MENU_BACK_MAIN)]]) ) @@ -720,7 +745,7 @@ async def admin_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE logger.info(f"Admin {user_id} requested raffle list.") keyboard = generate_admin_list_raffles_keyboard() active_raffles = get_active_raffles() - message_text = "**Rifas Activas**\n\nSelecciona una rifa para ver detalles, anunciar o terminar:" if active_raffles else "**Rifas Activas**\n\nNo hay rifas activas." + message_text = "**Sorteos Activos**\n\nSelecciona un sorteo para ver detalles, anunciar o terminar:" if active_raffles else "**Sorteos Activas**\n\nNo hay sorteos activos." await query.edit_message_text(message_text, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) elif data == ADMIN_MENU_BACK_MAIN: @@ -730,39 +755,40 @@ async def admin_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE # --- Raffle Specific Actions --- elif data.startswith(ADMIN_VIEW_RAFFLE_PREFIX): try: + await query.answer("Cargando detalles del sorteo...") raffle_id = int(data[len(ADMIN_VIEW_RAFFLE_PREFIX):]) logger.info(f"Admin {user_id} requested details for raffle {raffle_id}") details_text = format_raffle_details(raffle_id) # Use helper details_keyboard = generate_admin_raffle_details_keyboard(raffle_id) - await query.edit_message_text(details_text, reply_markup=details_keyboard, parse_mode=ParseMode.MARKDOWN) + await query.edit_message_text(details_text, reply_markup=details_keyboard, parse_mode=ParseMode.HTML) except (ValueError, IndexError): logger.error(f"Invalid callback data for view raffle: {data}") - await query.edit_message_text("Error: ID de rifa inválido.", reply_markup=generate_admin_list_raffles_keyboard()) + await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_list_raffles_keyboard()) elif data.startswith(ADMIN_ANNOUNCE_RAFFLE_PREFIX): try: raffle_id = int(data[len(ADMIN_ANNOUNCE_RAFFLE_PREFIX):]) logger.info(f"Admin {user_id} requested re-announce for raffle {raffle_id}") - await query.edit_message_text(f"📢 Re-anunciando la rifa {get_raffle_name(raffle_id)}...", reply_markup=None) # Give feedback + await query.edit_message_text(f"📢 Re-anunciando el sorteo {get_raffle_name(raffle_id)}...", reply_markup=None) # Give feedback await _announce_raffle_in_channels(context, raffle_id, user_id) # Optionally go back to the list or details view after announcing keyboard = generate_admin_list_raffles_keyboard() # Go back to list - await context.bot.send_message(user_id, "Volviendo a la lista de rifas:", reply_markup=keyboard) + await context.bot.send_message(user_id, "Volviendo a la lista de sorteos:", reply_markup=keyboard) except (ValueError, IndexError): logger.error(f"Invalid callback data for announce raffle: {data}") - await query.edit_message_text("Error: ID de rifa inválido.", reply_markup=generate_admin_list_raffles_keyboard()) + await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_list_raffles_keyboard()) elif data.startswith(ADMIN_END_RAFFLE_PROMPT_PREFIX): try: raffle_id = int(data[len(ADMIN_END_RAFFLE_PROMPT_PREFIX):]) except (ValueError, IndexError): logger.error(f"Invalid callback data for end raffle prompt: {data}") - await query.edit_message_text("Error: ID de rifa inválido.", reply_markup=generate_admin_main_menu_keyboard()) + await query.edit_message_text("Error: ID de sorteo inválido.", reply_markup=generate_admin_main_menu_keyboard()) return raffle = get_raffle(raffle_id) if not raffle or not raffle['active']: - await query.edit_message_text("Esta rifa no existe o ya ha terminado.", reply_markup=generate_admin_list_raffles_keyboard()) + await query.edit_message_text("Este sorteo no existe o ya ha terminado.", reply_markup=generate_admin_list_raffles_keyboard()) return # Store raffle ID and set flag to expect winner numbers @@ -772,8 +798,8 @@ async def admin_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE keyboard = generate_admin_cancel_end_keyboard() await query.edit_message_text( - f"Vas a terminar la rifa: **{raffle['name']}**\n\n" - "Por favor, envía ahora las **papeletas ganadoras** separadas por espacios (ej: `7 23 81`).", + f"Vas a terminar el sorteo: **{raffle['name']}**\n\n" + "Por favor, envía ahora las **participaciones ganadoras** separadas por espacios (ej: `7 23 81`).", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) @@ -784,10 +810,10 @@ async def admin_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE logger.info(f"Admin {user_id} cancelled the raffle end process.") # Go back to the raffle list keyboard = generate_admin_list_raffles_keyboard() - await query.edit_message_text("**Rifas Activas**\n\nSelecciona una rifa para terminarla:", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) + await query.edit_message_text("**Sorteos Activos**\n\nSelecciona un sorteo para terminarlo:", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN) elif data == ADMIN_NO_OP: - # Just ignore clicks on placeholder buttons like "No hay rifas activas" + # Just ignore clicks on placeholder buttons like "No hay sorteos activos" pass async def admin_receive_winner_numbers(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -825,8 +851,8 @@ async def admin_receive_winner_numbers(update: Update, context: ContextTypes.DEF logger.warning(f"Invalid winner numbers format from admin {user_id}: {e}") keyboard = generate_admin_cancel_end_keyboard() await update.message.reply_text( - f"❌ Papeletas inválidas: {e}\n\n" - "Por favor, envía las papeletas ganadoras (0-99) separadas por espacios (ej: `7 23 81`).", + f"❌ Participaciones inválidas: {e}\n\n" + "Por favor, envía las participaciones ganadoras (0-99) separadas por espacios (ej: `7 23 81`).", reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) @@ -839,7 +865,7 @@ async def admin_receive_winner_numbers(update: Update, context: ContextTypes.DEF context.user_data.pop('admin_ending_raffle_id', None) # Call the refactored ending logic - await update.message.reply_text(f"Procesando finalización con papeletas: {', '.join(f'{n:02}' for n in winner_numbers)}...") + await update.message.reply_text(f"Procesando finalización con participaciones: {', '.join(f'{n:02}' for n in winner_numbers)}...") success = await end_raffle_logic(context, raffle_id_to_end, winner_numbers, user_id) # Send admin back to the main menu after processing @@ -852,14 +878,14 @@ async def _announce_raffle_in_channels(context: ContextTypes.DEFAULT_TYPE, raffl if not raffle: logger.warning(f"Admin {admin_user_id} tried to announce non-existent raffle {raffle_id}.") try: - await context.bot.send_message(admin_user_id, f"Error: El ID de la rifa {raffle_id} no existe.") + await context.bot.send_message(admin_user_id, f"Error: El ID del sorteo {raffle_id} no existe.") except Exception as e: logger.error(f"Failed to send 'raffle not found' error to admin {admin_user_id}: {e}") return False if not initial_announcement and not raffle['active']: # For re-announcements, it must be active logger.warning(f"Admin {admin_user_id} tried to re-announce inactive raffle {raffle_id} ('{raffle['name']}').") try: - await context.bot.send_message(admin_user_id, f"Error: La rifa '{raffle['name']}' (ID {raffle_id}) no está activa y no se puede re-anunciar.") + await context.bot.send_message(admin_user_id, f"Error: El sorteo '{raffle['name']}' (ID {raffle_id}) no está activo y no se puede re-anunciar.") except Exception as e: logger.error(f"Failed to send 'raffle inactive' error to admin {admin_user_id}: {e}") return False @@ -878,11 +904,12 @@ async def _announce_raffle_in_channels(context: ContextTypes.DEFAULT_TYPE, raffl channel_alias = REVERSE_CHANNELS.get(channel_id_str, f"ID:{channel_id_str}") announce_caption = ( - f"🏆 **¡{'Nueva ' if initial_announcement else ''}Rifa Disponible!** 🏆\n\n" + f"🏆 **¡{'Nuevo ' if initial_announcement else ''}Sorteo Disponible!** 🏆\n\n" f"🌟 **{raffle_name}** 🌟\n\n" f"{raffle_description}\n\n" - f"💵 **Precio por papeleta:** {price}€\n" - f"🎟️ **Papeletas disponibles:** {remaining_count if remaining_count >= 0 else 'N/A'}\n\n" + f"🌍 **Envío internacional:** {'Sí ✅' if raffle['international_shipping'] else 'No ❌'}\n" + f"💵 **Donación mínima:** {price}€\n" + f"🎟️ **Participaciones disponibles:** {remaining_count if remaining_count >= 0 else 'N/A'}\n\n" f"📜 Normas y condiciones: {TYC_DOCUMENT_URL}" ) diff --git a/app/helpers.py b/app/helpers.py index 1842e8d..63d3e74 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -12,22 +12,23 @@ def format_raffle_details(raffle_id): """Fetches and formats raffle details for display, including multi-channel prices.""" raffle = get_raffle(raffle_id) # Fetches basic info from 'raffles' table if not raffle: - return "Error: No se encontró la rifa." + return "Error: No se encontró el sorteo." details = ( - f"ℹ️ **Detalles de la rifa** ℹ️\n\n" - f"**ID:** `{raffle['id']}`\n" - f"**Nombre:** {raffle['name']}\n" - f"**Descripción:**\n{raffle['description']}\n\n" - f"**Activo:** {'Sí' if raffle['active'] else 'No (Terminado)'}\n" - f"**Precio por número (canal principal):** {raffle['price']}€\n" + f"ℹ️ Detalles del sorteo ℹ️\n\n" + f"ID: {raffle['id']}\n" + f"Nombre: {raffle['name']}\n" + f"Descripción:\n{raffle['description'][:100]}...\n\n" + f"Envío internacional: {'Sí' if raffle['international_shipping'] else 'No'}\n" + f"Activo: {'Sí' if raffle['active'] else 'No (Terminado)'}\n" + f"Donación mínima (canal principal): {raffle['price']}€\n" ) # Image ID (optional display) if raffle['image_file_id']: - details += f"**ID Imagen:** (Presente)\n" + details += f"ID Imagen: {raffle['image_file_id']} (Presente)\n" else: - details += f"**ID Imagen:** (No asignada)\n" + details += f"ID Imagen: (No asignada)\n" # Add participant count and remaining numbers participants = get_participants(raffle_id) # Fetches list of Rows @@ -38,11 +39,25 @@ def format_raffle_details(raffle_id): # pending_participants_count = sum(1 for p in participants if p['step'] == 'waiting_for_payment') - details += f"\n**Participantes Confirmados:** {completed_participants_count}\n" - # details += f"**Reservas Pendientes:** {pending_participants_count}\n" + details += f"\nParticipantes Confirmados: {completed_participants_count}\n" + # details += f"Reservas Pendientes: {pending_participants_count}\n" remaining_count = get_remaining_numbers_amount(raffle_id) - details += f"**Números Disponibles:** {remaining_count if remaining_count >= 0 else 'Error al calcular'}\n" + details += f"Números Disponibles: {remaining_count if remaining_count >= 0 else 'Error al calcular'}\n" + + # Gross and net amounts + total_gross = 0.0 + total_fees = 0.0 + total_net = 0.0 + invoice_ids = get_all_invoice_ids(raffle_id) + for inv_id in invoice_ids: + gross, net, fees = get_paypal_amounts_for_invoice(inv_id) + total_gross += gross + total_fees += fees + total_net += net + details += f"\nTotal Recaudado (bruto): {total_gross:.2f}€\n" + details += f"Total Gastos (comisiones): {total_fees:.2f}€\n" + details += f"Total Beneficio (neto): {total_net:.2f}€\n" return details @@ -131,12 +146,12 @@ def get_winners(raffle_id, winner_numbers_int): # if not raffle_details: # logger.error(f"Cannot generate image: Raffle {raffle_id} not found.") # # Draw error message on image -# draw.text((10, 10), f"Error: Rifa {raffle_id} no encontrado", fill="red", font=title_font) +# draw.text((10, 10), f"Error: Sorteo {raffle_id} no encontrado", fill="red", font=title_font) # img.save(f"/app/data/raffles/raffle_table_{raffle_id}_error.png") # return False # Indicate failure # raffle_name = raffle_details['name'] -# title_text = f"Rifa: {raffle_name}" +# title_text = f"Sorteo: {raffle_name}" # # Calculate text bounding box for centering # try: # # Use textbbox for more accurate centering @@ -262,12 +277,12 @@ def generate_table_image(raffle_id): # --- Title Bar --- raffle_details = get_raffle(raffle_id) if not raffle_details: - draw.text((10, 10), f"Error: Rifa {raffle_id} no encontrada", fill="red", font=title_font) + draw.text((10, 10), f"Error: Sorteo {raffle_id} no encontrado", fill="red", font=title_font) img.save(f"/app/data/raffles/raffle_table_{raffle_id}_error.png") return False raffle_name = raffle_details['name'] - title_text = f"Rifa: {raffle_name}" + title_text = f"Sorteo: {raffle_name}" # Draw title bar (full width) title_bar_color = "#4a90e2" @@ -403,7 +418,7 @@ def get_paypal_access_token(): logger.info(f"Using cached PayPal access token") return old_token logger.info("Fetching new PayPal access token") - url = "https://api-m.sandbox.paypal.com/v1/oauth2/token" + url = f"{PAYPAL_URL}/v1/oauth2/token" headers = {"Accept": "application/json", "Accept-Language": "en_US"} data = {"grant_type": "client_credentials"} @@ -413,8 +428,8 @@ def get_paypal_access_token(): store_paypal_access_token(response.json()["access_token"], response.json()["expires_in"]) return response.json()["access_token"] -def create_paypal_order(access_token, value): - url = "https://api-m.sandbox.paypal.com/v2/checkout/orders" +def create_paypal_order(access_token, value, raffle_id, numbers): + url = f"{PAYPAL_URL}/v2/checkout/orders" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {access_token}" @@ -423,10 +438,12 @@ def create_paypal_order(access_token, value): "intent": "CAPTURE", "purchase_units": [ { - "amount": {"currency_code": "EUR", "value": f"{value:.2f}"} + "amount": {"currency_code": "EUR", "value": f"{value:.2f}"}, + "description": f"Donación para participar en el sorteo de HomeLabs Club (ID: {raffle_id}, Números: {numbers})", } ], "application_context": { + "locale": "es-ES", "return_url": f"https://t.me/{BOT_NAME}", "cancel_url": f"https://t.me/{BOT_NAME}" } @@ -439,3 +456,45 @@ def create_paypal_order(access_token, value): # Extract the approval link approval_url = next(link["href"] for link in order["links"] if link["rel"] == "approve") return approval_url, order["id"] + + +def get_paypal_amounts_for_invoice(invoice_id): + """Fetches the gross, net, and fee amounts for a given PayPal invoice ID.""" + access_token = get_paypal_access_token() + url = f"{PAYPAL_URL}/v2/checkout/orders/{invoice_id}" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}" + } + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + except requests.RequestException as e: + logger.error(f"Error fetching PayPal invoice {invoice_id}: {e}") + return 0.0, 0.0, 0.0 + + order = response.json() + + if order["status"] != "COMPLETED": + logger.warning(f"Invoice {invoice_id} is not completed. Status: {order['status']}") + return 0.0, 0.0, 0.0 + + gross_amount = float(order["purchase_units"][0]["amount"]["value"]) + fee_amount = 0.0 + net_amount = gross_amount + + # Fetch capture details to get fee information + capture_id = order["purchase_units"][0]["payments"]["captures"][0]["id"] + capture_url = f"{PAYPAL_URL}/v2/payments/captures/{capture_id}" + + capture_response = requests.get(capture_url, headers=headers) + capture_response.raise_for_status() + capture_details = capture_response.json() + + if "seller_receivable_breakdown" in capture_details: + breakdown = capture_details["seller_receivable_breakdown"] + fee_amount = float(breakdown.get("paypal_fee", {}).get("value", 0.0)) + net_amount = float(breakdown.get("net_amount", {}).get("value", gross_amount)) + + return gross_amount, net_amount, fee_amount \ No newline at end of file diff --git a/app/keyboards.py b/app/keyboards.py index 0f0db5e..2633efd 100644 --- a/app/keyboards.py +++ b/app/keyboards.py @@ -72,10 +72,10 @@ def generate_numbers_keyboard(raffle_id, user_id, page=0): if paging_buttons: keyboard.append(paging_buttons) - action_buttons_row = [ - InlineKeyboardButton("✨ Número Aleatorio ✨", callback_data=f"random_num:{raffle_id}") - ] - keyboard.append(action_buttons_row) + #action_buttons_row = [ + # InlineKeyboardButton("✨ Número Aleatorio ✨", callback_data=f"random_num:{raffle_id}") + #] + #keyboard.append(action_buttons_row) # Add Confirm/Cancel Buttons confirm_cancel_row = [ @@ -90,8 +90,8 @@ def generate_numbers_keyboard(raffle_id, user_id, page=0): def generate_admin_main_menu_keyboard(): keyboard = [ - [InlineKeyboardButton("➕ Crear Nueva Rifa", callback_data=ADMIN_MENU_CREATE)], - [InlineKeyboardButton("📋 Listar/Gestionar Rifas", callback_data=ADMIN_MENU_LIST)], + [InlineKeyboardButton("➕ Crear Nuevo Sorteo", callback_data=ADMIN_MENU_CREATE)], + [InlineKeyboardButton("📋 Listar/Gestionar Sorteos", callback_data=ADMIN_MENU_LIST)], ] return InlineKeyboardMarkup(keyboard) @@ -102,7 +102,7 @@ def generate_admin_list_raffles_keyboard(): keyboard = [] if not active_raffles: - keyboard.append([InlineKeyboardButton("No hay rifas activas.", callback_data=ADMIN_NO_OP)]) + keyboard.append([InlineKeyboardButton("No hay sorteos activos.", callback_data=ADMIN_NO_OP)]) else: for raffle in active_raffles: raffle_id = raffle['id'] @@ -122,7 +122,7 @@ def generate_admin_raffle_details_keyboard(raffle_id): keyboard = [ # Add relevant actions here if needed later, e.g., edit description? [InlineKeyboardButton("📢 Anunciar de Nuevo", callback_data=f"{ADMIN_ANNOUNCE_RAFFLE_PREFIX}{raffle_id}")], - [InlineKeyboardButton("🏁 Terminar Rifa", callback_data=f"{ADMIN_END_RAFFLE_PROMPT_PREFIX}{raffle_id}")], + [InlineKeyboardButton("🏁 Terminar Sorteo", callback_data=f"{ADMIN_END_RAFFLE_PROMPT_PREFIX}{raffle_id}")], [InlineKeyboardButton("⬅️ Volver a la Lista", callback_data=ADMIN_MENU_LIST)] # Back to list view ] return InlineKeyboardMarkup(keyboard) @@ -150,7 +150,7 @@ def generate_confirmation_keyboard(): """Generates Yes/No keyboard for final confirmation.""" keyboard = [ [ - InlineKeyboardButton("✅ Sí, crear rifa", callback_data=CONFIRM_CREATION_CALLBACK), + InlineKeyboardButton("✅ Sí, crear sorteo", callback_data=CONFIRM_CREATION_CALLBACK), InlineKeyboardButton("❌ No, cancelar", callback_data=CANCEL_CREATION_CALLBACK), ] ] diff --git a/app/paypal_processor.py b/app/paypal_processor.py index 2bb81bb..cada401 100644 --- a/app/paypal_processor.py +++ b/app/paypal_processor.py @@ -14,11 +14,12 @@ from database import ( get_user_by_invoice_id, confirm_reserved_numbers, get_raffle_name, get_raffle, get_remaining_numbers_amount, - store_main_message_id, get_main_message_id, + get_main_message_id, store_update_message_id, get_update_message_id, get_last_n_other_participants, get_remaining_numbers ) -from config import BOT_TOKEN, BOT_NAME, WEBHOOK_ID, TYC_DOCUMENT_URL, REVERSE_CHANNELS +from config import BOT_TOKEN, BOT_NAME, WEBHOOK_ID, TYC_DOCUMENT_URL, REVERSE_CHANNELS, NEWRELIC_API_KEY, PAYPAL_URL +from newrelic_telemetry_sdk import Log, LogClient app = Flask(__name__) @@ -26,6 +27,33 @@ app = Flask(__name__) 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 @@ -135,7 +163,7 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): # send_telegram_message( # user_id, # f"El estado de tu pago para la factura {invoice_id} es '{payment_status}'. " - # f"La rifa solo se confirma con pagos 'Completed'. Contacta con un administrador si crees que es un error." + # f"El sorteo solo se confirma con pagos 'Completed'. Contacta con un administrador si crees que es un error." # ) # return @@ -157,7 +185,7 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): 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 pagada ({payment_amount:.2f}€) para la factura {invoice_id} no coincide con el total esperado ({expected_amount:.2f}€) para los números {', '.join(numbers)}. " + 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 @@ -172,8 +200,8 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): # Send confirmation to user send_telegram_message( user_id, - f"✅ ¡Pago confirmado para la factura {invoice_id}!\n\n" - f"Te has apuntado con éxito a la rifa '{raffle_name}' con las papeletas: {', '.join(numbers)}." + 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 ) @@ -193,9 +221,10 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): # 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"🎯🏆🎯 **Rifa '{raffle_name}' Terminada** 🎯🏆🎯\n\n" + main_announcement = f"🎯🏆🎯 **Sorteo '{raffle_name}' terminado** 🎯🏆🎯\n\n" main_announcement += f"{raffle_details['description']}\n\n" - main_announcement += f"💵 **Precio por papeleta:** {raffle_details['price']}€\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={ @@ -214,10 +243,11 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): ] ] } - main_announcement = f"🏆 Rifa '{raffle_name}' en progreso 🏆\n\n" + main_announcement = f"🏆 Sorteo '{raffle_name}' en progreso 🏆\n\n" main_announcement += f"{raffle_details['description']}\n\n" - main_announcement += f"💵 **Precio por papeleta:** {raffle_details['price']}€\n" - main_announcement += f"🗒️ Quedan {remaining_numbers_amount} papeletas disponibles. ¡Date prisa! 🗒️\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={ @@ -235,26 +265,26 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): numbers_text = "" if len(numbers) > 1: - numbers_text = f"con las papeletas: {', '.join(numbers)}" + numbers_text = f"con las participaciones: {', '.join(numbers)}" else: - numbers_text = f"con la papeleta: {', '.join(numbers)}" + numbers_text = f"con la participación: {', '.join(numbers)}" - new_participant_line = f"🗳️ @{escaped_current_user_name} se ha unido a la rifa {numbers_text}. ¡Mucha suerte! 🗳️" + 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} papeletas. 🗒️" + 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 papeleta! ⏰⏰⏰\n\n" - remaining_numbers_text += f"Queda la papeleta: {remaining_numbers[0]}" + 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 papeletas! ⌛\n\n" - remaining_numbers_text += "¡El resultado de la rifa se dará a conocer a las 21:45h!" + 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} papeletas disponibles! 🔔🔔🔔\n\n" - remaining_numbers_text += f"Quedan las papeletas: {', '.join(remaining_numbers)}" + 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" @@ -262,7 +292,8 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): 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"💵 Precio por papeleta: {price_per_number}€\n\n" # Use the specific price + 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" ) @@ -307,7 +338,7 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): 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 a la rifa '{raffle_name}'! Tus números son: {', '.join(numbers)}" + 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: @@ -326,6 +357,15 @@ def receive_paypal_payment(invoice_id, payment_status, payment_amount_str): # 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(): @@ -343,7 +383,7 @@ def paypal_webhook(): # 3. Verify signature access_token = get_paypal_access_token() - verify_url = "https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature" + verify_url = f"{PAYPAL_URL}/v1/notifications/verify-webhook-signature" payload = { "auth_algo": headers["paypal-auth-algo"], @@ -367,11 +407,18 @@ def paypal_webhook(): logger.info(f"EVENT DATA: {event}") if event_type == "CHECKOUT.ORDER.APPROVED": # process approved order - logger.info(f"✅ Order approved: {event['resource']['id']}") + logger.info(f"✅ Order approved: {event['resource']['id']}. Capturing payment...") + # Capture payment resource = event["resource"] invoice_id = resource.get("id") # capture ID - payment_status = resource.get("status") # e.g. COMPLETED - payment_amount = resource["purchase_units"][0]["amount"]["value"] + capture_order(invoice_id, access_token) + 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}") @@ -379,9 +426,6 @@ def paypal_webhook(): # Process the valid payment receive_paypal_payment(invoice_id, payment_status, payment_amount) - elif event_type == "PAYMENT.CAPTURE.COMPLETED": - logger.info(f"✅ Payment completed: {event['resource']['id']}") - # Extract key fields (adjust keys based on your PayPal setup/IPN variables) else: logger.info(f"ℹ️ Received event: {event_type}") else: diff --git a/app/requirements.txt b/app/requirements.txt index 4db9ce2..208e78b 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,8 +1,9 @@ -python-telegram-bot[ext]==21.1.1 # Use specific version, ensure [ext] is included +python-telegram-bot[ext]==21.1.1 python-dotenv==1.0.1 -pillow==10.2.0 # Use specific version -requests==2.31.0 # Use specific version -Flask==3.0.0 # Use specific version -APScheduler==3.10.4 # Add APScheduler +pillow==10.2.0 +requests==2.31.0 +Flask==3.0.0 +APScheduler==3.10.4 pytz==2025.2 -beautifulsoup4==4.13.5 \ No newline at end of file +beautifulsoup4==4.13.5 +newrelic-telemetry-sdk==0.8.0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e2bbb65..f4f6d31 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,10 @@ services: - WEBHOOK_URL=${WEBHOOK_URL} - WEBHOOK_ID=${WEBHOOK_ID} - TYC_DOCUMENT_URL=${TYC_DOCUMENT_URL} + - NEWRELIC_API_KEY=${NEWRELIC_API_KEY} + - PAYPAL_PERCENTAGE_FEE=${PAYPAL_PERCENTAGE_FEE} + - PAYPAL_FIXED_FEE=${PAYPAL_FIXED_FEE} + - PAYPAL_URL=${PAYPAL_URL} telerifas_paypal_processor: build: context: app @@ -44,6 +48,8 @@ services: - WEBHOOK_URL=${WEBHOOK_URL} - WEBHOOK_ID=${WEBHOOK_ID} - TYC_DOCUMENT_URL=${TYC_DOCUMENT_URL} + - NEWRELIC_API_KEY=${NEWRELIC_API_KEY} + - PAYPAL_URL=${PAYPAL_URL} networks: - traefik labels: