Files
telerifas/app/paypal_processor.py
2025-10-29 11:14:34 +01:00

395 lines
18 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, get_paypal_access_token, is_vip_member_of_homelabs, send_raffle_update_image
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, VIP_DISCOUNT_PER_NUMBER
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()
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)
original_price_per_number = raffle_details['price']
is_vip = is_vip_member_of_homelabs(user_id)
if is_vip:
logger.info(f"User {user_id} ({current_user_name}) is a VIP member. Applying discount.")
price_per_number = max(1, raffle_details['price'] - VIP_DISCOUNT_PER_NUMBER) # Ensure price doesn't go negative
else:
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
)
# Send raffle update image with participant info
if send_raffle_update_image(raffle_id, current_user_name, numbers, BOT_TOKEN):
# Send image confirmation to user
if generate_table_image(raffle_id):
image_path = f"/app/data/raffles/raffle_table_{raffle_id}.png"
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 send raffle update 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 notify_user_of_pending_review(invoice_id, payment_status, status_details):
"""Notify user that their payment is pending review."""
participant_data = get_user_by_invoice_id(invoice_id)
if not participant_data:
logger.warning(f"No participant found for Invoice ID: {invoice_id}. Cannot notify about pending review.")
return
user_id = participant_data['user_id']
send_telegram_message(
user_id,
f"⚠️ Tu pago para la factura {invoice_id} está pendiente de revisión por PayPal.\n"
f"El estado actual es '{payment_status}' con detalles: '{status_details}'.\n"
f"El sorteo solo se confirma con pagos 'Completed'. Cuando el pago sea confirmado, recibirás una notificación.\n"
f"Tus números reservados se mantendrán durante este proceso."
)
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)
notify_user_of_pending_review(invoice_id, payment_status, status_details)
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