Files
homelabs-raffle/app/paypal_processor.py
2026-02-22 12:56:42 +01:00

382 lines
20 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# paypal_processor.py
from flask import Flask, request
import logging
import requests
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, escape_markdown_v2_chars_for_username
from database import (
get_user_by_invoice_id, confirm_reserved_numbers,
get_raffle_name, get_raffle,
get_raffle_channels_and_prices, get_remaining_numbers_amount,
store_announcement_message_id, get_announcement_message_id,
get_last_n_other_participants, get_remaining_numbers
)
from config import BOT_TOKEN, TYC_URL, NEWRELIC_API_KEY, BOT_NAME
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, 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 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, transaction_id, payment_status, payment_amount_str):
"""Processes verified PayPal payment data."""
logger.info(f"Processing payment for Invoice: {invoice_id}, TXN: {transaction_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']
price_per_number = participant_data['price_per_number'] # Price is already fetched
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 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"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, transaction_id):
logger.info(f"Successfully confirmed numbers {numbers} for user {user_id} (Name: {current_user_name}), raffle {raffle_id} with TXN {transaction_id}.")
raffle_name = get_raffle_name(raffle_id) # Get raffle name for user message
# 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 al sorteo '{raffle_name}' con los números: {', '.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)
last_other_participants = get_last_n_other_participants(raffle_id, user_id, n=3)
last_participants_text = format_last_participants_list(last_other_participants)
channels_with_their_prices = get_raffle_channels_and_prices(raffle_id)
if channels_with_their_prices:
for channel_price_info in channels_with_their_prices:
channel_id_to_announce = channel_price_info['channel_id']
price_for_this_channel_announce = channel_price_info['price'] # <<< PRICE FOR THIS SPECIFIC CHANNEL
escaped_current_user_name = escape_markdown_v2_chars_for_username(current_user_name)
numbers_text = ""
if len(numbers) > 1:
numbers_text = f"con los números: {', '.join(numbers)}"
else:
numbers_text = f"con el número: {', '.join(numbers)}"
new_participant_line = f"❗️ ¡Nuevo participante! @{escaped_current_user_name} se unió {numbers_text}. ❗️"
remaining_numbers_text = ""
if remaining_numbers_amount > 10:
remaining_numbers_text = f" Quedan {remaining_numbers_amount} números disponibles. "
elif remaining_numbers_amount == 1:
remaining_numbers = get_remaining_numbers(raffle_id)
remaining_numbers_text = f"ℹ️💥💥💥 ¡Último número disponible! 💥💥💥ℹ️\n\n"
remaining_numbers_text += f"Queda el número: {remaining_numbers[0]}"
elif remaining_numbers_amount == 0:
remaining_numbers_text = " ¡Ya no quedan números disponibles! "
else:
remaining_numbers = get_remaining_numbers(raffle_id)
remaining_numbers_text = f"ℹ️💥 ¡Últimos {remaining_numbers_amount} números disponibles! 💥ℹ️\n\n"
remaining_numbers_text += f"Quedan los números: {', '.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"💶 **Donación mínima:** {price_for_this_channel_announce}\n\n" # Use the specific price
f"Normas y condiciones: {TYC_URL} \n\n"
f"¡Apúntate con el comando /sorteo !"
)
previous_message_id = get_announcement_message_id(raffle_id, channel_id_to_announce)
sent_or_edited_message_id = None
if previous_message_id:
logger.info(f"Attempting to edit message {previous_message_id} in channel {channel_id_to_announce}")
# We need to re-send the photo to "edit" it with a new caption effectively,
# or just edit caption if the photo is guaranteed to be the same.
# For simplicity, let's try to just edit the caption of the existing photo.
# If the image itself needs to update (e.g. number grid), then delete + send new.
# Assuming the photo (raffle_table_{raffle_id}.png) is updated by generate_table_image
# We should delete old and send new.
# Try deleting old message first
try:
delete_payload = {'chat_id': channel_id_to_announce, 'message_id': previous_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 {previous_message_id} in channel {channel_id_to_announce}")
else:
logger.warning(f"Failed to delete old message {previous_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 {previous_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, parse_mode='Markdown')
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, parse_mode='Markdown')
# 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_announcement_message_id(raffle_id, channel_id_to_announce, sent_or_edited_message_id)
else:
logger.warning(f"No channels found to announce participation for raffle {raffle_id}")
# Send image confirmation to user (price not needed in this caption)
user_caption = f"Te has apuntado al sorteo '{raffle_name}' con los números: {', '.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"Hubo un error interno al confirmar tu participación para la factura {invoice_id} después de validar tu pago. "
f"Por favor, contacta con un administrador y proporciónales 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.")
@app.route("/paypal-webhook", methods=["POST"])
def paypal_webhook():
"""Webhook to listen for PayPal IPN messages."""
logging.info("Received request on /paypal-webhook")
raw_data = request.get_data() # Get raw data for verification
# logging.debug(f"Raw PayPal data: {raw_data.decode('utf-8')}") # Decode for debug logging if needed
# Verify the IPN message with PayPal
verify_url = "https://ipnpb.paypal.com/cgi-bin/webscr" # Use sandbox URL for testing if needed
# verify_url = "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr"
verify_data = b"cmd=_notify-validate&" + raw_data # Prepend the validation command
try:
response = requests.post(verify_url, data=verify_data, timeout=30) # Add timeout
response.raise_for_status() # Raise HTTP errors
except requests.exceptions.RequestException as e:
logger.error(f"Error verifying IPN with PayPal: {e}")
return "IPN Verification Error", 500 # Return error status
if response.text == "VERIFIED":
# --- Extract needed data from the FORM data (not raw_data) ---
data = request.form.to_dict()
logger.info(f"VERIFIED IPN received. Data: {data}")
# Extract key fields (adjust keys based on your PayPal setup/IPN variables)
invoice_id = data.get("invoice")
transaction_id = data.get("txn_id") # Use PayPal's transaction ID
payment_status = data.get("payment_status")
payment_amount = data.get("mc_gross") # Total amount paid
# custom_id = data.get("custom") # If you passed participant ID here
if not all([invoice_id, transaction_id, payment_status, payment_amount]):
logger.warning(f"Missing one or more required fields in VERIFIED IPN data: {data}")
return "Missing Fields", 200 # Acknowledge receipt but don't process
# Process the valid payment
receive_paypal_payment(invoice_id, transaction_id, payment_status, payment_amount)
elif response.text == "INVALID":
logger.warning(f"INVALID IPN received. Raw data: {raw_data.decode('utf-8')}")
# Consider logging more details from request.form if needed
else:
logger.warning(f"Unexpected response from PayPal verification: {response.text}")
return "", 200 # Always return 200 OK to PayPal IPN
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