Files
telerifas/app/paypal_processor.py
2025-09-16 23:00:28 +02:00

501 lines
25 KiB
Python
Raw 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 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, escape_markdown_v2_chars_for_username, 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": "Markdown"
})
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": "Markdown"
})
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 = escape_markdown_v2_chars_for_username(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='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, keyboard=keyboard, 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_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