- Added handling for 'waiting_for_capture' event in the YooKassa webhook to manage bind-only payment flows, including saving payment methods and canceling authorizations. - Introduced a new method in the YooKassaService to cancel payments, improving error handling and logging for payment cancellations. - Updated user detail synchronization to conditionally update descriptions only when they differ from the current panel state, enhancing efficiency.
277 lines
11 KiB
Python
277 lines
11 KiB
Python
import uuid
|
|
import logging
|
|
import asyncio
|
|
from typing import Optional, Dict, Any, List
|
|
|
|
from yookassa import Configuration, Payment as YooKassaPayment
|
|
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
|
from yookassa.domain.common.confirmation_type import ConfirmationType
|
|
|
|
from config.settings import Settings
|
|
|
|
|
|
class YooKassaService:
|
|
|
|
def __init__(self,
|
|
shop_id: Optional[str],
|
|
secret_key: Optional[str],
|
|
configured_return_url: Optional[str],
|
|
bot_username_for_default_return: Optional[str] = None,
|
|
settings_obj: Optional[Settings] = None):
|
|
|
|
self.settings = settings_obj
|
|
|
|
if not shop_id or not secret_key:
|
|
logging.warning(
|
|
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
|
|
"Payment functionality will be DISABLED.")
|
|
self.configured = False
|
|
else:
|
|
try:
|
|
Configuration.configure(shop_id, secret_key)
|
|
self.configured = True
|
|
logging.info(
|
|
f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
|
|
except Exception as e:
|
|
logging.error(f"Failed to configure YooKassa SDK: {e}",
|
|
exc_info=True)
|
|
self.configured = False
|
|
|
|
if configured_return_url:
|
|
self.return_url = configured_return_url
|
|
elif bot_username_for_default_return:
|
|
self.return_url = f"https://t.me/{bot_username_for_default_return}"
|
|
logging.info(
|
|
f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}"
|
|
)
|
|
else:
|
|
self.return_url = "https://example.com/payment_error_no_return_url_configured"
|
|
logging.warning(
|
|
f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. "
|
|
f"Using placeholder: {self.return_url}. Payments may not complete correctly."
|
|
)
|
|
logging.info(
|
|
f"YooKassa Service effective return_url for payments: {self.return_url}"
|
|
)
|
|
|
|
async def create_payment(
|
|
self,
|
|
amount: float,
|
|
currency: str,
|
|
description: str,
|
|
metadata: Dict[str, Any],
|
|
receipt_email: Optional[str] = None,
|
|
receipt_phone: Optional[str] = None,
|
|
save_payment_method: bool = False,
|
|
payment_method_id: Optional[str] = None,
|
|
capture: bool = True,
|
|
bind_only: bool = False) -> Optional[Dict[str, Any]]:
|
|
if not self.configured:
|
|
logging.error("YooKassa is not configured. Cannot create payment.")
|
|
return None
|
|
|
|
if not self.settings:
|
|
logging.error(
|
|
"YooKassaService: Settings object not available. Cannot create payment with receipt details."
|
|
)
|
|
return {
|
|
"error":
|
|
True,
|
|
"internal_message":
|
|
"Service settings (Settings object) not initialized."
|
|
}
|
|
|
|
customer_contact_for_receipt = {}
|
|
if receipt_email:
|
|
customer_contact_for_receipt["email"] = receipt_email
|
|
elif receipt_phone:
|
|
customer_contact_for_receipt["phone"] = receipt_phone
|
|
elif self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
|
customer_contact_for_receipt[
|
|
"email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
|
else:
|
|
logging.error(
|
|
"CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set."
|
|
)
|
|
return {
|
|
"error":
|
|
True,
|
|
"internal_message":
|
|
"YooKassa receipt customer contact (email/phone) missing and no default email configured."
|
|
}
|
|
|
|
try:
|
|
builder = PaymentRequestBuilder()
|
|
builder.set_amount({
|
|
"value": str(round(amount, 2)),
|
|
"currency": currency.upper()
|
|
})
|
|
# For binding cards only, do not capture and set minimal amount
|
|
if bind_only:
|
|
capture = False
|
|
amount = max(amount, 1.00)
|
|
builder.set_capture(capture)
|
|
builder.set_confirmation({
|
|
"type": ConfirmationType.REDIRECT,
|
|
"return_url": self.return_url
|
|
})
|
|
builder.set_description(description)
|
|
builder.set_metadata(metadata)
|
|
if save_payment_method:
|
|
# Ask YooKassa to save method for off-session charges
|
|
builder.set_save_payment_method(True)
|
|
if payment_method_id:
|
|
# Use a previously saved payment method for merchant-initiated payments
|
|
builder.set_payment_method_id(payment_method_id)
|
|
|
|
receipt_items_list: List[Dict[str, Any]] = [{
|
|
"description":
|
|
description[:128],
|
|
"quantity":
|
|
"1.00",
|
|
"amount": {
|
|
"value": str(round(amount, 2)),
|
|
"currency": currency.upper()
|
|
},
|
|
"vat_code":
|
|
str(self.settings.YOOKASSA_VAT_CODE),
|
|
"payment_mode":
|
|
self.settings.YOOKASSA_PAYMENT_MODE,
|
|
"payment_subject":
|
|
self.settings.YOOKASSA_PAYMENT_SUBJECT
|
|
}]
|
|
|
|
receipt_data_dict: Dict[str, Any] = {
|
|
"customer": customer_contact_for_receipt,
|
|
"items": receipt_items_list
|
|
}
|
|
|
|
builder.set_receipt(receipt_data_dict)
|
|
|
|
idempotence_key = str(uuid.uuid4())
|
|
payment_request = builder.build()
|
|
|
|
logging.info(
|
|
f"Creating YooKassa payment (Idempotence-Key: {idempotence_key}). "
|
|
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
|
|
)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
response = await loop.run_in_executor(
|
|
None, lambda: YooKassaPayment.create(payment_request,
|
|
idempotence_key))
|
|
|
|
logging.info(
|
|
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
|
|
)
|
|
|
|
return {
|
|
"id":
|
|
response.id,
|
|
"confirmation_url":
|
|
response.confirmation.confirmation_url
|
|
if response.confirmation else None,
|
|
"status":
|
|
response.status,
|
|
"metadata":
|
|
response.metadata,
|
|
"amount_value":
|
|
float(response.amount.value),
|
|
"amount_currency":
|
|
response.amount.currency,
|
|
"idempotence_key_used":
|
|
idempotence_key,
|
|
"paid":
|
|
response.paid,
|
|
"refundable":
|
|
response.refundable,
|
|
"created_at":
|
|
response.created_at.isoformat() if hasattr(
|
|
response.created_at, 'isoformat') else str(
|
|
response.created_at),
|
|
"description_from_yk":
|
|
response.description,
|
|
"test_mode":
|
|
response.test if hasattr(response, 'test') else None,
|
|
"payment_method": getattr(response, 'payment_method', None),
|
|
}
|
|
except Exception as e:
|
|
logging.error(f"YooKassa payment creation failed: {e}",
|
|
exc_info=True)
|
|
return None
|
|
|
|
async def get_payment_info(
|
|
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
|
|
if not self.configured:
|
|
logging.error(
|
|
"YooKassa is not configured. Cannot get payment info.")
|
|
return None
|
|
|
|
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
|
|
if not self.configured:
|
|
logging.error("YooKassa is not configured. Cannot cancel payment.")
|
|
return False
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
|
|
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
|
|
return True
|
|
except Exception as e:
|
|
logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}")
|
|
return False
|
|
try:
|
|
logging.info(
|
|
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
|
|
)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
payment_info_yk = await loop.run_in_executor(
|
|
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
|
|
|
|
if payment_info_yk:
|
|
logging.info(
|
|
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
|
|
)
|
|
return {
|
|
"id":
|
|
payment_info_yk.id,
|
|
"status":
|
|
payment_info_yk.status,
|
|
"paid":
|
|
payment_info_yk.paid,
|
|
"amount_value":
|
|
float(payment_info_yk.amount.value),
|
|
"amount_currency":
|
|
payment_info_yk.amount.currency,
|
|
"metadata":
|
|
payment_info_yk.metadata,
|
|
"description":
|
|
payment_info_yk.description,
|
|
"refundable":
|
|
payment_info_yk.refundable,
|
|
"created_at":
|
|
payment_info_yk.created_at.isoformat() if hasattr(
|
|
payment_info_yk.created_at, 'isoformat') else str(
|
|
payment_info_yk.created_at),
|
|
"captured_at":
|
|
payment_info_yk.captured_at.isoformat()
|
|
if payment_info_yk.captured_at and hasattr(
|
|
payment_info_yk.captured_at, 'isoformat') else None,
|
|
"payment_method_type":
|
|
payment_info_yk.payment_method.type
|
|
if payment_info_yk.payment_method else None,
|
|
"test_mode":
|
|
payment_info_yk.test
|
|
if hasattr(payment_info_yk, 'test') else None
|
|
}
|
|
else:
|
|
logging.warning(
|
|
f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
|
|
)
|
|
return None
|
|
except Exception as e:
|
|
logging.error(
|
|
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
|
|
exc_info=True)
|
|
return None
|