First commit
This commit is contained in:
402
app/paypal_processor.py
Normal file
402
app/paypal_processor.py
Normal file
@@ -0,0 +1,402 @@
|
||||
# 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,
|
||||
store_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
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Enable logging
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO
|
||||
)
|
||||
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"La rifa 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, 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"✅ ¡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)}."
|
||||
# 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"🎯🏆🎯 **Rifa '{raffle_name}' Terminada** 🎯🏆🎯\n\n"
|
||||
main_announcement += f"{raffle_details['description']}\n\n"
|
||||
main_announcement += f"💵 **Precio por papeleta:** {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"🏆 Rifa '{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"📜 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 papeletas: {', '.join(numbers)}"
|
||||
else:
|
||||
numbers_text = f"con la papeleta: {', '.join(numbers)}"
|
||||
|
||||
new_participant_line = f"🗳️ @{escaped_current_user_name} se ha unido a la rifa {numbers_text}. ¡Mucha suerte! 🗳️"
|
||||
|
||||
remaining_numbers_text = ""
|
||||
if remaining_numbers_amount > 10:
|
||||
remaining_numbers_text = f"🗒️ Todavía hay {remaining_numbers_amount} papeletas. 🗒️"
|
||||
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]}"
|
||||
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!"
|
||||
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)}"
|
||||
|
||||
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"💵 Precio por papeleta: {price_per_number}€\n\n" # Use the specific price
|
||||
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 a la rifa '{raffle_name}'! Tus números 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.")
|
||||
|
||||
|
||||
@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 = "https://api-m.sandbox.paypal.com/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']}")
|
||||
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"]
|
||||
|
||||
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.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:
|
||||
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
|
||||
Reference in New Issue
Block a user