Code Refactoring

This commit is contained in:
machka
2025-05-14 08:43:23 +00:00
parent 7f94d794d6
commit d406baf0c1
33 changed files with 6015 additions and 3571 deletions
+80 -63
View File
@@ -1,97 +1,114 @@
import logging
import asyncio
from aiogram import Bot
from apscheduler .schedulers .asyncio import AsyncIOScheduler
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from datetime import datetime
from config .settings import Settings
from config.settings import Settings
from .subscription_service import SubscriptionService
from db .database import get_db_connection_manager
from bot .middlewares .i18n import JsonI18n
from db.database import get_db_connection_manager
from bot.middlewares.i18n import JsonI18n
from .panel_api_service import PanelApiService
async def send_expiration_warnings (bot :Bot ,settings :Settings ,i18n :JsonI18n ,panel_service :PanelApiService ):
async def send_expiration_warnings(bot: Bot, settings: Settings,
i18n: JsonI18n,
panel_service: PanelApiService):
"""
Sends expiration warnings to users whose subscriptions are ending soon.
This function is called by the scheduler.
"""
logging .info (f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}...")
logging.info(
f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}..."
)
sub_service = SubscriptionService(get_db_connection_manager, settings,
panel_service)
sub_service =SubscriptionService (get_db_connection_manager ,settings ,panel_service )
expiring_subs = await sub_service.get_subscriptions_ending_soon(
settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
expiring_subs =await sub_service .get_subscriptions_ending_soon (settings .SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS )
if not expiring_subs :
logging .info ("No subscriptions found ending soon for notification.")
if not expiring_subs:
logging.info("No subscriptions found ending soon for notification.")
return
logging .info (f"Found {len(expiring_subs)} subscriptions ending soon for notification.")
for sub_info in expiring_subs :
user_id =sub_info ['user_id']
if not user_id :
logging .warning (f"Skipping notification for subscription without user_id: {sub_info}")
logging.info(
f"Found {len(expiring_subs)} subscriptions ending soon for notification."
)
for sub_info in expiring_subs:
user_id = sub_info['user_id']
if not user_id:
logging.warning(
f"Skipping notification for subscription without user_id: {sub_info}"
)
continue
user_lang =sub_info .get ('language_code')if sub_info .get ('language_code')else getattr (settings ,'DEFAULT_LANGUAGE','en')
first_name =sub_info .get ('first_name','User')
end_date_str =sub_info ['end_date_str']
user_lang = sub_info.get('language_code') if sub_info.get(
'language_code') else getattr(settings, 'DEFAULT_LANGUAGE', 'en')
first_name = sub_info.get('first_name', 'User')
end_date_str = sub_info['end_date_str']
days_left_float =sub_info .get ('days_left')
days_left_display ='N/A'
if days_left_float is not None :
days_left_float = sub_info.get('days_left')
days_left_display = 'N/A'
if days_left_float is not None:
days_left_display =max (0 ,int (round (days_left_float )))
days_left_display = max(0, int(round(days_left_float)))
_ =lambda key ,**kwargs :i18n .gettext (user_lang ,key ,**kwargs )
message_text =_ (
"subscription_ending_soon_notification",
user_name =first_name ,
end_date =end_date_str ,
days_left =days_left_display
)
try :
await bot .send_message (user_id ,message_text )
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
message_text = _("subscription_ending_soon_notification",
user_name=first_name,
end_date=end_date_str,
days_left=days_left_display)
try:
await bot.send_message(user_id, message_text)
await sub_service .update_last_notification_sent (user_id ,end_date_str )
logging .info (f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}")
except Exception as e :
await sub_service.update_last_notification_sent(
user_id, end_date_str)
logging.info(
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}"
)
except Exception as e:
logging .error (f"Failed to send expiration warning to user {user_id}: {e}")
await asyncio .sleep (0.1 )
logging.error(
f"Failed to send expiration warning to user {user_id}: {e}")
await asyncio.sleep(0.1)
async def schedule_subscription_notifications (bot :Bot ,settings :Settings ,i18n :JsonI18n ,scheduler :AsyncIOScheduler ):
async def schedule_subscription_notifications(bot: Bot, settings: Settings,
i18n: JsonI18n,
scheduler: AsyncIOScheduler):
"""Schedules the daily job to send expiration warnings."""
async def job_wrapper():
panel_service = PanelApiService(settings)
try:
async def job_wrapper ():
panel_service =PanelApiService (settings )
try :
await send_expiration_warnings(bot, settings, i18n, panel_service)
except Exception as e:
logging.error(
f"Error in scheduled job 'send_expiration_warnings': {e}",
exc_info=True)
finally:
await panel_service.close_session()
await send_expiration_warnings (bot ,settings ,i18n ,panel_service )
except Exception as e :
logging .error (f"Error in scheduled job 'send_expiration_warnings': {e}",exc_info =True )
finally :
await panel_service .close_session ()
try:
notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC)
notification_minute = int(
settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC)
except (ValueError, TypeError):
logging.warning(
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC."
)
notification_hour = 9
notification_minute = 0
try :
notification_hour =int (settings .SUBSCRIPTION_NOTIFICATION_HOUR_UTC )
notification_minute =int (settings .SUBSCRIPTION_NOTIFICATION_MINUTE_UTC )
except (ValueError ,TypeError ):
logging .warning ("SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC.")
notification_hour =9
notification_minute =0
scheduler .add_job (
job_wrapper ,
'cron',
hour =notification_hour ,
minute =notification_minute ,
name ="daily_subscription_expiration_warnings",
misfire_grace_time =60 *15
scheduler.add_job(job_wrapper,
'cron',
hour=notification_hour,
minute=notification_minute,
name="daily_subscription_expiration_warnings",
misfire_grace_time=60 * 15)
logging.info(
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
)
logging .info (f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC.")
+219 -143
View File
@@ -1,183 +1,259 @@
import aiohttp
import logging
from typing import Optional ,List ,Dict ,Any
from datetime import datetime ,timedelta ,timezone
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta, timezone
from config .settings import Settings
from config.settings import Settings
class PanelApiService :
def __init__ (self ,settings :Settings ):
self .settings =settings
self .base_url =settings .PANEL_API_URL
self .api_key =settings .PANEL_API_KEY
self ._session :Optional [aiohttp .ClientSession ]=None
self .default_client_ip ="127.0.0.1"
async def _get_session (self )->aiohttp .ClientSession :
if self ._session is None or self ._session .closed :
self ._session =aiohttp .ClientSession ()
return self ._session
class PanelApiService:
async def close_session (self ):
if self ._session and not self ._session .closed :
await self ._session .close ()
self ._session =None
logging .info ("Panel API service session closed.")
def __init__(self, settings: Settings):
self.settings = settings
self.base_url = settings.PANEL_API_URL
self.api_key = settings.PANEL_API_KEY
self._session: Optional[aiohttp.ClientSession] = None
self.default_client_ip = "127.0.0.1"
async def _prepare_headers (self )->Dict [str ,str ]:
headers ={
"Content-Type":"application/json",
"X-Forwarded-Proto":"https",
"X-Forwarded-For":self .default_client_ip ,
"X-Real-IP":self .default_client_ip ,
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close_session(self):
if self._session and not self._session.closed:
await self._session.close()
self._session = None
logging.info("Panel API service session closed.")
async def _prepare_headers(self) -> Dict[str, str]:
headers = {
"Content-Type": "application/json",
"X-Forwarded-Proto": "https",
"X-Forwarded-For": self.default_client_ip,
"X-Real-IP": self.default_client_ip,
}
if self .api_key :
headers ["Authorization"]=f"Bearer {self.api_key}"
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
async def _request (self ,method :str ,endpoint :str ,**kwargs )->Optional [Dict [str ,Any ]]:
if not self .base_url :
logging .error ("Panel API URL not configured.")
return {"error":True ,"status_code":0 ,"message":"Panel API URL not configured."}
async def _request(self, method: str, endpoint: str,
**kwargs) -> Optional[Dict[str, Any]]:
if not self.base_url:
logging.error("Panel API URL not configured.")
return {
"error": True,
"status_code": 0,
"message": "Panel API URL not configured."
}
session =await self ._get_session ()
headers =await self ._prepare_headers ()
session = await self._get_session()
headers = await self._prepare_headers()
if "Authorization"not in headers and self .api_key :
logging .warning (f"Authorization header missing for panel endpoint {endpoint} despite API key being set.")
if "Authorization" not in headers and self.api_key:
logging.warning(
f"Authorization header missing for panel endpoint {endpoint} despite API key being set."
)
url =f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
json_payload_for_log =kwargs .get ('json')if method in ["POST","PATCH","PUT"]else None
log_prefix =f"Panel API {method} {url}"
if json_payload_for_log :log_prefix +=f" Payload: {json_payload_for_log}"
url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
json_payload_for_log = kwargs.get('json') if method in [
"POST", "PATCH", "PUT"
] else None
log_prefix = f"Panel API {method} {url}"
if json_payload_for_log:
log_prefix += f" Payload: {json_payload_for_log}"
try :
async with session .request (method ,url ,headers =headers ,**kwargs )as response :
if 200 <=response .status <300 :
try :
data =await response .json ()
logging .debug (f"{log_prefix} - Success ({response.status})")
try:
async with session.request(method, url, headers=headers,
**kwargs) as response:
if 200 <= response.status < 300:
try:
data = await response.json()
logging.debug(
f"{log_prefix} - Success ({response.status})")
return data
except aiohttp .ContentTypeError :
logging .debug (f"{log_prefix} - Success ({response.status}) with non-JSON response.")
return {"status":"success","code":response .status ,"data_text":await response .text ()}
else :
try :
error_json =await response .json ()
logging .error (f"{log_prefix} - Failed ({response.status}): {error_json}")
return {"error":True ,"status_code":response .status ,"response":error_json ,
"message":error_json .get ("message"),"errorCode":error_json .get ("errorCode")}
except aiohttp .ContentTypeError :
error_text =await response .text ()
logging .error (f"{log_prefix} - Failed ({response.status}): {error_text}")
return {"error":True ,"status_code":response .status ,"message":error_text }
except aiohttp .ClientError as e :
logging .error (f"Panel API client request error to {url}: {e}")
return {"error":True ,"status_code":-1 ,"message":str (e )}
except Exception as e :
logging .error (f"Unexpected Panel API request error to {url}: {e}",exc_info =True )
return {"error":True ,"status_code":-2 ,"message":f"Unexpected error: {str(e)}"}
except aiohttp.ContentTypeError:
logging.debug(
f"{log_prefix} - Success ({response.status}) with non-JSON response."
)
return {
"status": "success",
"code": response.status,
"data_text": await response.text()
}
else:
try:
error_json = await response.json()
logging.error(
f"{log_prefix} - Failed ({response.status}): {error_json}"
)
return {
"error": True,
"status_code": response.status,
"response": error_json,
"message": error_json.get("message"),
"errorCode": error_json.get("errorCode")
}
except aiohttp.ContentTypeError:
error_text = await response.text()
logging.error(
f"{log_prefix} - Failed ({response.status}): {error_text}"
)
return {
"error": True,
"status_code": response.status,
"message": error_text
}
except aiohttp.ClientError as e:
logging.error(f"Panel API client request error to {url}: {e}")
return {"error": True, "status_code": -1, "message": str(e)}
except Exception as e:
logging.error(f"Unexpected Panel API request error to {url}: {e}",
exc_info=True)
return {
"error": True,
"status_code": -2,
"message": f"Unexpected error: {str(e)}"
}
async def get_users_by_filter (self ,username :Optional [str ]=None )->Optional [List [Dict [str ,Any ]]]:
async def get_users_by_filter(
self,
username: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
"""Fetches users from panel by username."""
if not username :
logging .warning ("get_users_by_filter called without username.")
if not username:
logging.warning("get_users_by_filter called without username.")
return None
params ={"username":username }
response_data =await self ._request ("GET","/users",params =params )
params = {"username": username}
response_data = await self._request("GET", "/users", params=params)
if response_data and not response_data .get ("error"):
users_list =response_data .get ("response",{}).get ("users",[])
logging .info (f"Found {len(users_list)} panel users matching filter: {params}")
if response_data and not response_data.get("error"):
users_list = response_data.get("response", {}).get("users", [])
logging.info(
f"Found {len(users_list)} panel users matching filter: {params}"
)
return users_list
logging .error (f"Failed to fetch panel users with filter {params}. Response: {response_data}")
logging.error(
f"Failed to fetch panel users with filter {params}. Response: {response_data}"
)
return None
async def create_panel_user (
self ,
username :str ,
telegram_id :Optional [int ]=None ,
email :Optional [str ]=None ,
default_expire_days :int =1 ,
default_traffic_limit_bytes :int =0 ,
default_traffic_limit_strategy :str ="NO_RESET",
specific_inbound_uuids :Optional [List [str ]]=None ,
activate_all_inbounds_default_flag :bool =True
)->Optional [Dict [str ,Any ]]:
async def create_panel_user(
self,
username: str,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
default_expire_days: int = 1,
default_traffic_limit_bytes: int = 0,
default_traffic_limit_strategy: str = "NO_RESET",
specific_inbound_uuids: Optional[List[str]] = None,
activate_all_inbounds_default_flag: bool = True
) -> Optional[Dict[str, Any]]:
if not (6 <=len (username )<=34 and username .replace ('_','').replace ('-','').isalnum ()):
msg =f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)."
logging .error (msg )
return {"error":True ,"status_code":400 ,"message":msg ,"response":{"message":msg ,"errorCode":"VALIDATION_ERROR"}}
if not (6 <= len(username) <= 34
and username.replace('_', '').replace('-', '').isalnum()):
msg = f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)."
logging.error(msg)
return {
"error": True,
"status_code": 400,
"message": msg,
"response": {
"message": msg,
"errorCode": "VALIDATION_ERROR"
}
}
now =datetime .now (timezone .utc )
expire_at_dt =now +timedelta (days =default_expire_days )
expire_at_iso =expire_at_dt .isoformat (timespec ='milliseconds').replace ('+00:00','Z')
now = datetime.now(timezone.utc)
expire_at_dt = now + timedelta(days=default_expire_days)
expire_at_iso = expire_at_dt.isoformat(
timespec='milliseconds').replace('+00:00', 'Z')
payload :Dict [str ,Any ]={
"username":username ,
"expireAt":expire_at_iso ,
"trafficLimitStrategy":default_traffic_limit_strategy ,
"trafficLimitBytes":default_traffic_limit_bytes ,
payload: Dict[str, Any] = {
"username": username,
"expireAt": expire_at_iso,
"trafficLimitStrategy": default_traffic_limit_strategy,
"trafficLimitBytes": default_traffic_limit_bytes,
}
if specific_inbound_uuids :
payload ["activeUserInbounds"]=specific_inbound_uuids
if specific_inbound_uuids:
payload["activeUserInbounds"] = specific_inbound_uuids
payload["activateAllInbounds"] = False
else:
payload["activateAllInbounds"] = activate_all_inbounds_default_flag
if telegram_id is not None: payload["telegramId"] = telegram_id
if email: payload["email"] = email
payload ["activateAllInbounds"]=False
else :
payload ["activateAllInbounds"]=activate_all_inbounds_default_flag
return await self._request("POST", "/users", json=payload)
if telegram_id is not None :payload ["telegramId"]=telegram_id
if email :payload ["email"]=email
async def update_user_details_on_panel(
self, user_uuid: str,
update_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if 'uuid' not in update_payload: update_payload['uuid'] = user_uuid
return await self ._request ("POST","/users",json =payload )
update_payload.pop('activateAllInbounds', None)
async def update_user_details_on_panel (self ,user_uuid :str ,update_payload :Dict [str ,Any ])->Optional [Dict [str ,Any ]]:
if 'uuid'not in update_payload :update_payload ['uuid']=user_uuid
update_payload .pop ('activateAllInbounds',None )
full_response =await self ._request ("PATCH","/users",json =update_payload )
if full_response and not full_response .get ("error")and full_response .get ("response"):
logging .info (f"User {user_uuid} details updated on panel.")
return full_response .get ("response")
logging .error (f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}")
full_response = await self._request("PATCH",
"/users",
json=update_payload)
if full_response and not full_response.get(
"error") and full_response.get("response"):
logging.info(f"User {user_uuid} details updated on panel.")
return full_response.get("response")
logging.error(
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}"
)
return None
async def get_all_panel_users (self ,page_size :int =100 )->Optional [List [Dict [str ,Any ]]]:
all_users =[];start_offset =0
while True :
params ={"size":page_size ,"start":start_offset }
response_data =await self ._request ("GET","/users",params =params )
if not response_data or response_data .get ("error"):logging .error (f"Failed to fetch panel users batch: {response_data}");return None
users_batch =response_data .get ("response",{}).get ("users",[])
if not users_batch :break
all_users .extend (users_batch )
if len (users_batch )<page_size :break
start_offset +=page_size
logging .info (f"Fetched {len(all_users)} users from panel API.");return all_users
async def get_all_panel_users(self,
page_size: int = 100
) -> Optional[List[Dict[str, Any]]]:
all_users = []
start_offset = 0
while True:
params = {"size": page_size, "start": start_offset}
response_data = await self._request("GET", "/users", params=params)
if not response_data or response_data.get("error"):
logging.error(
f"Failed to fetch panel users batch: {response_data}")
return None
users_batch = response_data.get("response", {}).get("users", [])
if not users_batch: break
all_users.extend(users_batch)
if len(users_batch) < page_size: break
start_offset += page_size
logging.info(f"Fetched {len(all_users)} users from panel API.")
return all_users
async def get_user_by_uuid (self ,user_uuid :str )->Optional [Dict [str ,Any ]]:
full_response =await self ._request ("GET",f"/users/{user_uuid}")
if full_response and not full_response .get ("error")and full_response .get ("response"):
return full_response .get ("response")
async def get_user_by_uuid(self,
user_uuid: str) -> Optional[Dict[str, Any]]:
full_response = await self._request("GET", f"/users/{user_uuid}")
if full_response and not full_response.get(
"error") and full_response.get("response"):
return full_response.get("response")
return None
async def update_user_status_on_panel (self ,user_uuid :str ,enable :bool )->bool :
endpoint =f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}"
response_data =await self ._request ("POST",endpoint )
if response_data and not response_data .get ("error")and (response_data .get ("response")or response_data .get ("status")=="success"):
logging .info (f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}.");return True
logging .error (f"Failed to update user {user_uuid} status on panel. Resp: {response_data}");return False
async def update_user_status_on_panel(self, user_uuid: str,
enable: bool) -> bool:
endpoint = f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}"
response_data = await self._request("POST", endpoint)
if response_data and not response_data.get("error") and (
response_data.get("response")
or response_data.get("status") == "success"):
logging.info(
f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}."
)
return True
logging.error(
f"Failed to update user {user_uuid} status on panel. Resp: {response_data}"
)
return False
async def get_subscription_link (self ,short_uuid_or_sub_uuid :str ,client_type :Optional [str ]=None )->Optional [str ]:
if not self .settings .PANEL_API_URL :return None
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
async def get_subscription_link(
self,
short_uuid_or_sub_uuid: str,
client_type: Optional[str] = None) -> Optional[str]:
if not self.settings.PANEL_API_URL: return None
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
+160 -116
View File
@@ -1,144 +1,188 @@
import uuid
import logging
from yookassa import Configuration ,Payment
from yookassa .domain .request .payment_request_builder import PaymentRequestBuilder
from yookassa import Configuration, Payment
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
from typing import Optional, Dict, Any, List
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: 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. Payment functionality will be disabled."
)
self.configured = False
else:
Configuration.account_id = shop_id
Configuration.secret_key = secret_key
self.configured = True
logging.info(f"YooKassa configured for shop_id: {shop_id}")
if configured_return_url:
self.return_url = configured_return_url
elif bot_username_for_default:
self.return_url = f"https://t.me/{bot_username_for_default}"
logging.info(
f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}"
)
else:
self.return_url = "https://example.com/payment_error_no_return_url"
logging.warning(
f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}"
)
logging.info(
f"YooKassa Service effective return_url: {self.return_url}")
from typing import Optional ,Dict ,Any ,List
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 :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. Payment functionality will be disabled.")
self .configured =False
else :
Configuration .account_id =shop_id
Configuration .secret_key =secret_key
self .configured =True
logging .info (f"YooKassa configured for shop_id: {shop_id}")
if configured_return_url :
self .return_url =configured_return_url
elif bot_username_for_default :
self .return_url =f"https://t.me/{bot_username_for_default}"
logging .info (f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}")
else :
self .return_url ="https://example.com/payment_error_no_return_url"
logging .warning (f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}")
logging .info (f"YooKassa Service effective return_url: {self.return_url}")
async def create_payment (self ,amount :float ,currency :str ,description :str ,metadata :Dict [str ,Any ])->Optional [Dict [str ,Any ]]:
if not self .configured :
logging .error ("YooKassa is not configured. Cannot create payment.")
async def create_payment(
self, amount: float, currency: str, description: str,
metadata: Dict[str, Any]) -> 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 for receipt creation.")
return {"error":True ,"internal_message":"Service settings not initialized."}
if not self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL :
logging .error ("CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact.")
return {"error":True ,"internal_message":"YooKassa receipt email not configured by admin."}
try :
builder =PaymentRequestBuilder ()
builder .set_amount ({"value":str (round (amount ,2 )),"currency":currency .upper ()})
builder .set_capture (True )
builder .set_confirmation ({"type":"redirect","return_url":self .return_url })
builder .set_description (description )
builder .set_metadata (metadata )
receipt_items :List [Dict [str ,Any ]]=[
{
"description":description ,
"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_customer :Dict [str ,str ]={}
if self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL :
receipt_customer ["email"]=self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL
if not receipt_customer :
logging .error ("YooKassa: No customer contact (email/phone) for receipt.")
return {"error":True ,"internal_message":"Receipt customer contact missing."}
receipt_payload :Dict [str ,Any ]={
"customer":receipt_customer ,
"items":receipt_items
if not self.settings:
logging.error(
"YooKassaService: Settings object not available for receipt creation."
)
return {
"error": True,
"internal_message": "Service settings not initialized."
}
if not self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
logging.error(
"CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact."
)
return {
"error":
True,
"internal_message":
"YooKassa receipt email not configured by admin."
}
builder .set_receipt (receipt_payload )
try:
builder = PaymentRequestBuilder()
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
builder.set_confirmation({
"type": "redirect",
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
receipt_items: List[Dict[str, Any]] = [{
"description":
description,
"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_customer: Dict[str, str] = {}
if self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
receipt_customer[
"email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
if not receipt_customer:
logging.error(
"YooKassa: No customer contact (email/phone) for receipt.")
return {
"error": True,
"internal_message": "Receipt customer contact missing."
}
receipt_payload: Dict[str, Any] = {
"customer": receipt_customer,
"items": receipt_items
}
builder.set_receipt(receipt_payload)
idempotence_key =str (uuid .uuid4 ())
payment_request =builder .build ()
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logging .info (f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}")
logging.info(
f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}"
)
res =Payment .create (payment_request ,idempotence_key )
logging .info (f"YooKassa Payment.create response: ID={res.id}, Status={res.status}")
res = Payment.create(payment_request, idempotence_key)
logging.info(
f"YooKassa Payment.create response: ID={res.id}, Status={res.status}"
)
return {
"id":res .id ,
"confirmation_url":res .confirmation .confirmation_url if res .confirmation else None ,
"status":res .status ,
"metadata":res .metadata ,
"amount_value":float (res .amount .value ),
"amount_currency":res .amount .currency ,
"idempotence_key":idempotence_key ,
"paid":res .paid ,
"refundable":res .refundable ,
"created_at":res .created_at .isoformat ()if hasattr (res .created_at ,'isoformat')else str (res .created_at )
"id":
res.id,
"confirmation_url":
res.confirmation.confirmation_url
if res.confirmation else None,
"status":
res.status,
"metadata":
res.metadata,
"amount_value":
float(res.amount.value),
"amount_currency":
res.amount.currency,
"idempotence_key":
idempotence_key,
"paid":
res.paid,
"refundable":
res.refundable,
"created_at":
res.created_at.isoformat() if hasattr(
res.created_at, 'isoformat') else str(res.created_at)
}
except Exception as e :
logging .error (f"YooKassa payment creation failed: {e}",exc_info =True )
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 :str )->Optional [Dict [str ,Any ]]:
if not self .configured :
logging .error ("YooKassa is not configured. Cannot get payment info.")
async def get_payment_info(self,
payment_id: str) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error(
"YooKassa is not configured. Cannot get payment info.")
return None
try :
payment_info =Payment .find_one (payment_id )
if payment_info :
return {"id":payment_info .id ,"status":payment_info .status ,"paid":payment_info .paid ,
"amount_value":float (payment_info .amount .value ),"amount_currency":payment_info .amount .currency ,
"metadata":payment_info .metadata ,"description":payment_info .description ,}
try:
payment_info = Payment.find_one(payment_id)
if payment_info:
return {
"id": payment_info.id,
"status": payment_info.status,
"paid": payment_info.paid,
"amount_value": float(payment_info.amount.value),
"amount_currency": payment_info.amount.currency,
"metadata": payment_info.metadata,
"description": payment_info.description,
}
return None
except Exception as e:
logging.error(
f"YooKassa get payment info for {payment_id} failed: {e}")
return None
except Exception as e :
logging .error (f"YooKassa get payment info for {payment_id} failed: {e}")
return None
+67 -67
View File
@@ -1,88 +1,88 @@
import logging
import aiosqlite
from datetime import datetime ,timezone
from typing import Optional ,Dict ,Callable ,Any ,Tuple
from datetime import datetime, timezone
from typing import Optional, Dict, Callable, Any, Tuple
from aiogram import Bot
from config .settings import Settings
from db .database import get_promo_code_by_code ,increment_promo_activation ,get_db_connection_manager ,_setup_db_connection
from config.settings import Settings
from db.database import get_promo_code_by_code, increment_promo_activation, get_db_connection_manager, _setup_db_connection
from .subscription_service import SubscriptionService
from bot .middlewares .i18n import JsonI18n
from bot.middlewares.i18n import JsonI18n
class PromoCodeService :
def __init__ (
self ,
db_conn_provider :Callable [[],Any ],
settings :Settings ,
subscription_service :SubscriptionService ,
bot :Bot ,
i18n :JsonI18n
):
self .db_conn_provider =db_conn_provider
self .settings =settings
self .subscription_service =subscription_service
self .bot =bot
self .i18n =i18n
async def apply_promo_code (self ,user_id :int ,code_input :str ,user_lang :str )->Tuple [bool ,str ]:
class PromoCodeService:
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
subscription_service: SubscriptionService, bot: Bot,
i18n: JsonI18n):
self.db_conn_provider = db_conn_provider
self.settings = settings
self.subscription_service = subscription_service
self.bot = bot
self.i18n = i18n
async def apply_promo_code(self, user_id: int, code_input: str,
user_lang: str) -> Tuple[bool, str]:
"""
Applies a promo code for a user.
Returns: (success_status: bool, message_text_for_user: str)
"""
_ =lambda k ,**kw :self .i18n .gettext (user_lang ,k ,**kw )
code_input_upper =code_input .strip ().upper ()
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
async with self .db_conn_provider ()as db :
await _setup_db_connection (db )
try :
promo_data =await get_promo_code_by_code (code_input_upper ,db_conn =db )
async with self.db_conn_provider() as db:
await _setup_db_connection(db)
try:
promo_data = await get_promo_code_by_code(code_input_upper,
db_conn=db)
if not promo_data :
return False ,_ ("promo_code_not_found",code =code_input_upper )
if not promo_data:
return False, _("promo_code_not_found",
code=code_input_upper)
if promo_data ['current_activations']>=promo_data ['max_activations']:
return False ,_ ("promo_code_max_activations_reached",code =code_input_upper )
if promo_data['current_activations'] >= promo_data[
'max_activations']:
return False, _("promo_code_max_activations_reached",
code=code_input_upper)
active_sub = await self.subscription_service.get_active_subscription(
user_id)
if not active_sub:
return False, _("promo_code_no_active_subscription")
bonus_days = promo_data['bonus_days']
new_end_date = await self.subscription_service.extend_active_subscription_days(
user_id=user_id,
bonus_days=bonus_days,
db_conn=db,
reason=f"promo code {code_input_upper}")
if new_end_date:
activation_success = await increment_promo_activation(
promo_data['promo_code_id'],
user_id,
db_conn=db,
payment_id=None)
if activation_success:
await db.commit()
return True, _(
"promo_code_applied_success",
code=code_input_upper,
bonus_days=bonus_days,
new_end_date=new_end_date.strftime('%Y-%m-%d'))
else:
active_sub =await self .subscription_service .get_active_subscription (user_id )
if not active_sub :
return False ,_ ("promo_code_no_active_subscription")
await db.rollback()
return False, _("promo_code_invalid_or_expired")
else:
await db.rollback()
return False, _("error_applying_promo_bonus")
bonus_days =promo_data ['bonus_days']
new_end_date =await self .subscription_service .extend_active_subscription_days (
user_id =user_id ,
bonus_days =bonus_days ,
db_conn =db ,
reason =f"promo code {code_input_upper}"
)
if new_end_date :
activation_success =await increment_promo_activation (promo_data ['promo_code_id'],user_id ,db_conn =db ,payment_id =None )
if activation_success :
await db .commit ()
return True ,_ ("promo_code_applied_success",
code =code_input_upper ,
bonus_days =bonus_days ,
new_end_date =new_end_date .strftime ('%Y-%m-%d'))
else :
await db .rollback ()
return False ,_ ("promo_code_invalid_or_expired")
else :
await db .rollback ()
return False ,_ ("error_applying_promo_bonus")
except Exception as e :
logging .error (f"Error applying promo code {code_input_upper} for user {user_id}: {e}",exc_info =True )
await db .rollback ()
return False ,_ ("error_try_again")
except Exception as e:
logging.error(
f"Error applying promo code {code_input_upper} for user {user_id}: {e}",
exc_info=True)
await db.rollback()
return False, _("error_try_again")
+107 -74
View File
@@ -1,96 +1,129 @@
import logging
import aiosqlite
from typing import Optional ,Dict ,Callable ,Any ,TYPE_CHECKING ,Tuple
from typing import Optional, Dict, Callable, Any, TYPE_CHECKING, Tuple
from aiogram import Bot
from datetime import datetime
from config .settings import Settings
from db .database import get_user
from bot .middlewares .i18n import JsonI18n
from config.settings import Settings
from db.database import get_user
from bot.middlewares.i18n import JsonI18n
if TYPE_CHECKING :
if TYPE_CHECKING:
from .subscription_service import SubscriptionService
class ReferralService :
def __init__ (self ,db_conn_provider :Callable [[],Any ],settings :Settings ,subscription_service_instance :'SubscriptionService',bot :Bot ,i18n :JsonI18n ):
self .db_conn_provider =db_conn_provider
self .settings =settings
self .subscription_service =subscription_service_instance
self .bot =bot
self .i18n =i18n
class ReferralService:
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
subscription_service_instance: 'SubscriptionService',
bot: Bot, i18n: JsonI18n):
self.db_conn_provider = db_conn_provider
self.settings = settings
self.subscription_service = subscription_service_instance
self.bot = bot
self.i18n = i18n
async def process_new_user_referral(self, referee_user_id: int,
inviter_user_id: Optional[int]):
if inviter_user_id and referee_user_id != inviter_user_id:
async def process_new_user_referral (self ,referee_user_id :int ,inviter_user_id :Optional [int ]):
if inviter_user_id and referee_user_id !=inviter_user_id :
logging .info (f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}.")
logging.info(
f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}."
)
pass
async def apply_referral_bonuses_for_payment (
self ,referee_user_id :int ,purchased_subscription_months :int ,
db_conn :aiosqlite .Connection
)->Dict [str ,Any ]:
async def apply_referral_bonuses_for_payment(
self, referee_user_id: int, purchased_subscription_months: int,
db_conn: aiosqlite.Connection) -> Dict[str, Any]:
"""Applies bonuses. Notifies inviter. Returns referee bonus details."""
referee_final_end_date :Optional [datetime ]=None
referee_bonus_applied_days :Optional [int ]=None
try :
referee_user_row =await get_user (referee_user_id ,db_conn =db_conn )
if not referee_user_row or referee_user_row ['referred_by_id']is None :
logging .debug (f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses.")
return {"referee_bonus_applied_days":None ,"referee_new_end_date":None }
inviter_user_id =referee_user_row ['referred_by_id']
inviter_user_row =await get_user (inviter_user_id ,db_conn =db_conn )
referee_name =referee_user_row ['first_name']or f"User {referee_user_id}"
default_lang_for_placeholder =getattr (self .settings ,'DEFAULT_LANGUAGE','en')
inviter_name =inviter_user_row ['first_name']if inviter_user_row else self .i18n .gettext (default_lang_for_placeholder ,"friend_placeholder")
inviter_bonus =self .settings .referral_bonus_inviter .get (purchased_subscription_months )
referee_bonus =self .settings .referral_bonus_referee .get (purchased_subscription_months )
if inviter_bonus and inviter_bonus >0 and inviter_user_row :
new_end_date_inviter =await self .subscription_service .extend_subscription_for_referral (
user_id =inviter_user_id ,bonus_days =inviter_bonus ,db_conn =db_conn
referee_final_end_date: Optional[datetime] = None
referee_bonus_applied_days: Optional[int] = None
try:
referee_user_row = await get_user(referee_user_id, db_conn=db_conn)
if not referee_user_row or referee_user_row[
'referred_by_id'] is None:
logging.debug(
f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses."
)
if new_end_date_inviter :
logging .info (f"Bonus applied for inviter {inviter_user_id}.")
try :
inviter_lang =inviter_user_row .get ('language_code',default_lang_for_placeholder )
_i =lambda k ,**kw :self .i18n .gettext (inviter_lang ,k ,**kw )
await self .bot .send_message (
inviter_user_id ,
_i ("referral_bonus_inviter_notification_extended",
days =inviter_bonus ,
referee_name =referee_name ,
new_end_date =new_end_date_inviter .strftime ('%Y-%m-%d')
)
)
except Exception as e :
logging .error (f"Failed to send bonus notification to inviter {inviter_user_id}: {e}")
else :logging .warning (f"Failed to apply bonus subscription extension for inviter {inviter_user_id}.")
return {
"referee_bonus_applied_days": None,
"referee_new_end_date": None
}
if referee_bonus and referee_bonus >0 :
new_end_date_referee =await self .subscription_service .extend_subscription_for_referral (
user_id =referee_user_id ,bonus_days =referee_bonus ,db_conn =db_conn ,is_referee_bonus =True
)
if new_end_date_referee :
logging .info (f"Bonus applied for referee {referee_user_id}.")
referee_final_end_date =new_end_date_referee
referee_bonus_applied_days =referee_bonus
else :logging .warning (f"Failed to apply bonus subscription extension for referee {referee_user_id}.")
inviter_user_id = referee_user_row['referred_by_id']
inviter_user_row = await get_user(inviter_user_id, db_conn=db_conn)
return {"referee_bonus_applied_days":referee_bonus_applied_days ,"referee_new_end_date":referee_final_end_date }
except Exception as e :
logging .error (f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}",exc_info =True )
referee_name = referee_user_row[
'first_name'] or f"User {referee_user_id}"
default_lang_for_placeholder = getattr(self.settings,
'DEFAULT_LANGUAGE', 'en')
inviter_name = inviter_user_row[
'first_name'] if inviter_user_row else self.i18n.gettext(
default_lang_for_placeholder, "friend_placeholder")
inviter_bonus = self.settings.referral_bonus_inviter.get(
purchased_subscription_months)
referee_bonus = self.settings.referral_bonus_referee.get(
purchased_subscription_months)
if inviter_bonus and inviter_bonus > 0 and inviter_user_row:
new_end_date_inviter = await self.subscription_service.extend_subscription_for_referral(
user_id=inviter_user_id,
bonus_days=inviter_bonus,
db_conn=db_conn)
if new_end_date_inviter:
logging.info(
f"Bonus applied for inviter {inviter_user_id}.")
try:
inviter_lang = inviter_user_row.get(
'language_code', default_lang_for_placeholder)
_i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw)
await self.bot.send_message(
inviter_user_id,
_i("referral_bonus_inviter_notification_extended",
days=inviter_bonus,
referee_name=referee_name,
new_end_date=new_end_date_inviter.strftime(
'%Y-%m-%d')))
except Exception as e:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e}"
)
else:
logging.warning(
f"Failed to apply bonus subscription extension for inviter {inviter_user_id}."
)
if referee_bonus and referee_bonus > 0:
new_end_date_referee = await self.subscription_service.extend_subscription_for_referral(
user_id=referee_user_id,
bonus_days=referee_bonus,
db_conn=db_conn,
is_referee_bonus=True)
if new_end_date_referee:
logging.info(
f"Bonus applied for referee {referee_user_id}.")
referee_final_end_date = new_end_date_referee
referee_bonus_applied_days = referee_bonus
else:
logging.warning(
f"Failed to apply bonus subscription extension for referee {referee_user_id}."
)
return {
"referee_bonus_applied_days": referee_bonus_applied_days,
"referee_new_end_date": referee_final_end_date
}
except Exception as e:
logging.error(
f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}",
exc_info=True)
raise
def generate_referral_link (self ,bot_username :str ,inviter_user_id :int )->str :
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
def generate_referral_link(self, bot_username: str,
inviter_user_id: int) -> str:
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
+621 -287
View File
@@ -1,336 +1,670 @@
import logging
import aiosqlite
from datetime import datetime ,timedelta ,timezone
from typing import Optional ,Dict ,Any ,List ,Callable ,Awaitable
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List, Callable, Awaitable
from config .settings import Settings
from db .database import get_db_connection_manager ,_setup_db_connection ,get_user ,has_had_any_subscription as db_has_had_any_subscription
from config.settings import Settings
from db.database import get_db_connection_manager, _setup_db_connection, get_user, has_had_any_subscription as db_has_had_any_subscription
from .panel_api_service import PanelApiService
if False :
if False:
from .referral_service import ReferralService
class SubscriptionService :
def __init__ (self ,db_conn_provider :Callable [[],Any ],settings :Settings ,panel_service :PanelApiService ):
self .db_conn_provider =db_conn_provider
self .settings =settings
self .panel_service =panel_service
async def _get_db (self ,existing_conn :Optional [aiosqlite .Connection ]=None )->tuple [aiosqlite .Connection ,bool ]:
class SubscriptionService:
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
panel_service: PanelApiService):
self.db_conn_provider = db_conn_provider
self.settings = settings
self.panel_service = panel_service
async def _get_db(
self,
existing_conn: Optional[aiosqlite.Connection] = None
) -> tuple[aiosqlite.Connection, bool]:
"""Helper to get a DB connection. Returns (connection, should_manage_flag)."""
if existing_conn :
return existing_conn ,False
if existing_conn:
return existing_conn, False
conn_manager =self .db_conn_provider ()
conn =await conn_manager .__aenter__ ()
try :
await _setup_db_connection (conn )
except Exception as e :
await conn_manager .__aexit__ (type (e ),e ,e .__traceback__ )
conn_manager = self.db_conn_provider()
conn = await conn_manager.__aenter__()
try:
await _setup_db_connection(conn)
except Exception as e:
await conn_manager.__aexit__(type(e), e, e.__traceback__)
raise
return conn ,True
return conn, True
async def _release_db (self ,db :aiosqlite .Connection ,should_manage :bool ,exc_type =None ,exc_val =None ,exc_tb =None ):
async def _release_db(self,
db: aiosqlite.Connection,
should_manage: bool,
exc_type=None,
exc_val=None,
exc_tb=None):
"""Helper to release/close a DB connection if this service instance opened it."""
if should_manage :
await db .__aexit__ (exc_type ,exc_val ,exc_tb )
if should_manage:
await db.__aexit__(exc_type, exc_val, exc_tb)
async def get_user_language (self ,user_id :int )->str :
async def get_user_language(self, user_id: int) -> str:
"""Fetches the user's language preference from the local database."""
db ,should_manage =await self ._get_db ()
try :
user_record =await get_user (user_id ,db_conn =db )
default_lang =self .settings .DEFAULT_LANGUAGE
return user_record ['language_code']if user_record and 'language_code'in user_record .keys ()and user_record ['language_code']else default_lang
finally :
await self ._release_db (db ,should_manage )
db, should_manage = await self._get_db()
try:
user_record = await get_user(user_id, db_conn=db)
default_lang = self.settings.DEFAULT_LANGUAGE
return user_record[
'language_code'] if user_record and 'language_code' in user_record.keys(
) and user_record['language_code'] else default_lang
finally:
await self._release_db(db, should_manage)
async def get_panel_user_uuid (self ,user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [str ]:
async def get_panel_user_uuid(
self,
user_id: int,
db_conn: Optional[aiosqlite.Connection] = None) -> Optional[str]:
"""Fetches panel_user_uuid for a given Telegram user_id from local DB."""
db_to_use ,should_manage_this_conn =await self ._get_db (db_conn )
try :
user_record =await get_user (user_id ,db_conn =db_to_use )
return user_record ['panel_user_uuid']if user_record and 'panel_user_uuid'in user_record .keys ()and user_record ['panel_user_uuid']else None
finally :
await self ._release_db (db_to_use ,should_manage_this_conn )
db_to_use, should_manage_this_conn = await self._get_db(db_conn)
try:
user_record = await get_user(user_id, db_conn=db_to_use)
return user_record[
'panel_user_uuid'] if user_record and 'panel_user_uuid' in user_record.keys(
) and user_record['panel_user_uuid'] else None
finally:
await self._release_db(db_to_use, should_manage_this_conn)
async def has_had_any_subscription (self ,user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->bool :
async def has_had_any_subscription(
self,
user_id: int,
db_conn: Optional[aiosqlite.Connection] = None) -> bool:
"""Checks if the user has any record in the subscriptions table."""
db_to_use ,should_manage_this_conn =await self ._get_db (db_conn )
try :
return await db_has_had_any_subscription (user_id ,db_conn =db_to_use )
finally :
await self ._release_db (db_to_use ,should_manage_this_conn )
db_to_use, should_manage_this_conn = await self._get_db(db_conn)
try:
return await db_has_had_any_subscription(user_id,
db_conn=db_to_use)
finally:
await self._release_db(db_to_use, should_manage_this_conn)
async def activate_trial_subscription (self ,user_id :int )->Optional [Dict [str ,Any ]]:
async def activate_trial_subscription(
self, user_id: int) -> Optional[Dict[str, Any]]:
"""
Activates a trial subscription for an eligible user.
Manages its own database connection and transaction.
Returns dict with trial details or specific error dict on failure/ineligibility.
"""
if not self .settings .TRIAL_ENABLED or self .settings .TRIAL_DURATION_DAYS <=0 :
logging .info (f"Trial subscription feature is disabled or duration invalid for user {user_id}.")
return {"eligible":False ,"activated":False ,"message_key":"trial_feature_disabled"}
if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0:
logging.info(
f"Trial subscription feature is disabled or duration invalid for user {user_id}."
)
return {
"eligible": False,
"activated": False,
"message_key": "trial_feature_disabled"
}
async with self .db_conn_provider ()as db :
await _setup_db_connection (db )
try :
if await db_has_had_any_subscription (user_id ,db_conn =db ):
logging .info (f"User {user_id} has prior subscriptions. Trial not applicable.")
return {"eligible":False ,"activated":False ,"message_key":"trial_not_eligible_already_subscribed"}
panel_user_uuid =await self .get_panel_user_uuid (user_id ,db_conn =db )
panel_subscription_uuid_for_link =None ;panel_short_uuid_for_link =None
panel_user_interacted_now =False ;specific_inbounds =self .settings .parsed_default_panel_user_inbound_uuids
panel_actual_subscription_url :Optional [str ]=None
if not panel_user_uuid :
logging .info (f"No panel_user_uuid for TG user_id {user_id} for trial. Creating panel user.")
panel_username_to_create =f"tg_{user_id}"
creation_response =await self .panel_service .create_panel_user (
username =panel_username_to_create ,telegram_id =user_id ,
default_expire_days =self .settings .TRIAL_DURATION_DAYS ,
default_traffic_limit_bytes =self .settings .trial_traffic_limit_bytes ,
default_traffic_limit_strategy =self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,
specific_inbound_uuids =specific_inbounds ,
activate_all_inbounds_default_flag =False if specific_inbounds else True
async with self.db_conn_provider() as db:
await _setup_db_connection(db)
try:
if await db_has_had_any_subscription(user_id, db_conn=db):
logging.info(
f"User {user_id} has prior subscriptions. Trial not applicable."
)
panel_user_obj_from_api =None
if creation_response and not creation_response .get ("error"):panel_user_obj_from_api =creation_response .get ("response")
elif creation_response and creation_response .get ("errorCode")=="A019":
existing_users_list =await self .panel_service .get_users_by_filter (username =panel_username_to_create )
if existing_users_list and len (existing_users_list )==1 :panel_user_obj_from_api =existing_users_list [0 ]
return {
"eligible": False,
"activated": False,
"message_key": "trial_not_eligible_already_subscribed"
}
if panel_user_obj_from_api and panel_user_obj_from_api .get ('uuid'):
panel_user_uuid =panel_user_obj_from_api ['uuid']
panel_subscription_uuid_for_link =panel_user_obj_from_api .get ('subscriptionUuid')
panel_short_uuid_for_link =panel_user_obj_from_api .get ('shortUuid')
panel_actual_subscription_url =panel_user_obj_from_api .get ('subscriptionUrl')
await db .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id ))
panel_user_interacted_now =True
if panel_user_obj_from_api .get ('telegramId')!=user_id :
await self .panel_service .update_user_details_on_panel (panel_user_uuid ,{"telegramId":user_id })
else :
logging .error (f"Failed to create/link panel user for trial (TG_ID {user_id}). Resp: {creation_response if 'creation_response' in locals() else 'N/A'}")
await db .rollback ()
return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"}
else :
panel_user_data =await self .panel_service .get_user_by_uuid (panel_user_uuid )
if panel_user_data :
panel_subscription_uuid_for_link =panel_user_data .get ('subscriptionUuid')
panel_short_uuid_for_link =panel_user_data .get ('shortUuid')
panel_actual_subscription_url =panel_user_data .get ('subscriptionUrl')
panel_user_uuid = await self.get_panel_user_uuid(user_id,
db_conn=db)
panel_subscription_uuid_for_link = None
panel_short_uuid_for_link = None
panel_user_interacted_now = False
specific_inbounds = self.settings.parsed_default_panel_user_inbound_uuids
panel_actual_subscription_url: Optional[str] = None
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link :
panel_subscription_uuid_for_link =panel_short_uuid_for_link
if not panel_subscription_uuid_for_link :
logging .error (f"Critical: panel_subscription_uuid for link is None for trial (panel_uuid {panel_user_uuid}).")
await db .rollback ()
return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"}
if not panel_user_uuid:
logging.info(
f"No panel_user_uuid for TG user_id {user_id} for trial. Creating panel user."
)
panel_username_to_create = f"tg_{user_id}"
creation_response = await self.panel_service.create_panel_user(
username=panel_username_to_create,
telegram_id=user_id,
default_expire_days=self.settings.TRIAL_DURATION_DAYS,
default_traffic_limit_bytes=self.settings.
trial_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
specific_inbound_uuids=specific_inbounds,
activate_all_inbounds_default_flag=False
if specific_inbounds else True)
panel_user_obj_from_api = None
if creation_response and not creation_response.get(
"error"):
panel_user_obj_from_api = creation_response.get(
"response")
elif creation_response and creation_response.get(
"errorCode") == "A019":
existing_users_list = await self.panel_service.get_users_by_filter(
username=panel_username_to_create)
if existing_users_list and len(
existing_users_list) == 1:
panel_user_obj_from_api = existing_users_list[0]
start_date =datetime .now (timezone .utc )
end_date =start_date +timedelta (days =self .settings .TRIAL_DURATION_DAYS )
await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",(panel_user_uuid ,))
upsert_sql =""" INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes) VALUES (?, ?, ?, ?, ?, 0, 1, 'TRIAL', ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = 0, is_active = 1, status_from_panel = 'TRIAL', traffic_limit_bytes = excluded.traffic_limit_bytes, last_notification_sent = NULL; """
trial_traffic_val =self .settings .trial_traffic_limit_bytes
params =(user_id ,panel_user_uuid ,panel_subscription_uuid_for_link ,start_date .isoformat (),end_date .isoformat (),trial_traffic_val )
if panel_user_obj_from_api and panel_user_obj_from_api.get(
'uuid'):
panel_user_uuid = panel_user_obj_from_api['uuid']
panel_subscription_uuid_for_link = panel_user_obj_from_api.get(
'subscriptionUuid')
panel_short_uuid_for_link = panel_user_obj_from_api.get(
'shortUuid')
panel_actual_subscription_url = panel_user_obj_from_api.get(
'subscriptionUrl')
await db.execute(
"UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",
(panel_user_uuid, user_id))
panel_user_interacted_now = True
if panel_user_obj_from_api.get(
'telegramId') != user_id:
await self.panel_service.update_user_details_on_panel(
panel_user_uuid, {"telegramId": user_id})
else:
logging.error(
f"Failed to create/link panel user for trial (TG_ID {user_id}). Resp: {creation_response if 'creation_response' in locals() else 'N/A'}"
)
await db.rollback()
return {
"eligible": True,
"activated": False,
"message_key": "trial_activation_failed"
}
else:
panel_user_data = await self.panel_service.get_user_by_uuid(
panel_user_uuid)
if panel_user_data:
panel_subscription_uuid_for_link = panel_user_data.get(
'subscriptionUuid')
panel_short_uuid_for_link = panel_user_data.get(
'shortUuid')
panel_actual_subscription_url = panel_user_data.get(
'subscriptionUrl')
upsert_cursor =await db .execute (upsert_sql ,params )
trial_subscription_id =upsert_cursor .lastrowid
if not trial_subscription_id or trial_subscription_id ==0 :
id_fetch_cursor =await db .execute ("SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",(panel_subscription_uuid_for_link ,))
id_fetch_row =await id_fetch_cursor .fetchone ()
if id_fetch_cursor :await id_fetch_cursor .close ()
if id_fetch_row :trial_subscription_id =id_fetch_row ['subscription_id']
logging .info (f"Local trial subscription (ID: {trial_subscription_id}) for user {user_id} prepared. Ends: {end_date.isoformat()}.")
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link:
panel_subscription_uuid_for_link = panel_short_uuid_for_link
if not panel_subscription_uuid_for_link:
logging.error(
f"Critical: panel_subscription_uuid for link is None for trial (panel_uuid {panel_user_uuid})."
)
await db.rollback()
return {
"eligible": True,
"activated": False,
"message_key": "trial_activation_failed"
}
panel_update_payload :Dict [str ,Any ]={"uuid":panel_user_uuid ,"expireAt":end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z'),"status":"ACTIVE","trafficLimitBytes":trial_traffic_val ,"trafficLimitStrategy":self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,}
if specific_inbounds :panel_update_payload ["activeUserInbounds"]=specific_inbounds
elif panel_user_interacted_now :panel_update_payload ["activateAllInbounds"]=True
start_date = datetime.now(timezone.utc)
end_date = start_date + timedelta(
days=self.settings.TRIAL_DURATION_DAYS)
await db.execute(
"UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",
(panel_user_uuid, ))
upsert_sql = """ INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes) VALUES (?, ?, ?, ?, ?, 0, 1, 'TRIAL', ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = 0, is_active = 1, status_from_panel = 'TRIAL', traffic_limit_bytes = excluded.traffic_limit_bytes, last_notification_sent = NULL; """
trial_traffic_val = self.settings.trial_traffic_limit_bytes
params = (user_id, panel_user_uuid,
panel_subscription_uuid_for_link,
start_date.isoformat(), end_date.isoformat(),
trial_traffic_val)
updated_panel_user =await self .panel_service .update_user_details_on_panel (panel_user_uuid ,panel_update_payload )
if not updated_panel_user :logging .warning (f"Panel user details update FAILED for trial user {panel_user_uuid}.")
else :
logging .info (f"Panel user {panel_user_uuid} details updated for trial. Panel ExpireAt: {updated_panel_user.get('expireAt')}")
if updated_panel_user .get ('subscriptionUrl'):panel_actual_subscription_url =updated_panel_user .get ('subscriptionUrl')
if updated_panel_user .get ('shortUuid'):panel_short_uuid_for_link =updated_panel_user .get ('shortUuid')
upsert_cursor = await db.execute(upsert_sql, params)
trial_subscription_id = upsert_cursor.lastrowid
if not trial_subscription_id or trial_subscription_id == 0:
id_fetch_cursor = await db.execute(
"SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",
(panel_subscription_uuid_for_link, ))
id_fetch_row = await id_fetch_cursor.fetchone()
if id_fetch_cursor: await id_fetch_cursor.close()
if id_fetch_row:
trial_subscription_id = id_fetch_row['subscription_id']
logging.info(
f"Local trial subscription (ID: {trial_subscription_id}) for user {user_id} prepared. Ends: {end_date.isoformat()}."
)
await db .commit ()
return {"eligible":True ,"activated":True ,"end_date":end_date ,"days":self .settings .TRIAL_DURATION_DAYS ,"traffic_gb":self .settings .TRIAL_TRAFFIC_LIMIT_GB ,"panel_user_uuid":panel_user_uuid ,"panel_short_uuid":panel_short_uuid_for_link ,"subscription_url":panel_actual_subscription_url }
except Exception as e :
logging .error (f"Error activating trial for user {user_id}: {e}",exc_info =True )
await db .rollback ()
return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"}
panel_update_payload: Dict[str, Any] = {
"uuid":
panel_user_uuid,
"expireAt":
end_date.isoformat(timespec='milliseconds').replace(
'+00:00', 'Z'),
"status":
"ACTIVE",
"trafficLimitBytes":
trial_traffic_val,
"trafficLimitStrategy":
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
}
if specific_inbounds:
panel_update_payload[
"activeUserInbounds"] = specific_inbounds
elif panel_user_interacted_now:
panel_update_payload["activateAllInbounds"] = True
async def activate_subscription (
self ,user_id :int ,months :int ,payment_amount :float ,
payment_id_internal :int ,db_conn :aiosqlite .Connection ,
promo_code_id :Optional [int ]=None
)->Optional [Dict [str ,Any ]]:
db =db_conn
try :
panel_user_uuid =await self .get_panel_user_uuid (user_id ,db_conn =db )
panel_subscription_uuid_for_link =None ;panel_short_uuid_for_link =None ;panel_user_interacted_now =False
panel_actual_subscription_url :Optional [str ]=None
specific_inbounds_from_settings =self .settings .parsed_default_panel_user_inbound_uuids
if not panel_user_uuid :
panel_username_to_create =f"tg_{user_id}"
creation_response =await self .panel_service .create_panel_user (username =panel_username_to_create ,telegram_id =user_id ,default_expire_days =self .settings .PANEL_USER_DEFAULT_EXPIRE_DAYS ,default_traffic_limit_bytes =self .settings .PANEL_USER_DEFAULT_TRAFFIC_BYTES ,default_traffic_limit_strategy =self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,specific_inbound_uuids =specific_inbounds_from_settings ,activate_all_inbounds_default_flag =False if specific_inbounds_from_settings else True )
panel_user_object_from_api =None
if creation_response and not creation_response .get ("error"):panel_user_object_from_api =creation_response .get ("response")
elif creation_response and creation_response .get ("errorCode")=="A019":
existing_users_list =await self .panel_service .get_users_by_filter (username =panel_username_to_create )
if existing_users_list and len (existing_users_list )==1 :panel_user_object_from_api =existing_users_list [0 ]
if panel_user_object_from_api and panel_user_object_from_api .get ('uuid'):
panel_user_uuid =panel_user_object_from_api ['uuid'];panel_subscription_uuid_for_link =panel_user_object_from_api .get ('subscriptionUuid');panel_short_uuid_for_link =panel_user_object_from_api .get ('shortUuid');panel_actual_subscription_url =panel_user_object_from_api .get ('subscriptionUrl')
cursor_conflict =await db .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",(panel_user_uuid ,user_id ));conflicting_tg_user =await cursor_conflict .fetchone ();await cursor_conflict .close ()
if conflicting_tg_user :logging .error (f"CRITICAL CONFLICT: Panel UUID {panel_user_uuid} already linked to TG user {conflicting_tg_user['user_id']}.");return None
await db .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id ));panel_user_interacted_now =True
if panel_user_object_from_api .get ('telegramId')!=user_id :await self .panel_service .update_user_details_on_panel (panel_user_uuid ,{"telegramId":user_id })
else :logging .error (f"Failed to create/link panel user for TG_ID {user_id}. Resp: {creation_response if 'creation_response' in locals() else 'N/A'}");return None
else :
panel_user_data =await self .panel_service .get_user_by_uuid (panel_user_uuid )
if panel_user_data :panel_subscription_uuid_for_link =panel_user_data .get ('subscriptionUuid');panel_short_uuid_for_link =panel_user_data .get ('shortUuid');panel_actual_subscription_url =panel_user_data .get ('subscriptionUrl')
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link :panel_subscription_uuid_for_link =panel_short_uuid_for_link
if not panel_subscription_uuid_for_link :logging .error (f"Critical: panel_subscription_uuid for link is None for panel_user_uuid {panel_user_uuid}.");return None
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload)
if not updated_panel_user:
logging.warning(
f"Panel user details update FAILED for trial user {panel_user_uuid}."
)
else:
logging.info(
f"Panel user {panel_user_uuid} details updated for trial. Panel ExpireAt: {updated_panel_user.get('expireAt')}"
)
if updated_panel_user.get('subscriptionUrl'):
panel_actual_subscription_url = updated_panel_user.get(
'subscriptionUrl')
if updated_panel_user.get('shortUuid'):
panel_short_uuid_for_link = updated_panel_user.get(
'shortUuid')
cursor =await db .execute ("SELECT subscription_id, end_date FROM subscriptions WHERE panel_user_uuid = ? AND is_active = 1 ORDER BY end_date DESC LIMIT 1",(panel_user_uuid ,));current_sub_row =await cursor .fetchone ();await cursor .close ()
current_sub_end_date_str =current_sub_row ['end_date']if current_sub_row else None ;start_date =datetime .now (timezone .utc )
if current_sub_end_date_str :
try :
parsed_current_end_date =datetime .fromisoformat (current_sub_end_date_str .replace ("Z","+00:00"))
if parsed_current_end_date .tzinfo is None :parsed_current_end_date =parsed_current_end_date .replace (tzinfo =timezone .utc )
if parsed_current_end_date >start_date :start_date =parsed_current_end_date
except ValueError :logging .warning (f"Bad current_sub_end_date string: {current_sub_end_date_str} for panel_user {panel_user_uuid}.")
final_end_date =start_date +timedelta (days =months *30 )
if promo_code_id :
promo_cursor =await db .execute ("SELECT bonus_days FROM promo_codes WHERE promo_code_id = ?",(promo_code_id ,));promo_row =await promo_cursor .fetchone ();await promo_cursor .close ()
if promo_row :final_end_date +=timedelta (days =promo_row ['bonus_days']);await db .execute ("INSERT OR IGNORE INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",(promo_code_id ,user_id ,payment_id_internal ));await db .execute ("UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",(promo_code_id ,))
await db.commit()
return {
"eligible": True,
"activated": True,
"end_date": end_date,
"days": self.settings.TRIAL_DURATION_DAYS,
"traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
"panel_user_uuid": panel_user_uuid,
"panel_short_uuid": panel_short_uuid_for_link,
"subscription_url": panel_actual_subscription_url
}
except Exception as e:
logging.error(
f"Error activating trial for user {user_id}: {e}",
exc_info=True)
await db.rollback()
return {
"eligible": True,
"activated": False,
"message_key": "trial_activation_failed"
}
await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",(panel_user_uuid ,))
upsert_sql =""" INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel) VALUES (?, ?, ?, ?, ?, ?, 1, 'ACTIVE') ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = 1, status_from_panel = 'ACTIVE', last_notification_sent = NULL; """
params =(user_id ,panel_user_uuid ,panel_subscription_uuid_for_link ,start_date .isoformat (),final_end_date .isoformat (),months )
upsert_cursor =await db .execute (upsert_sql ,params );subscription_id_to_return =upsert_cursor .lastrowid
if not subscription_id_to_return or subscription_id_to_return ==0 :
id_cursor =await db .execute ("SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",(panel_subscription_uuid_for_link ,));id_row =await id_cursor .fetchone ();await id_cursor .close ()
if id_row :subscription_id_to_return =id_row ['subscription_id']
logging .info (f"Local subscription UPSERTED (ID: {subscription_id_to_return}) for user {user_id}. Ends: {final_end_date.isoformat()}.")
panel_update_payload :Dict [str ,Any ]={"uuid":panel_user_uuid ,"expireAt":final_end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z'),"status":"ACTIVE","trafficLimitBytes":self .settings .PANEL_USER_DEFAULT_TRAFFIC_BYTES ,"trafficLimitStrategy":self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY }
if specific_inbounds_from_settings :panel_update_payload ["activeUserInbounds"]=specific_inbounds_from_settings
elif panel_user_interacted_now :panel_update_payload ["activateAllInbounds"]=True
if "activateAllInbounds"in panel_update_payload and not specific_inbounds_from_settings and not panel_user_interacted_now :del panel_update_payload ["activateAllInbounds"]
logging .info (f"Attempting to update panel user {panel_user_uuid} for paid sub: {panel_update_payload}")
updated_panel_user =await self .panel_service .update_user_details_on_panel (panel_user_uuid ,panel_update_payload )
if not updated_panel_user :logging .warning (f"Panel user details update FAILED for {panel_user_uuid}.")
else :
logging .info (f"Panel user {panel_user_uuid} details updated. Panel ExpireAt: {updated_panel_user.get('expireAt')}")
if updated_panel_user .get ('subscriptionUrl'):panel_actual_subscription_url =updated_panel_user .get ('subscriptionUrl')
if updated_panel_user .get ('shortUuid'):panel_short_uuid_for_link =updated_panel_user .get ('shortUuid')
return {"subscription_id":subscription_id_to_return ,"end_date":final_end_date ,"is_active":True ,"panel_user_uuid":panel_user_uuid ,"panel_short_uuid":panel_short_uuid_for_link ,"subscription_url":panel_actual_subscription_url }
except Exception as e :logging .error (f"Error in activate_subscription (paid) for user {user_id}: {e}",exc_info =True );return None
async def extend_active_subscription_days (self ,user_id :int ,bonus_days :int ,db_conn :aiosqlite .Connection ,reason :str ="bonus")->Optional [datetime ]:
db =db_conn
try :
user_cursor =await db .execute ("SELECT panel_user_uuid FROM users WHERE user_id = ?",(user_id ,))
user_panel_data =await user_cursor .fetchone ()
if user_cursor :await user_cursor .close ()
panel_user_uuid_for_update =user_panel_data ['panel_user_uuid']if user_panel_data and 'panel_user_uuid'in user_panel_data .keys ()and user_panel_data ['panel_user_uuid']else None
sql_select_active_sub ="SELECT subscription_id, end_date FROM subscriptions WHERE user_id = ? AND is_active = 1 "
params_select_active_sub :tuple =(user_id ,)
if panel_user_uuid_for_update :
sql_select_active_sub +="AND panel_user_uuid = ? "
params_select_active_sub +=(panel_user_uuid_for_update ,)
else :
logging .warning (f"Extending subscription for user {user_id} without panel_user_uuid. This might be ambiguous if user has multiple panel accounts linked to one TG ID (not typical).")
sql_select_active_sub +="ORDER BY end_date DESC LIMIT 1"
cursor =await db .execute (sql_select_active_sub ,params_select_active_sub )
active_sub_row =await cursor .fetchone ()
if cursor :await cursor .close ()
if not active_sub_row or not active_sub_row ['end_date']:
logging .info (f"No active subscription found for user {user_id} (panel UUID: {panel_user_uuid_for_update}) to extend with {reason} bonus.")
async def activate_subscription(
self,
user_id: int,
months: int,
payment_amount: float,
payment_id_internal: int,
db_conn: aiosqlite.Connection,
promo_code_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
db = db_conn
try:
panel_user_uuid = await self.get_panel_user_uuid(user_id,
db_conn=db)
panel_subscription_uuid_for_link = None
panel_short_uuid_for_link = None
panel_user_interacted_now = False
panel_actual_subscription_url: Optional[str] = None
specific_inbounds_from_settings = self.settings.parsed_default_panel_user_inbound_uuids
if not panel_user_uuid:
panel_username_to_create = f"tg_{user_id}"
creation_response = await self.panel_service.create_panel_user(
username=panel_username_to_create,
telegram_id=user_id,
default_expire_days=self.settings.
PANEL_USER_DEFAULT_EXPIRE_DAYS,
default_traffic_limit_bytes=self.settings.
PANEL_USER_DEFAULT_TRAFFIC_BYTES,
default_traffic_limit_strategy=self.settings.
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
specific_inbound_uuids=specific_inbounds_from_settings,
activate_all_inbounds_default_flag=False
if specific_inbounds_from_settings else True)
panel_user_object_from_api = None
if creation_response and not creation_response.get("error"):
panel_user_object_from_api = creation_response.get(
"response")
elif creation_response and creation_response.get(
"errorCode") == "A019":
existing_users_list = await self.panel_service.get_users_by_filter(
username=panel_username_to_create)
if existing_users_list and len(existing_users_list) == 1:
panel_user_object_from_api = existing_users_list[0]
if panel_user_object_from_api and panel_user_object_from_api.get(
'uuid'):
panel_user_uuid = panel_user_object_from_api['uuid']
panel_subscription_uuid_for_link = panel_user_object_from_api.get(
'subscriptionUuid')
panel_short_uuid_for_link = panel_user_object_from_api.get(
'shortUuid')
panel_actual_subscription_url = panel_user_object_from_api.get(
'subscriptionUrl')
cursor_conflict = await db.execute(
"SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",
(panel_user_uuid, user_id))
conflicting_tg_user = await cursor_conflict.fetchone()
await cursor_conflict.close()
if conflicting_tg_user:
logging.error(
f"CRITICAL CONFLICT: Panel UUID {panel_user_uuid} already linked to TG user {conflicting_tg_user['user_id']}."
)
return None
await db.execute(
"UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",
(panel_user_uuid, user_id))
panel_user_interacted_now = True
if panel_user_object_from_api.get('telegramId') != user_id:
await self.panel_service.update_user_details_on_panel(
panel_user_uuid, {"telegramId": user_id})
else:
logging.error(
f"Failed to create/link panel user for TG_ID {user_id}. Resp: {creation_response if 'creation_response' in locals() else 'N/A'}"
)
return None
else:
panel_user_data = await self.panel_service.get_user_by_uuid(
panel_user_uuid)
if panel_user_data:
panel_subscription_uuid_for_link = panel_user_data.get(
'subscriptionUuid')
panel_short_uuid_for_link = panel_user_data.get(
'shortUuid')
panel_actual_subscription_url = panel_user_data.get(
'subscriptionUrl')
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link:
panel_subscription_uuid_for_link = panel_short_uuid_for_link
if not panel_subscription_uuid_for_link:
logging.error(
f"Critical: panel_subscription_uuid for link is None for panel_user_uuid {panel_user_uuid}."
)
return None
current_end_date_str =active_sub_row ['end_date']
try :
current_end_date =datetime .fromisoformat (current_end_date_str .replace ("Z","+00:00"))if isinstance (current_end_date_str ,str )else current_end_date_str
if not isinstance (current_end_date ,datetime ):
raise ValueError ("current_end_date is not a datetime object after parsing")
except ValueError as ve :
logging .error (f"Error parsing current_end_date '{current_end_date_str}' for user {user_id}: {ve}")
return None
cursor = await db.execute(
"SELECT subscription_id, end_date FROM subscriptions WHERE panel_user_uuid = ? AND is_active = 1 ORDER BY end_date DESC LIMIT 1",
(panel_user_uuid, ))
current_sub_row = await cursor.fetchone()
await cursor.close()
current_sub_end_date_str = current_sub_row[
'end_date'] if current_sub_row else None
start_date = datetime.now(timezone.utc)
if current_sub_end_date_str:
try:
parsed_current_end_date = datetime.fromisoformat(
current_sub_end_date_str.replace("Z", "+00:00"))
if parsed_current_end_date.tzinfo is None:
parsed_current_end_date = parsed_current_end_date.replace(
tzinfo=timezone.utc)
if parsed_current_end_date > start_date:
start_date = parsed_current_end_date
except ValueError:
logging.warning(
f"Bad current_sub_end_date string: {current_sub_end_date_str} for panel_user {panel_user_uuid}."
)
final_end_date = start_date + timedelta(days=months * 30)
if promo_code_id:
promo_cursor = await db.execute(
"SELECT bonus_days FROM promo_codes WHERE promo_code_id = ?",
(promo_code_id, ))
promo_row = await promo_cursor.fetchone()
await promo_cursor.close()
if promo_row:
final_end_date += timedelta(days=promo_row['bonus_days'])
await db.execute(
"INSERT OR IGNORE INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",
(promo_code_id, user_id, payment_id_internal))
await db.execute(
"UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",
(promo_code_id, ))
if current_end_date .tzinfo is None :
current_end_date =current_end_date .replace (tzinfo =timezone .utc )
await db.execute(
"UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",
(panel_user_uuid, ))
upsert_sql = """ INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel) VALUES (?, ?, ?, ?, ?, ?, 1, 'ACTIVE') ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = 1, status_from_panel = 'ACTIVE', last_notification_sent = NULL; """
params = (user_id, panel_user_uuid,
panel_subscription_uuid_for_link, start_date.isoformat(),
final_end_date.isoformat(), months)
upsert_cursor = await db.execute(upsert_sql, params)
subscription_id_to_return = upsert_cursor.lastrowid
if not subscription_id_to_return or subscription_id_to_return == 0:
id_cursor = await db.execute(
"SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",
(panel_subscription_uuid_for_link, ))
id_row = await id_cursor.fetchone()
await id_cursor.close()
if id_row:
subscription_id_to_return = id_row['subscription_id']
logging.info(
f"Local subscription UPSERTED (ID: {subscription_id_to_return}) for user {user_id}. Ends: {final_end_date.isoformat()}."
)
now_utc =datetime .now (timezone .utc )
start_point_for_bonus =current_end_date if current_end_date >now_utc else now_utc
new_end_date =start_point_for_bonus +timedelta (days =bonus_days )
await db .execute ("UPDATE subscriptions SET end_date = ?, last_notification_sent = NULL WHERE subscription_id = ?",(new_end_date .isoformat (),active_sub_row ['subscription_id']))
logging .info (f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date.isoformat()}")
if panel_user_uuid_for_update :
panel_update_payload ={"uuid":panel_user_uuid_for_update ,"expireAt":new_end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z')}
if not await self .panel_service .update_user_details_on_panel (panel_user_uuid_for_update ,panel_update_payload ):
logging .warning (f"Failed to update panel expiry for {panel_user_uuid_for_update} after {reason} bonus.")
return new_end_date
except Exception as e :
logging .error (f"Error extending subscription with {reason} bonus for user {user_id} (using provided db_conn): {e}",exc_info =True )
panel_update_payload: Dict[str, Any] = {
"uuid":
panel_user_uuid,
"expireAt":
final_end_date.isoformat(timespec='milliseconds').replace(
'+00:00', 'Z'),
"status":
"ACTIVE",
"trafficLimitBytes":
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
"trafficLimitStrategy":
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY
}
if specific_inbounds_from_settings:
panel_update_payload[
"activeUserInbounds"] = specific_inbounds_from_settings
elif panel_user_interacted_now:
panel_update_payload["activateAllInbounds"] = True
if "activateAllInbounds" in panel_update_payload and not specific_inbounds_from_settings and not panel_user_interacted_now:
del panel_update_payload["activateAllInbounds"]
logging.info(
f"Attempting to update panel user {panel_user_uuid} for paid sub: {panel_update_payload}"
)
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload)
if not updated_panel_user:
logging.warning(
f"Panel user details update FAILED for {panel_user_uuid}.")
else:
logging.info(
f"Panel user {panel_user_uuid} details updated. Panel ExpireAt: {updated_panel_user.get('expireAt')}"
)
if updated_panel_user.get('subscriptionUrl'):
panel_actual_subscription_url = updated_panel_user.get(
'subscriptionUrl')
if updated_panel_user.get('shortUuid'):
panel_short_uuid_for_link = updated_panel_user.get(
'shortUuid')
return {
"subscription_id": subscription_id_to_return,
"end_date": final_end_date,
"is_active": True,
"panel_user_uuid": panel_user_uuid,
"panel_short_uuid": panel_short_uuid_for_link,
"subscription_url": panel_actual_subscription_url
}
except Exception as e:
logging.error(
f"Error in activate_subscription (paid) for user {user_id}: {e}",
exc_info=True)
return None
async def extend_subscription_for_referral (self ,user_id :int ,bonus_days :int ,db_conn :aiosqlite .Connection ,is_referee_bonus :bool =False )->Optional [datetime ]:
reason ="referee bonus"if is_referee_bonus else "inviter referral bonus"
return await self .extend_active_subscription_days (user_id ,bonus_days ,db_conn ,reason =reason )
async def extend_active_subscription_days(
self,
user_id: int,
bonus_days: int,
db_conn: aiosqlite.Connection,
reason: str = "bonus") -> Optional[datetime]:
db = db_conn
try:
user_cursor = await db.execute(
"SELECT panel_user_uuid FROM users WHERE user_id = ?",
(user_id, ))
user_panel_data = await user_cursor.fetchone()
if user_cursor: await user_cursor.close()
panel_user_uuid_for_update = user_panel_data[
'panel_user_uuid'] if user_panel_data and 'panel_user_uuid' in user_panel_data.keys(
) and user_panel_data['panel_user_uuid'] else None
async def get_active_subscription (self ,user_id :int )->Optional [Dict [str ,Any ]]:
db ,should_manage =await self ._get_db ()
try :
now_iso_utc =datetime .now (timezone .utc ).isoformat ()
cursor =await db .execute ("""SELECT s.subscription_id, s.panel_subscription_uuid, s.panel_user_uuid, s.start_date, s.end_date, s.duration_months, s.is_active, s.status_from_panel, s.traffic_limit_bytes, s.traffic_used_bytes, u.username as bot_username FROM subscriptions s LEFT JOIN users u ON s.user_id = u.user_id WHERE s.user_id = ? AND s.is_active = 1 AND s.end_date > ? ORDER BY s.end_date DESC LIMIT 1""",(user_id ,now_iso_utc ))
sub_row =await cursor .fetchone ();await cursor .close ()
if sub_row :
sub_dict =dict (sub_row )
for date_key in ['start_date','end_date']:
if sub_dict .get (date_key )and isinstance (sub_dict [date_key ],str ):
try :sub_dict [date_key ]=datetime .fromisoformat (sub_dict [date_key ].replace ("Z","+00:00"))
except ValueError :logging .warning (f"Could not parse date string {sub_dict[date_key]} for key {date_key} in get_active_subscription")
sql_select_active_sub = "SELECT subscription_id, end_date FROM subscriptions WHERE user_id = ? AND is_active = 1 "
params_select_active_sub: tuple = (user_id, )
if panel_user_uuid_for_update:
sql_select_active_sub += "AND panel_user_uuid = ? "
params_select_active_sub += (panel_user_uuid_for_update, )
else:
logging.warning(
f"Extending subscription for user {user_id} without panel_user_uuid. This might be ambiguous if user has multiple panel accounts linked to one TG ID (not typical)."
)
sql_select_active_sub += "ORDER BY end_date DESC LIMIT 1"
cursor = await db.execute(sql_select_active_sub,
params_select_active_sub)
active_sub_row = await cursor.fetchone()
if cursor: await cursor.close()
if not active_sub_row or not active_sub_row['end_date']:
logging.info(
f"No active subscription found for user {user_id} (panel UUID: {panel_user_uuid_for_update}) to extend with {reason} bonus."
)
return None
current_end_date_str = active_sub_row['end_date']
try:
current_end_date = datetime.fromisoformat(
current_end_date_str.replace("Z", "+00:00")) if isinstance(
current_end_date_str, str) else current_end_date_str
if not isinstance(current_end_date, datetime):
raise ValueError(
"current_end_date is not a datetime object after parsing"
)
except ValueError as ve:
logging.error(
f"Error parsing current_end_date '{current_end_date_str}' for user {user_id}: {ve}"
)
return None
if current_end_date.tzinfo is None:
current_end_date = current_end_date.replace(
tzinfo=timezone.utc)
now_utc = datetime.now(timezone.utc)
start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc
new_end_date = start_point_for_bonus + timedelta(days=bonus_days)
await db.execute(
"UPDATE subscriptions SET end_date = ?, last_notification_sent = NULL WHERE subscription_id = ?",
(new_end_date.isoformat(), active_sub_row['subscription_id']))
logging.info(
f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date.isoformat()}"
)
if panel_user_uuid_for_update:
panel_update_payload = {
"uuid":
panel_user_uuid_for_update,
"expireAt":
new_end_date.isoformat(timespec='milliseconds').replace(
'+00:00', 'Z')
}
if not await self.panel_service.update_user_details_on_panel(
panel_user_uuid_for_update, panel_update_payload):
logging.warning(
f"Failed to update panel expiry for {panel_user_uuid_for_update} after {reason} bonus."
)
return new_end_date
except Exception as e:
logging.error(
f"Error extending subscription with {reason} bonus for user {user_id} (using provided db_conn): {e}",
exc_info=True)
return None
async def extend_subscription_for_referral(
self,
user_id: int,
bonus_days: int,
db_conn: aiosqlite.Connection,
is_referee_bonus: bool = False) -> Optional[datetime]:
reason = "referee bonus" if is_referee_bonus else "inviter referral bonus"
return await self.extend_active_subscription_days(user_id,
bonus_days,
db_conn,
reason=reason)
async def get_active_subscription(
self, user_id: int) -> Optional[Dict[str, Any]]:
db, should_manage = await self._get_db()
try:
now_iso_utc = datetime.now(timezone.utc).isoformat()
cursor = await db.execute(
"""SELECT s.subscription_id, s.panel_subscription_uuid, s.panel_user_uuid, s.start_date, s.end_date, s.duration_months, s.is_active, s.status_from_panel, s.traffic_limit_bytes, s.traffic_used_bytes, u.username as bot_username FROM subscriptions s LEFT JOIN users u ON s.user_id = u.user_id WHERE s.user_id = ? AND s.is_active = 1 AND s.end_date > ? ORDER BY s.end_date DESC LIMIT 1""",
(user_id, now_iso_utc))
sub_row = await cursor.fetchone()
await cursor.close()
if sub_row:
sub_dict = dict(sub_row)
for date_key in ['start_date', 'end_date']:
if sub_dict.get(date_key) and isinstance(
sub_dict[date_key], str):
try:
sub_dict[date_key] = datetime.fromisoformat(
sub_dict[date_key].replace("Z", "+00:00"))
except ValueError:
logging.warning(
f"Could not parse date string {sub_dict[date_key]} for key {date_key} in get_active_subscription"
)
return sub_dict
return None
finally :await self ._release_db (db ,should_manage )
finally:
await self._release_db(db, should_manage)
async def get_subscriptions_ending_soon (self ,days_threshold :int )->List [Dict [str ,Any ]]:
db ,should_manage =await self ._get_db ()
try :
now_utc =datetime .now (timezone .utc )
threshold_date =now_utc +timedelta (days =days_threshold )
today_date_str =now_utc .strftime ('%Y-%m-%d')
query =""" SELECT s.user_id, u.first_name, u.language_code, s.end_date as end_date_raw, strftime('%Y-%m-%d', s.end_date) as end_date_str FROM subscriptions s JOIN users u ON s.user_id = u.user_id WHERE s.is_active = 1 AND s.end_date BETWEEN ? AND ? AND (s.last_notification_sent IS NULL OR s.last_notification_sent < ?) ORDER BY s.end_date ASC """
cursor =await db .execute (query ,(now_utc .isoformat (),threshold_date .isoformat (),today_date_str ));rows =await cursor .fetchall ();await cursor .close ()
processed_rows =[]
for row_data in rows :
row_dict =dict (row_data );end_date_obj_for_calc =None
if isinstance (row_dict ['end_date_raw'],str ):
try :
end_date_obj_for_calc =datetime .fromisoformat (row_dict ['end_date_raw'].replace ("Z","+00:00"))
if end_date_obj_for_calc .tzinfo is None :end_date_obj_for_calc =end_date_obj_for_calc .replace (tzinfo =timezone .utc )
row_dict ['days_left']=(end_date_obj_for_calc -now_utc ).total_seconds ()/(24 *3600 )
except ValueError :row_dict ['days_left']=None
else :row_dict ['days_left']=None
processed_rows .append (row_dict )
async def get_subscriptions_ending_soon(
self, days_threshold: int) -> List[Dict[str, Any]]:
db, should_manage = await self._get_db()
try:
now_utc = datetime.now(timezone.utc)
threshold_date = now_utc + timedelta(days=days_threshold)
today_date_str = now_utc.strftime('%Y-%m-%d')
query = """ SELECT s.user_id, u.first_name, u.language_code, s.end_date as end_date_raw, strftime('%Y-%m-%d', s.end_date) as end_date_str FROM subscriptions s JOIN users u ON s.user_id = u.user_id WHERE s.is_active = 1 AND s.end_date BETWEEN ? AND ? AND (s.last_notification_sent IS NULL OR s.last_notification_sent < ?) ORDER BY s.end_date ASC """
cursor = await db.execute(
query, (now_utc.isoformat(), threshold_date.isoformat(),
today_date_str))
rows = await cursor.fetchall()
await cursor.close()
processed_rows = []
for row_data in rows:
row_dict = dict(row_data)
end_date_obj_for_calc = None
if isinstance(row_dict['end_date_raw'], str):
try:
end_date_obj_for_calc = datetime.fromisoformat(
row_dict['end_date_raw'].replace("Z", "+00:00"))
if end_date_obj_for_calc.tzinfo is None:
end_date_obj_for_calc = end_date_obj_for_calc.replace(
tzinfo=timezone.utc)
row_dict['days_left'] = (end_date_obj_for_calc -
now_utc).total_seconds() / (
24 * 3600)
except ValueError:
row_dict['days_left'] = None
else:
row_dict['days_left'] = None
processed_rows.append(row_dict)
return processed_rows
finally :await self ._release_db (db ,should_manage )
finally:
await self._release_db(db, should_manage)
async def update_last_notification_sent (self ,user_id :int ,subscription_end_date_iso :str ):
db ,should_manage =await self ._get_db ()
try :
today_iso_date_str =datetime .now (timezone .utc ).strftime ('%Y-%m-%d')
await db .execute ("UPDATE subscriptions SET last_notification_sent = ? WHERE user_id = ? AND is_active = 1 AND end_date = ?",(today_iso_date_str ,user_id ,subscription_end_date_iso ))
await db .commit ()
except Exception as e :logging .error (f"Error updating last_notification_sent for {user_id} and end_date {subscription_end_date_iso}: {e}");await db .rollback ()
finally :await self ._release_db (db ,should_manage )
async def update_last_notification_sent(self, user_id: int,
subscription_end_date_iso: str):
db, should_manage = await self._get_db()
try:
today_iso_date_str = datetime.now(
timezone.utc).strftime('%Y-%m-%d')
await db.execute(
"UPDATE subscriptions SET last_notification_sent = ? WHERE user_id = ? AND is_active = 1 AND end_date = ?",
(today_iso_date_str, user_id, subscription_end_date_iso))
await db.commit()
except Exception as e:
logging.error(
f"Error updating last_notification_sent for {user_id} and end_date {subscription_end_date_iso}: {e}"
)
await db.rollback()
finally:
await self._release_db(db, should_manage)