diff --git a/.env.example b/.env.example index 8afef7d..d91fe46 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,10 @@ RUB_PRICE_12_MONTHS=900 STARS_PRICE_12_MONTHS=0 TRIBUTE_LINK_12_MONTHS= +# Traffic Packages (enables traffic sale mode when set) +TRAFFIC_PACKAGES=10:199,50:799 # Format: ":", comma-separated +STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars + # Subscription Notifications SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index ee0996f..9f29d8c 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -34,7 +34,7 @@ async def get_payments_with_pagination(session: AsyncSession, page: int = 0, return payments, total_count -def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: +def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str: """Format single payment info as text.""" _ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs) @@ -66,12 +66,21 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: 'severpay': 'SeverPay', 'platega': 'Platega', }.get(payment.provider, payment.provider or 'Unknown') + + traffic_mode = getattr(settings, "traffic_sale_mode", False) + if traffic_mode: + traffic_val = payment.subscription_duration_months or 0 + traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}" + period_line = _("admin_payment_traffic_label", default="🗂 Трафик: {traffic_gb} GB", traffic_gb=traffic_display) + else: + period_line = _("admin_payment_months_label", default="📅 Период: {months} мес.", months=payment.subscription_duration_months or 0) return ( f"{status_emoji} {payment.amount} {payment.currency}\n" f"👤 {user_info}\n" f"💳 {provider_text}\n" f"📅 {payment_date}\n" + f"{period_line}\n" f"📋 {payment.status}\n" f"📝 {payment.description or 'N/A'}" ) @@ -109,7 +118,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict, total_pages=total_pages) + "\n") for i, payment in enumerate(payments, 1): - text_parts.append(f"{page * page_size + i}. {format_payment_text(payment, i18n, current_lang)}") + text_parts.append(f"{page * page_size + i}. {format_payment_text(payment, i18n, current_lang, settings)}") text_parts.append("") # Empty line between payments # Build keyboard with pagination and export @@ -202,13 +211,21 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: _("admin_csv_provider", default="Provider"), _("admin_csv_status", default="Status"), _("admin_csv_description", default="Description"), - _("admin_csv_months", default="Months"), + _("admin_csv_units", default="Months/GB"), _("admin_csv_created_at", default="Created At"), _("admin_csv_provider_payment_id", default="Provider Payment ID") ]) + + traffic_mode = getattr(settings, "traffic_sale_mode", False) # Write payment data for payment in all_payments: + units_val = payment.subscription_duration_months or "" + if traffic_mode and units_val not in ("", None): + try: + units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}" + except Exception: + units_val = payment.subscription_duration_months or "" writer.writerow([ payment.payment_id, payment.user_id, @@ -219,7 +236,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: payment.provider or "", payment.status, payment.description or "", - payment.subscription_duration_months or "", + units_val, payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "", payment.provider_payment_id or "" ]) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 204d4e8..64b8b26 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -40,6 +40,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, metadata = payment_info_from_webhook.get("metadata", {}) user_id_str = metadata.get("user_id") subscription_months_str = metadata.get("subscription_months") + traffic_gb_str = metadata.get("traffic_gb") + sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription") promo_code_id_str = metadata.get("promo_code_id") payment_db_id_str = metadata.get("payment_db_id") auto_renew_subscription_id_str = metadata.get( @@ -47,8 +49,11 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, # For auto-renew payments, payment_db_id may be absent. In that case, # we will create/ensure a payment record idempotently using provider payment id. - if (not user_id_str or not subscription_months_str - or (not payment_db_id_str and not auto_renew_subscription_id_str)): + if ( + not user_id_str + or (not subscription_months_str and not traffic_gb_str) + or (not payment_db_id_str and not auto_renew_subscription_id_str) + ): logging.error( f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" ) @@ -57,15 +62,17 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, db_user = None try: user_id = int(user_id_str) - subscription_months = int(subscription_months_str) + subscription_months = float(subscription_months_str or 0) + traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months payment_db_id = int( payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None - is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id) + is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic") promo_code_id = int( promo_code_id_str ) if promo_code_id_str and promo_code_id_str.isdigit() else None amount_data = payment_info_from_webhook.get("amount", {}) + months_for_record = int(subscription_months) if sale_mode != "traffic" else 0 payment_value = float(amount_data.get("value", 0.0)) # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists @@ -79,9 +86,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, user_id=user_id, amount=payment_value, currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL), - months=subscription_months, + months=months_for_record or 1, description=payment_info_from_webhook.get( - "description") or f"Auto-renewal for {subscription_months} months", + "description") or f"Auto-renewal for {months_for_record or subscription_months} months", provider="yookassa", provider_payment_id=yk_payment_id_from_hook, ) @@ -196,14 +203,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, raise Exception( f"DB Error: Could not update payment record {payment_db_id}") + months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0 activation_details = await subscription_service.activate_subscription( session, user_id, - subscription_months, + months_for_activation, payment_value, payment_db_id, promo_code_id_from_payment=promo_code_id, - provider="yookassa") + provider="yookassa", + sale_mode=sale_mode, + traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None, + ) if not activation_details or not activation_details.get('end_date'): logging.error( @@ -217,13 +228,15 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, applied_promo_bonus_days = activation_details.get( "applied_promo_bonus_days", 0) - referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( - session, - user_id, - subscription_months, - current_payment_db_id=payment_db_id, - skip_if_active_before_payment=False, - ) + referral_bonus_info = None + if sale_mode != "traffic": + referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( + session, + user_id, + months_for_activation or int(subscription_months) or 1, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=False, + ) applied_referee_bonus_days_from_referral: Optional[int] = None if referral_bonus_info and referral_bonus_info.get( "referee_new_end_date"): @@ -236,14 +249,28 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) + traffic_label = ( + str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}" + ) # For auto-renew charges, avoid re-sending config link; send concise message - if is_auto_renew and final_end_date_for_user: + if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user: details_message = _( "yookassa_auto_renewal", - months=subscription_months, + months=int(subscription_months), end_date=final_end_date_for_user.strftime('%Y-%m-%d'), ) details_markup = None + elif sale_mode == "traffic": + config_link = activation_details.get("subscription_url") or _("config_link_not_available") + details_message = _( + "payment_successful_traffic_full", + traffic_gb=traffic_label, + end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "—", + config_link=config_link, + ) + details_markup = get_connect_and_main_keyboard( + user_lang, i18n, settings, config_link, preserve_message=True + ) else: config_link = activation_details.get("subscription_url") or _( "config_link_not_available" @@ -263,7 +290,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, details_message = _( "payment_successful_with_referral_bonus_full", - months=subscription_months, + months=int(subscription_months), base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'), bonus_days=applied_referee_bonus_days_from_referral, final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'), @@ -273,7 +300,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, elif applied_promo_bonus_days > 0 and final_end_date_for_user: details_message = _( "payment_successful_with_promo_full", - months=subscription_months, + months=int(subscription_months), bonus_days=applied_promo_bonus_days, end_date=final_end_date_for_user.strftime('%Y-%m-%d'), config_link=config_link, @@ -281,7 +308,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, elif final_end_date_for_user: details_message = _( "payment_successful_full", - months=subscription_months, + months=int(subscription_months), end_date=final_end_date_for_user.strftime('%Y-%m-%d'), config_link=config_link, ) @@ -315,9 +342,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, user_id=user_id, amount=payment_value, currency=settings.DEFAULT_CURRENCY_SYMBOL, - months=subscription_months, + months=int(subscription_months) if sale_mode != "traffic" else 0, payment_provider="yookassa", # This is specifically for YooKassa webhook - username=user.username if user else None + username=user.username if user else None, + traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None, ) except Exception as e: logging.error(f"Failed to send payment notification: {e}") diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index 06f3150..7b2207d 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -74,24 +74,26 @@ async def referral_command_handler(event: Union[types.Message, return bonus_info_parts = [] - if settings.subscription_options: + if getattr(settings, "traffic_sale_mode", False): + bonus_details_str = _("referral_not_available_for_traffic") + else: + if settings.subscription_options: + for months_period_key, _price in sorted( + settings.subscription_options.items()): - for months_period_key, _price in sorted( - settings.subscription_options.items()): + inv_bonus = settings.referral_bonus_inviter.get(months_period_key) + ref_bonus = settings.referral_bonus_referee.get(months_period_key) + if inv_bonus is not None or ref_bonus is not None: + bonus_info_parts.append( + _("referral_bonus_per_period", + months=months_period_key, + inviter_bonus_days=inv_bonus + if inv_bonus is not None else _("no_bonus_placeholder"), + referee_bonus_days=ref_bonus + if ref_bonus is not None else _("no_bonus_placeholder"))) - inv_bonus = settings.referral_bonus_inviter.get(months_period_key) - ref_bonus = settings.referral_bonus_referee.get(months_period_key) - if inv_bonus is not None or ref_bonus is not None: - bonus_info_parts.append( - _("referral_bonus_per_period", - months=months_period_key, - inviter_bonus_days=inv_bonus - if inv_bonus is not None else _("no_bonus_placeholder"), - referee_bonus_days=ref_bonus - if ref_bonus is not None else _("no_bonus_placeholder"))) - - bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _( - "referral_no_bonuses_configured") + bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _( + "referral_no_bonuses_configured") # Get referral statistics referral_stats = await referral_service.get_referral_stats(session, inviter_user_id) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index ef3e480..108bf25 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -40,11 +40,15 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac return currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available") + traffic_mode = bool(getattr(settings, "traffic_sale_mode", False)) + options = settings.traffic_packages if traffic_mode else settings.subscription_options + text_content = ( + get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period") + ) if options else get_text("no_subscription_options_available") reply_markup = ( - get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n) - if settings.subscription_options + get_subscription_options_keyboard(options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode) + if options else get_back_to_main_menu_markup(current_lang, i18n) ) @@ -125,17 +129,50 @@ async def my_subscription_command_handler( end_date = active.get("end_date") days_left = (end_date.date() - datetime.now().date()).days if end_date else 0 - text = get_text( - "my_subscription_details", - end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", - days_left=max(0, days_left), - status=active.get("status_from_panel", get_text("status_active")).capitalize(), - config_link=active.get("config_link") or get_text("config_link_not_available"), - traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")), - traffic_used=( - f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na") - ), - ) + traffic_mode = bool(getattr(settings, "traffic_sale_mode", False)) + def _fmt_gb(val: Optional[float]) -> str: + if val is None: + return get_text("traffic_na") + try: + if isinstance(val, (int, float)): + val_gb = float(val) / (2**30) + return f"{val_gb:.2f} GB" + except Exception: + pass + return str(val) + + if traffic_mode: + limit_display = _fmt_gb(active.get("traffic_limit_bytes")) + used_display = _fmt_gb(active.get("traffic_used_bytes")) + remaining_display = get_text("traffic_na") + try: + limit_val = active.get("traffic_limit_bytes") or 0 + used_val = active.get("traffic_used_bytes") or 0 + remaining_val = max(0, float(limit_val) - float(used_val)) + remaining_display = _fmt_gb(remaining_val) + except Exception: + pass + text = get_text( + "my_traffic_details", + status=active.get("status_from_panel", get_text("status_active")).capitalize(), + end_date=end_date.strftime("%Y-%m-%d") if end_date else get_text("traffic_no_expiry"), + traffic_limit=limit_display, + traffic_used=used_display, + traffic_left=remaining_display, + config_link=active.get("config_link") or get_text("config_link_not_available"), + ) + else: + text = get_text( + "my_subscription_details", + end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", + days_left=max(0, days_left), + status=active.get("status_from_panel", get_text("status_active")).capitalize(), + config_link=active.get("config_link") or get_text("config_link_not_available"), + traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")), + traffic_used=( + f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na") + ), + ) base_markup = get_back_to_main_menu_markup(current_lang, i18n) kb = base_markup.inline_keyboard @@ -214,7 +251,7 @@ async def my_subscription_command_handler( ]) # 2) Auto-renew toggle (YooKassa only) - if local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active: + if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active: toggle_text = ( get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") ) @@ -226,7 +263,7 @@ async def my_subscription_command_handler( ]) # 3) Payment methods management (when autopayments enabled) - if settings.yookassa_autopayments_active: + if not traffic_mode and settings.yookassa_autopayments_active: prepend_rows.append([ InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage") ]) diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py index 3962b55..947d592 100644 --- a/bot/handlers/user/subscription/payment_methods.py +++ b/bot/handlers/user/subscription/payment_methods.py @@ -390,8 +390,15 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup) return + traffic_mode = getattr(settings, "traffic_sale_mode", False) + def _format_item(p: Payment) -> str: - title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1) + if traffic_mode: + units_val = p.subscription_duration_months or 0 + units_display = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}" + title = p.description or _("traffic_purchase_title", traffic_gb=units_display) + else: + title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1) date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A" return f"{date_str} — {title} — {p.amount:.2f} {p.currency}" @@ -455,4 +462,3 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings except Exception: pass - diff --git a/bot/handlers/user/subscription/payments_crypto.py b/bot/handlers/user/subscription/payments_crypto.py index 267f4d4..ed03c8a 100644 --- a/bot/handlers/user/subscription/payments_crypto.py +++ b/bot/handlers/user/subscription/payments_crypto.py @@ -39,9 +39,10 @@ async def pay_crypto_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_amount = float(price_str) + parts = data_payload.split(":") + months = float(parts[0]) + price_amount = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): try: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -50,7 +51,12 @@ async def pay_crypto_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) invoice_url = await cryptopay_service.create_invoice( session=session, @@ -58,17 +64,22 @@ async def pay_crypto_callback_handler( months=months, amount=price_amount, description=payment_description, + sale_mode=sale_mode, ) if invoice_url: try: await callback.message.edit_text( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( invoice_url, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, @@ -76,12 +87,16 @@ async def pay_crypto_callback_handler( except Exception: try: await callback.message.answer( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( invoice_url, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py index 5266443..1671c98 100644 --- a/bot/handlers/user/subscription/payments_freekassa.py +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -47,9 +47,10 @@ async def pay_fk_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_rub = float(price_str) + parts = data_payload.split(":") + months = float(parts[0]) + price_rub = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): logging.error(f"Invalid pay_fk data in callback: {callback.data}") try: @@ -59,7 +60,12 @@ async def pay_fk_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_record_payload = { @@ -68,7 +74,7 @@ async def pay_fk_callback_handler( "currency": currency_code, "status": "pending_freekassa", "description": payment_description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "freekassa", } @@ -135,12 +141,16 @@ async def pay_fk_callback_handler( ) try: await callback.message.edit_text( - f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + f"{order_info_text}\n\n" + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( location, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, @@ -149,12 +159,16 @@ async def pay_fk_callback_handler( logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.") try: await callback.message.answer( - f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + f"{order_info_text}\n\n" + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( location, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py index 1da2f29..b83e418 100644 --- a/bot/handlers/user/subscription/payments_platega.py +++ b/bot/handlers/user/subscription/payments_platega.py @@ -47,9 +47,10 @@ async def pay_platega_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_rub = float(price_str) + parts = data_payload.split(":") + months = float(parts[0]) + price_rub = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): logging.error(f"Invalid pay_platega data in callback: {callback.data}") try: @@ -59,7 +60,12 @@ async def pay_platega_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_record_payload = { @@ -68,7 +74,7 @@ async def pay_platega_callback_handler( "currency": currency_code, "status": "pending_platega", "description": payment_description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "platega", } @@ -96,6 +102,7 @@ async def pay_platega_callback_handler( "payment_db_id": payment_record.payment_id, "user_id": user_id, "months": months, + "sale_mode": sale_mode, } ) @@ -136,12 +143,16 @@ async def pay_platega_callback_handler( try: await callback.message.edit_text( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( redirect_url, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, @@ -150,12 +161,16 @@ async def pay_platega_callback_handler( logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.") try: await callback.message.answer( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( redirect_url, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py index 47df521..2a3d100 100644 --- a/bot/handlers/user/subscription/payments_severpay.py +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -46,9 +46,10 @@ async def pay_severpay_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_rub = float(price_str) + parts = data_payload.split(":") + months = float(parts[0]) + price_rub = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): logging.error(f"Invalid pay_severpay data in callback: {callback.data}") try: @@ -58,7 +59,12 @@ async def pay_severpay_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_record_payload = { @@ -67,7 +73,7 @@ async def pay_severpay_callback_handler( "currency": currency_code, "status": "pending_severpay", "description": payment_description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "severpay", } @@ -126,12 +132,16 @@ async def pay_severpay_callback_handler( if payment_link: try: await callback.message.edit_text( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( payment_link, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, @@ -140,12 +150,16 @@ async def pay_severpay_callback_handler( logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.") try: await callback.message.answer( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( payment_link, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, diff --git a/bot/handlers/user/subscription/payments_stars.py b/bot/handlers/user/subscription/payments_stars.py index fa4ef47..8ee31cb 100644 --- a/bot/handlers/user/subscription/payments_stars.py +++ b/bot/handlers/user/subscription/payments_stars.py @@ -40,9 +40,10 @@ async def pay_stars_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, stars_price_str = data_payload.split(":") - months = int(months_str) - stars_price = int(stars_price_str) + parts = data_payload.split(":") + months = float(parts[0]) + stars_price = int(float(parts[1])) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): try: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -51,7 +52,12 @@ async def pay_stars_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) payment_db_id = await stars_service.create_invoice( session=session, @@ -59,16 +65,21 @@ async def pay_stars_callback_handler( months=months, stars_price=stars_price, description=payment_description, + sale_mode=sale_mode, ) if payment_db_id: try: await callback.message.edit_text( - get_text("payment_invoice_sent_message", months=months), + get_text( + "payment_invoice_sent_message_traffic" if sale_mode == "traffic" else "payment_invoice_sent_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( text=get_text("back_to_payment_methods_button"), - callback_data=f"subscribe_period:{months}", + callback_data=f"subscribe_period:{human_value}", )] ]), ) @@ -106,9 +117,10 @@ async def handle_successful_stars_payment( payload = (message.successful_payment.invoice_payload if message and message.successful_payment else "") try: - payment_db_id_str, months_str = (payload or "").split(":", 1) - payment_db_id = int(payment_db_id_str) - months = int(months_str) + parts = (payload or "").split(":") + payment_db_id = int(parts[0]) + months = float(parts[1]) if len(parts) > 1 else 0 + sale_mode = parts[2] if len(parts) > 2 else "subscription" except Exception: return @@ -120,4 +132,5 @@ async def handle_successful_stars_payment( months=months, stars_amount=stars_amount, i18n_data=i18n_data, + sale_mode=sale_mode, ) diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py index 0d7592e..e6145e1 100644 --- a/bot/handlers/user/subscription/payments_subscription.py +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -29,8 +29,9 @@ async def select_subscription_period_callback_handler( pass return + traffic_mode = bool(getattr(settings, "traffic_sale_mode", False)) try: - months = int(callback.data.split(":")[-1]) + months = float(callback.data.split(":")[-1]) except (ValueError, IndexError): logging.error(f"Invalid subscription period in callback_data: {callback.data}") try: @@ -39,10 +40,13 @@ async def select_subscription_period_callback_handler( pass return - price_rub = settings.subscription_options.get(months) + price_source = settings.traffic_packages if traffic_mode else settings.subscription_options + stars_price_source = settings.stars_traffic_packages if traffic_mode else settings.stars_subscription_options + + price_rub = price_source.get(months) if price_rub is None: logging.error( - f"Price not found for {months} months subscription period in settings.subscription_options." + f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." ) try: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -51,8 +55,8 @@ async def select_subscription_period_callback_handler( return currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - text_content = get_text("choose_payment_method") - stars_price = settings.stars_subscription_options.get(months) + text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method") + stars_price = stars_price_source.get(months) reply_markup = get_payment_method_keyboard( months, price_rub, @@ -61,6 +65,7 @@ async def select_subscription_period_callback_handler( current_lang, i18n, settings, + sale_mode="traffic" if traffic_mode else "subscription", ) try: diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index c586143..6566551 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -18,10 +18,17 @@ from db.dal import payment_dal, user_billing_dal router = Router(name="user_subscription_payments_yookassa_router") -def _parse_months_and_price(payload: str) -> Optional[Tuple[int, float]]: +def _format_value(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" + + +def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]: try: - months_str, price_str = payload.split(":") - return int(months_str), float(price_str) + parts = payload.split(":") + value = float(parts[0]) + price = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" + return value, price, sale_mode except (ValueError, IndexError): return None @@ -64,19 +71,24 @@ async def _initiate_yk_payment( back_callback: str, payment_method_id: Optional[str] = None, selected_method_internal_id: Optional[int] = None, + sale_mode: str = "subscription", ) -> bool: """Create payment record and initiate YooKassa payment (new card or saved card).""" if not callback.message: return False - payment_description = get_text("payment_description_subscription", months=months) + payment_description = ( + get_text("payment_description_traffic", traffic_gb=_format_value(months)) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) payment_record_data = { "user_id": user_id, "amount": price_rub, "currency": currency_code_for_yk, "status": "pending_yookassa", "description": payment_description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), } db_payment_record = None @@ -109,7 +121,10 @@ async def _initiate_yk_payment( "user_id": str(user_id), "subscription_months": str(months), "payment_db_id": str(db_payment_record.payment_id), + "sale_mode": sale_mode, } + if sale_mode == "traffic": + yookassa_metadata["traffic_gb"] = str(months) if payment_method_id: yookassa_metadata["used_saved_payment_method_id"] = payment_method_id @@ -198,7 +213,11 @@ async def _initiate_yk_payment( try: await callback.message.edit_text( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=_format_value(months), + ), reply_markup=get_payment_url_keyboard( payment_response_yk["confirmation_url"], current_lang, @@ -214,7 +233,11 @@ async def _initiate_yk_payment( ) try: await callback.message.answer( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=_format_value(months), + ), reply_markup=get_payment_url_keyboard( payment_response_yk["confirmation_url"], current_lang, @@ -328,7 +351,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti pass return - parsed = _parse_months_and_price(data_payload) + parsed = _parse_offer_payload(data_payload) if not parsed: logging.error(f"Invalid pay_yk payload structure: {callback.data}") try: @@ -337,10 +360,10 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti pass return - months, price_rub = parsed + months, price_rub, sale_mode = parsed user_id = callback.from_user.id currency_code_for_yk = "RUB" - autopay_enabled = bool(settings.yookassa_autopayments_active) + autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_require_binding = bool( getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) ) @@ -364,6 +387,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti current_lang, i18n, has_saved_cards=True, + sale_mode=sale_mode, ), ) except Exception as e_edit: @@ -377,6 +401,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti current_lang, i18n, has_saved_cards=True, + sale_mode=sale_mode, ), ) except Exception: @@ -400,7 +425,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{_format_value(months)}", + sale_mode=sale_mode, ) try: await callback.answer() @@ -443,7 +469,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti pass return - parsed = _parse_months_and_price(data_payload) + parsed = _parse_offer_payload(data_payload) if not parsed: logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") try: @@ -452,10 +478,10 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti pass return - months, price_rub = parsed + months, price_rub, sale_mode = parsed user_id = callback.from_user.id currency_code_for_yk = "RUB" - autopay_enabled = bool(settings.yookassa_autopayments_active) + autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_require_binding = bool( getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) ) @@ -473,7 +499,8 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{_format_value(months)}", + sale_mode=sale_mode, ) try: await callback.answer() @@ -494,14 +521,6 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set pass return - autopay_enabled = bool(settings.yookassa_autopayments_active) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - try: _, data_payload = callback.data.split(":", 1) except ValueError: @@ -522,9 +541,10 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set return try: - months = int(parts[0]) + months = float(parts[0]) price_rub = float(parts[1]) page = int(parts[2]) if len(parts) > 2 else 0 + sale_mode = parts[3] if len(parts) > 3 else "subscription" except (ValueError, IndexError): logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") try: @@ -533,6 +553,14 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set pass return + autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + user_id = callback.from_user.id try: saved_methods = await user_billing_dal.list_user_payment_methods( @@ -552,6 +580,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set current_lang, i18n, has_saved_cards=False, + sale_mode=sale_mode, ), ) except Exception as e_edit: @@ -565,6 +594,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set current_lang, i18n, has_saved_cards=False, + sale_mode=sale_mode, ), ) except Exception: @@ -596,6 +626,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set current_lang, i18n, page=page, + sale_mode=sale_mode, ), ) except Exception as e_edit: @@ -610,6 +641,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set current_lang, i18n, page=page, + sale_mode=sale_mode, ), ) except Exception: @@ -633,14 +665,6 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett pass return - autopay_enabled = bool(settings.yookassa_autopayments_active) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - if not yookassa_service or not yookassa_service.configured: logging.error("YooKassa service unavailable for pay_yk_use_saved.") try: @@ -673,8 +697,9 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett return try: - months = int(parts[0]) + months = float(parts[0]) price_rub = float(parts[1]) + sale_mode = parts[3] if len(parts) > 3 else "subscription" except (ValueError, IndexError): logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") try: @@ -683,6 +708,14 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett pass return + autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + method_identifier = parts[2] user_id = callback.from_user.id @@ -727,9 +760,10 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=False, - back_callback=f"pay_yk_saved_list:{months}:{price_rub}", + back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}", payment_method_id=selected_method.provider_payment_method_id, selected_method_internal_id=selected_method.method_id, + sale_mode=sale_mode, ) try: await callback.answer() diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index cbe51e1..954e3f5 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -91,19 +91,31 @@ def get_trial_confirmation_keyboard(lang: str, def get_subscription_options_keyboard(subscription_options: Dict[ - int, Optional[int]], currency_symbol_val: str, lang: str, - i18n_instance) -> InlineKeyboardMarkup: + float, Optional[float]], currency_symbol_val: str, lang: str, + i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + def _format_gb(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" if subscription_options: for months, price in subscription_options.items(): if price is not None: - button_text = _("subscribe_for_months_button", - months=months, - price=price, - currency_symbol=currency_symbol_val) + if traffic_mode: + button_text = _( + "buy_traffic_package_button", + traffic_gb=_format_gb(months), + price=price, + currency_symbol=currency_symbol_val, + ) + callback_data = f"subscribe_period:{_format_gb(months)}" + else: + button_text = _("subscribe_for_months_button", + months=months, + price=price, + currency_symbol=currency_symbol_val) + callback_data = f"subscribe_period:{months}" builder.button(text=button_text, - callback_data=f"subscribe_period:{months}") + callback_data=callback_data) builder.adjust(1) builder.row( InlineKeyboardButton(text=_(key="back_to_main_menu_button"), @@ -114,39 +126,43 @@ def get_subscription_options_keyboard(subscription_options: Dict[ def get_payment_method_keyboard(months: int, price: float, stars_price: Optional[int], currency_symbol_val: str, lang: str, - i18n_instance, settings: Settings) -> InlineKeyboardMarkup: + i18n_instance, settings: Settings, sale_mode: str = "subscription") -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + def _format_value(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" + value_str = _format_value(months) + mode_suffix = f":{sale_mode}" for method in settings.payment_methods_order: if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False): builder.button( text=_("pay_with_severpay_button"), - callback_data=f"pay_severpay:{months}:{price}", + callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}", ) elif method == "freekassa" and settings.FREEKASSA_ENABLED: builder.button( text=_("pay_with_sbp_button"), - callback_data=f"pay_fk:{months}:{price}", + callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}", ) elif method == "platega" and settings.PLATEGA_ENABLED: builder.button( text=_("pay_with_platega_button"), - callback_data=f"pay_platega:{months}:{price}", + callback_data=f"pay_platega:{value_str}:{price}{mode_suffix}", ) elif method == "yookassa" and settings.YOOKASSA_ENABLED: builder.button( text=_("pay_with_yookassa_button"), - callback_data=f"pay_yk:{months}:{price}", + callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}", ) elif method == "stars" and settings.STARS_ENABLED and stars_price is not None: builder.button( text=_("pay_with_stars_button"), - callback_data=f"pay_stars:{months}:{stars_price}", + callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}", ) elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED: builder.button( text=_("pay_with_cryptopay_button"), - callback_data=f"pay_crypto:{months}:{price}", + callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}", ) builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe") @@ -178,28 +194,33 @@ def get_yk_autopay_choice_keyboard( lang: str, i18n_instance, has_saved_cards: bool = True, + sale_mode: str = "subscription", ) -> InlineKeyboardMarkup: """Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled.""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() price_str = str(price) + def _format_value(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" + value_str = _format_value(months) + suffix = f":{sale_mode}" if has_saved_cards: builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_saved_card_button"), - callback_data=f"pay_yk_saved_list:{months}:{price_str}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}", ) ) builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_new_card_button"), - callback_data=f"pay_yk_new:{months}:{price_str}", + callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}", ) ) builder.row( InlineKeyboardButton( text=_(key="back_to_payment_methods_button"), - callback_data=f"subscribe_period:{months}", + callback_data=f"subscribe_period:{value_str}", ) ) return builder.as_markup() @@ -212,6 +233,7 @@ def get_yk_saved_cards_keyboard( lang: str, i18n_instance, page: int = 0, + sale_mode: str = "subscription", ) -> InlineKeyboardMarkup: """Paginated keyboard for selecting a saved YooKassa card.""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) @@ -221,12 +243,16 @@ def get_yk_saved_cards_keyboard( start = page * per_page end = min(total, start + per_page) price_str = str(price) + def _format_value(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" + value_str = _format_value(months) + suffix = f":{sale_mode}" for method_id, title in cards[start:end]: builder.row( InlineKeyboardButton( text=title, - callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}", + callback_data=f"pay_yk_use_saved:{value_str}:{price_str}:{method_id}{suffix}", ) ) @@ -235,14 +261,14 @@ def get_yk_saved_cards_keyboard( nav_buttons.append( InlineKeyboardButton( text="⬅️", - callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page-1}{suffix}", ) ) if end < total: nav_buttons.append( InlineKeyboardButton( text="➡️", - callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page+1}{suffix}", ) ) if nav_buttons: @@ -251,13 +277,13 @@ def get_yk_saved_cards_keyboard( builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_new_card_button"), - callback_data=f"pay_yk_new:{months}:{price_str}", + callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}", ) ) builder.row( InlineKeyboardButton( text=_(key="back_to_autopay_method_choice_button"), - callback_data=f"pay_yk:{months}:{price_str}", + callback_data=f"pay_yk:{value_str}:{price_str}{suffix}", ) ) return builder.as_markup() diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index b846438..4afa298 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -63,6 +63,7 @@ class CryptoPayService: months: int, amount: float, description: str, + sale_mode: str = "subscription", ) -> Optional[str]: if not self.configured or not self.client: logging.error("CryptoPayService not configured") @@ -78,7 +79,7 @@ class CryptoPayService: "currency": self.settings.CRYPTOPAY_ASSET, "status": "pending_cryptopay", "description": description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "cryptopay", }, ) @@ -94,6 +95,8 @@ class CryptoPayService: "user_id": str(user_id), "subscription_months": str(months), "payment_db_id": str(payment_record.payment_id), + "sale_mode": sale_mode, + "traffic_gb": str(months) if sale_mode == "traffic" else None, }) try: invoice = await self.client.create_invoice( @@ -132,8 +135,10 @@ class CryptoPayService: try: meta = json.loads(invoice.payload) user_id = int(meta["user_id"]) - months = int(meta["subscription_months"]) + months = float(meta.get("subscription_months") or 0) payment_db_id = int(meta["payment_db_id"]) + sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription") + traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months except Exception as e: logging.error(f"Failed to parse CryptoPay payload: {e}") return @@ -156,18 +161,22 @@ class CryptoPayService: activation = await subscription_service.activate_subscription( session, user_id, - months, + int(months) if sale_mode != "traffic" else 0, float(invoice.amount), payment_db_id, provider="cryptopay", + sale_mode=sale_mode, + traffic_gb=traffic_gb if sale_mode == "traffic" else None, ) - referral_bonus = await referral_service.apply_referral_bonuses_for_payment( - session, - user_id, - months, - current_payment_db_id=payment_db_id, - skip_if_active_before_payment=False, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await referral_service.apply_referral_bonuses_for_payment( + session, + user_id, + int(months) or 1, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=False, + ) await session.commit() except Exception as e: await session.rollback() @@ -186,7 +195,12 @@ class CryptoPayService: final_end = referral_bonus["referee_new_end_date"] applied_days = referral_bonus.get("referee_bonus_applied_days", 0) - if applied_days: + if sale_mode == "traffic": + text = _("payment_successful_traffic_full", + traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}", + end_date=final_end.strftime('%Y-%m-%d') if final_end else "—", + config_link=config_link) + elif applied_days: inviter_name_display = _("friend_placeholder") if db_user and db_user.referred_by_id: inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) @@ -197,7 +211,7 @@ class CryptoPayService: elif inviter.username: inviter_name_display = username_for_display(inviter.username, with_at=False) text = _("payment_successful_with_referral_bonus_full", - months=months, + months=int(months), base_end_date=activation["end_date"].strftime('%Y-%m-%d'), bonus_days=applied_days, final_end_date=final_end.strftime('%Y-%m-%d'), @@ -205,8 +219,8 @@ class CryptoPayService: config_link=config_link) else: text = _("payment_successful_full", - months=months, - end_date=final_end.strftime('%Y-%m-%d'), + months=int(months), + end_date=final_end.strftime('%Y-%m-%d') if final_end else "—", config_link=config_link) markup = get_connect_and_main_keyboard( @@ -231,7 +245,8 @@ class CryptoPayService: user_id=user_id, amount=float(invoice.amount), currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, - months=months, + months=int(months) if sale_mode != "traffic" else 0, + traffic_gb=traffic_gb if sale_mode == "traffic" else None, payment_provider="crypto_pay", username=user.username if user else None ) diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index 23f266d..ab1457a 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -284,23 +284,28 @@ class FreeKassaService: ) months = payment.subscription_duration_months or 1 + sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" activation = await self.subscription_service.activate_subscription( session, payment.user_id, - months, + int(months) if sale_mode != "traffic" else 0, float(payment.amount), payment.payment_id, provider="freekassa", + sale_mode=sale_mode, + traffic_gb=months if sale_mode == "traffic" else None, ) - referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( - session, - payment.user_id, - months, - current_payment_db_id=payment.payment_id, - skip_if_active_before_payment=False, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + int(months), + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) await session.commit() except Exception as e: @@ -315,6 +320,7 @@ class FreeKassaService: config_link = None final_end = None months = payment.subscription_duration_months or 1 + sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" if activation: config_link = activation.get("subscription_url") final_end = activation.get("end_date") @@ -334,7 +340,14 @@ class FreeKassaService: else: end_date_str = _("config_link_not_available") - if applied_days: + traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}" + + if sale_mode == "traffic": + text = _("payment_successful_traffic_full", + traffic_gb=traffic_label, + end_date=end_date_str if final_end else "", + config_link=config_link) + elif applied_days: inviter_name_display = _("friend_placeholder") if db_user and db_user.referred_by_id: inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) @@ -392,7 +405,8 @@ class FreeKassaService: user_id=payment.user_id, amount=float(payment.amount), currency=self.default_currency, - months=months, + months=int(months) if sale_mode != "traffic" else 0, + traffic_gb=months if sale_mode == "traffic" else None, payment_provider="freekassa", username=db_user.username if db_user else None, ) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 99a6290..53b4ec5 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -222,7 +222,8 @@ class NotificationService: async def notify_payment_received(self, user_id: int, amount: float, currency: str, months: int, payment_provider: str, - username: Optional[str] = None): + username: Optional[str] = None, + traffic_gb: Optional[float] = None): """Send notification about successful payment""" if not self.settings.LOG_PAYMENTS: return @@ -243,23 +244,42 @@ class NotificationService: "platega": "💳", "severpay": "💳", }.get(payment_provider.lower(), "💰") - - message = _( - "log_payment_received", - default="{provider_emoji} Получен платеж\n\n" - "👤 Пользователь: {user_display}\n" - "💰 Сумма: {amount} {currency}\n" - "📅 Период: {months} мес.\n" - "🏦 Провайдер: {payment_provider}\n" - "🕐 Время: {timestamp}", - provider_emoji=provider_emoji, - user_display=user_display, - amount=amount, - currency=currency, - months=months, - payment_provider=payment_provider, - timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") - ) + + if traffic_gb is not None: + traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}" + message = _( + "log_payment_received_traffic", + default="{provider_emoji} Получен платеж\n\n" + "👤 Пользователь: {user_display}\n" + "💰 Сумма: {amount} {currency}\n" + "🗂 Трафик: {traffic_gb} GB\n" + "🏦 Провайдер: {payment_provider}\n" + "🕐 Время: {timestamp}", + provider_emoji=provider_emoji, + user_display=user_display, + amount=amount, + currency=currency, + traffic_gb=traffic_label, + payment_provider=payment_provider, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + else: + message = _( + "log_payment_received", + default="{provider_emoji} Получен платеж\n\n" + "👤 Пользователь: {user_display}\n" + "💰 Сумма: {amount} {currency}\n" + "📅 Период: {months} мес.\n" + "🏦 Провайдер: {payment_provider}\n" + "🕐 Время: {timestamp}", + provider_emoji=provider_emoji, + user_display=user_display, + amount=amount, + currency=currency, + months=months, + payment_provider=payment_provider, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) # Send to log channel profile_keyboard = self._build_profile_keyboard(_, user_id) diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index c449034..3acc13b 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -157,6 +157,7 @@ class PlategaService: return web.Response(text="ok") payment_months = payment.subscription_duration_months or 1 + sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" if status == "CONFIRMED": if amount_raw is not None: @@ -184,19 +185,23 @@ class PlategaService: activation = await self.subscription_service.activate_subscription( session, payment.user_id, - payment_months, + int(payment_months) if sale_mode != "traffic" else 0, float(payment.amount), payment.payment_id, provider="platega", + sale_mode=sale_mode, + traffic_gb=payment_months if sale_mode == "traffic" else None, ) - referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( - session, - payment.user_id, - payment_months, - current_payment_db_id=payment.payment_id, - skip_if_active_before_payment=False, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + int(payment_months), + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) await session.commit() except Exception as exc: @@ -221,7 +226,16 @@ class PlategaService: final_end = referral_bonus["referee_new_end_date"] applied_days = referral_bonus.get("referee_bonus_applied_days", 0) - if applied_days: + traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}" + + if sale_mode == "traffic": + text = _( + "payment_successful_traffic_full", + traffic_gb=traffic_label, + end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + config_link=config_link, + ) + elif applied_days: inviter_name_display = _("friend_placeholder") if db_user and db_user.referred_by_id: inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) @@ -281,7 +295,8 @@ class PlategaService: user_id=payment.user_id, amount=float(payment.amount), currency=currency, - months=payment_months, + months=int(payment_months) if sale_mode != "traffic" else 0, + traffic_gb=payment_months if sale_mode == "traffic" else None, payment_provider="platega", username=db_user.username if db_user else None, ) diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index a2b9b32..379305f 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -190,6 +190,7 @@ class SeverPayService: return web.json_response({"status": False, "msg": "payment_not_found"}, status=404) payment_months = payment.subscription_duration_months or 1 + sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" if status == "success": try: await payment_dal.update_provider_payment_and_status( @@ -202,19 +203,23 @@ class SeverPayService: activation = await self.subscription_service.activate_subscription( session, payment.user_id, - payment_months, + int(payment_months) if sale_mode != "traffic" else 0, float(payment.amount), payment.payment_id, provider="severpay", + sale_mode=sale_mode, + traffic_gb=payment_months if sale_mode == "traffic" else None, ) - referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( - session, - payment.user_id, - payment_months, - current_payment_db_id=payment.payment_id, - skip_if_active_before_payment=False, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + int(payment_months), + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) await session.commit() except Exception as exc: @@ -239,7 +244,16 @@ class SeverPayService: final_end = referral_bonus["referee_new_end_date"] applied_days = referral_bonus.get("referee_bonus_applied_days", 0) - if applied_days: + traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}" + + if sale_mode == "traffic": + text = _( + "payment_successful_traffic_full", + traffic_gb=traffic_label, + end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + config_link=config_link, + ) + elif applied_days: inviter_name_display = _("friend_placeholder") if db_user and db_user.referred_by_id: inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) @@ -299,7 +313,8 @@ class SeverPayService: user_id=payment.user_id, amount=float(payment.amount), currency=payment.currency, - months=payment_months, + months=int(payment_months) if sale_mode != "traffic" else 0, + traffic_gb=payment_months if sale_mode == "traffic" else None, payment_provider="severpay", username=db_user.username if db_user else None, ) diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index e278796..4fc9745 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -26,14 +26,14 @@ class StarsService: self.referral_service = referral_service async def create_invoice(self, session: AsyncSession, user_id: int, months: int, - stars_price: int, description: str) -> Optional[int]: + stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]: payment_record_data = { "user_id": user_id, "amount": float(stars_price), "currency": "XTR", "status": "pending_stars", "description": description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "telegram_stars", } try: @@ -46,7 +46,7 @@ class StarsService: exc_info=True) return None - payload = f"{db_payment_record.payment_id}:{months}" + payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}" prices = [LabeledPrice(label=description, amount=stars_price)] try: await self.bot.send_invoice( @@ -69,7 +69,8 @@ class StarsService: payment_db_id: int, months: int, stars_amount: int, - i18n_data: dict) -> None: + i18n_data: dict, + sale_mode: str = "subscription") -> None: try: await payment_dal.update_provider_payment_and_status( session, payment_db_id, @@ -86,23 +87,27 @@ class StarsService: activation_details = await self.subscription_service.activate_subscription( session, message.from_user.id, - months, + int(months) if sale_mode != "traffic" else 0, float(stars_amount), payment_db_id, provider="telegram_stars", + sale_mode=sale_mode, + traffic_gb=months if sale_mode == "traffic" else None, ) if not activation_details or not activation_details.get("end_date"): logging.error( f"Failed to activate subscription after stars payment for user {message.from_user.id}") return - referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( - session, - message.from_user.id, - months, - current_payment_db_id=payment_db_id, - skip_if_active_before_payment=False, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + message.from_user.id, + int(months) or 1, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=False, + ) await session.commit() applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None @@ -120,7 +125,14 @@ class StarsService: "config_link_not_available" ) - if applied_days: + if sale_mode == "traffic": + success_msg = _( + "payment_successful_traffic_full", + traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}", + end_date=final_end.strftime('%Y-%m-%d'), + config_link=config_link, + ) + elif applied_days: inviter_name_display = _("friend_placeholder") db_user = await user_dal.get_user_by_id(session, message.from_user.id) if db_user and db_user.referred_by_id: @@ -170,9 +182,10 @@ class StarsService: user_id=message.from_user.id, amount=float(stars_amount), currency="XTR", - months=months, + months=int(months) if sale_mode != "traffic" else 0, payment_provider="stars", - username=user.username if user else None + username=user.username if user else None, + traffic_gb=months if sale_mode == "traffic" else None, ) except Exception as e: logging.error(f"Failed to send stars payment notification: {e}") diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index f8a5303..fe9adcd 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -419,6 +419,119 @@ class SubscriptionService: "subscription_url": final_subscription_url, } + async def _activate_traffic_package( + self, + session: AsyncSession, + user_id: int, + traffic_gb: float, + payment_amount: float, + payment_db_id: int, + provider: str = "yookassa", + ) -> Optional[Dict[str, Any]]: + """Activate or extend a traffic-based package instead of a time-based subscription.""" + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + logging.error("User %s not found for traffic package activation", user_id) + return None + + panel_user_uuid, panel_sub_link_id, panel_short_uuid, _ = ( + await self._get_or_create_panel_user_link_details(session, user_id, db_user) + ) + + if not panel_user_uuid or not panel_sub_link_id: + logging.error("Failed to ensure panel linkage for user %s during traffic activation", user_id) + return None + + panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {} + traffic_info = panel_user_data.get("userTraffic") or {} + current_limit = panel_user_data.get("trafficLimitBytes") + current_used = traffic_info.get("usedTrafficBytes") + + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, panel_user_uuid + ) + if current_limit is None and active_sub: + current_limit = active_sub.traffic_limit_bytes + if current_used is None and active_sub: + current_used = active_sub.traffic_used_bytes + + purchase_bytes = int(float(traffic_gb) * (1024**3)) + new_limit = (current_limit or 0) + purchase_bytes + + start_date = datetime.now(timezone.utc) + # Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further. + far_future = datetime(2099, 1, 1, tzinfo=timezone.utc) + final_end_date = far_future + if active_sub and active_sub.end_date and active_sub.end_date > final_end_date: + final_end_date = active_sub.end_date + + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_user_uuid, panel_sub_link_id + ) + + sub_payload = { + "user_id": user_id, + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_link_id, + "start_date": start_date, + "end_date": final_end_date, + "duration_months": 0, + "is_active": True, + "status_from_panel": "ACTIVE", + "traffic_limit_bytes": new_limit, + "traffic_used_bytes": current_used, + "provider": provider, + "skip_notifications": True, + "auto_renew_enabled": False, + } + + try: + new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload) + except Exception as exc: + logging.error("Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True) + return None + + panel_update_payload = self._build_panel_update_payload( + panel_user_uuid=panel_user_uuid, + expire_at=final_end_date, + status="ACTIVE", + traffic_limit_bytes=new_limit, + traffic_limit_strategy="NO_RESET", + ) + + panel_update_payload["description"] = "\n".join( + [ + (db_user.username or "") if db_user else "", + (db_user.first_name or "") if db_user else "", + (db_user.last_name or "") if db_user else "", + ] + ) + + updated_panel_user = await self.panel_service.update_user_details_on_panel( + panel_user_uuid, panel_update_payload + ) + if not updated_panel_user or updated_panel_user.get("error"): + logging.warning( + "Panel user details update FAILED for traffic package user %s. Response: %s", + panel_user_uuid, + updated_panel_user, + ) + return None + + final_subscription_url = updated_panel_user.get("subscriptionUrl") + final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + + return { + "subscription_id": new_or_updated_sub.subscription_id, + "end_date": final_end_date, + "is_active": True, + "panel_user_uuid": panel_user_uuid, + "panel_short_uuid": final_panel_short_uuid, + "subscription_url": final_subscription_url, + "applied_promo_bonus_days": 0, + "traffic_limit_bytes": new_limit, + } + async def activate_subscription( self, session: AsyncSession, @@ -428,8 +541,21 @@ class SubscriptionService: payment_db_id: int, promo_code_id_from_payment: Optional[int] = None, provider: str = "yookassa", + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> Optional[Dict[str, Any]]: + if sale_mode == "traffic" or getattr(self.settings, "traffic_sale_mode", False): + target_gb = traffic_gb if traffic_gb is not None else float(months) + return await self._activate_traffic_package( + session=session, + user_id=user_id, + traffic_gb=target_gb, + payment_amount=payment_amount, + payment_db_id=payment_db_id, + provider=provider, + ) + db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: logging.error( @@ -447,6 +573,11 @@ class SubscriptionService: ) return None + try: + months_int = int(months) + except Exception: + months_int = 1 + current_active_sub = await subscription_dal.get_active_subscription_by_user_id( session, user_id, panel_user_uuid ) @@ -459,7 +590,7 @@ class SubscriptionService: start_date = current_active_sub.end_date # base duration by months - end_after_months = add_months(start_date, months) + end_after_months = add_months(start_date, months_int) duration_days_total = (end_after_months - start_date).days applied_promo_bonus_days = 0 @@ -512,7 +643,7 @@ class SubscriptionService: "panel_subscription_uuid": panel_sub_link_id, "start_date": start_date, "end_date": final_end_date, - "duration_months": months, + "duration_months": months_int, "is_active": True, "status_from_panel": "ACTIVE", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, @@ -823,6 +954,9 @@ class SubscriptionService: sub: Subscription, ) -> bool: """Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure.""" + if getattr(self.settings, "traffic_sale_mode", False): + logging.info("Auto-renew skipped: traffic sale mode enabled") + return True if not sub.auto_renew_enabled: return True # If autopayments are disabled globally, skip charging attempts @@ -902,6 +1036,7 @@ class SubscriptionService: status: Optional[str] = None, traffic_limit_bytes: Optional[int] = None, include_uuid: bool = True, + traffic_limit_strategy: Optional[str] = None, ) -> Dict[str, Any]: payload: Dict[str, Any] = {} if include_uuid and panel_user_uuid: @@ -912,7 +1047,7 @@ class SubscriptionService: payload["status"] = status if traffic_limit_bytes is not None: payload["trafficLimitBytes"] = traffic_limit_bytes - payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY + payload["trafficLimitStrategy"] = traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY if self.settings.parsed_user_squad_uuids: payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids if self.settings.parsed_user_external_squad_uuid: diff --git a/config/settings.py b/config/settings.py index 783dca4..535efb9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -105,6 +105,15 @@ class Settings(BaseSettings): STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None) PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None) + TRAFFIC_PACKAGES: Optional[str] = Field( + default=None, + description="Comma-separated list of traffic packages in the format ':', e.g. '10:199,50:799'", + ) + STARS_TRAFFIC_PACKAGES: Optional[str] = Field( + default=None, + description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'", + ) + SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True) SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True) @@ -360,6 +369,62 @@ class Settings(BaseSettings): options[12] = self.STARS_PRICE_12_MONTHS return options + @computed_field + @property + def traffic_packages(self) -> Dict[float, float]: + """ + Mapping of traffic size in GB to price in the default currency. + """ + packages: Dict[float, float] = {} + raw = (self.TRAFFIC_PACKAGES or "").strip() + if not raw: + return packages + for part in raw.split(","): + chunk = part.strip() + if not chunk or ":" not in chunk: + continue + size_str, price_str = chunk.split(":", 1) + try: + size_gb = float(size_str.strip()) + price_val = float(price_str.strip()) + if size_gb > 0 and price_val >= 0: + packages[size_gb] = price_val + except ValueError: + logging.warning("Invalid TRAFFIC_PACKAGES entry skipped: %s", chunk) + continue + return packages + + @computed_field + @property + def stars_traffic_packages(self) -> Dict[float, int]: + """ + Mapping of traffic size in GB to price in Telegram Stars. + """ + packages: Dict[float, int] = {} + raw = (self.STARS_TRAFFIC_PACKAGES or "").strip() + if not raw: + return packages + for part in raw.split(","): + chunk = part.strip() + if not chunk or ":" not in chunk: + continue + size_str, price_str = chunk.split(":", 1) + try: + size_gb = float(size_str.strip()) + price_val = int(float(price_str.strip())) + if size_gb > 0 and price_val >= 0: + packages[size_gb] = price_val + except ValueError: + logging.warning("Invalid STARS_TRAFFIC_PACKAGES entry skipped: %s", chunk) + continue + return packages + + @computed_field + @property + def traffic_sale_mode(self) -> bool: + """When true, the bot sells traffic packages instead of time-based subscriptions.""" + return bool(self.traffic_packages) + def referral_bonus_inviter(self) -> Dict[int, int]: bonuses: Dict[int, int] = {} if self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH is not None: diff --git a/locales/en.json b/locales/en.json index 1176664..7732f34 100644 --- a/locales/en.json +++ b/locales/en.json @@ -25,8 +25,11 @@ "error_displaying_menu": "Error displaying menu.", "main_menu_unknown_action": "Unknown action.", "select_subscription_period": "Select subscription period:", + "select_traffic_package": "Select a traffic package:", "subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}", + "buy_traffic_package_button": "{traffic_gb} GB - {price} {currency_symbol}", "choose_payment_method": "Choose payment method:", + "choose_payment_method_traffic": "Choose how to pay for the traffic package:", "pay_button": "💳 Pay", "pay_with_yookassa_button": "💳 YooKassa", "yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:", @@ -56,11 +59,15 @@ "my_devices_feature_disabled": "The My Devices section is currently unavailable.", "payment_description_subscription": "Subscription payment for {months} mo.", + "payment_description_traffic": "Traffic package {traffic_gb} GB", "payment_link_message": "To pay for {months} mo. subscription, click the button below:", + "payment_link_message_traffic": "To pay for a {traffic_gb} GB package, tap the button below:", "free_kassa_order_info": "Order #{order_id} from {date}", "payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.", + "payment_invoice_sent_message_traffic": "Invoice for {traffic_gb} GB sent above. Complete the payment or pick another method below.", "payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.", "payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", + "payment_successful_traffic_full": "✅ Payment successful!\nYour {traffic_gb} GB package is active.\nValidity: {end_date}\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", "payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", "payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.", "config_link_not_available": "not available, contact support", @@ -80,6 +87,7 @@ "no_button": "No", "referral_program_info_new": "🎁 Referral Program\n\n📊 Your stats:\n👥 Friends invited: {invited_count}\n💳 Purchased subscription: {purchased_count}\n\n🔗 Your link:\n{referral_link}\n\n💰 Invitation bonuses:\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", "referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: {inviter_bonus_days} days\n ➢ Friend: {referee_bonus_days} days", + "referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.", "referral_share_message_button": "📩 Message for friend", "referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", "friend_placeholder": "friend", @@ -131,6 +139,7 @@ "admin_csv_provider": "Provider", "admin_csv_status": "Status", "admin_csv_description": "Description", + "admin_csv_units": "Months/GB", "admin_csv_months": "Months", "admin_csv_created_at": "Created At", "admin_csv_provider_payment_id": "Provider Payment ID", @@ -297,6 +306,7 @@ "log_open_referrer_profile_button": "👤 Referrer profile", "log_new_user_registration": "👤 New User\n\n🆔 ID: {user_id}\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}", "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", + "log_payment_received_traffic": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 Traffic: {traffic_gb} GB\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", "log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}", "log_panel_sync": "{status_emoji} Panel Synchronization\n\n📊 Status: {status}\n👥 Users processed: {users_processed}\n📋 Subscriptions synced: {subs_synced}\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}", @@ -416,7 +426,11 @@ "admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}", "admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}", "admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})", + "admin_payment_traffic_label": "🗂 Traffic: {traffic_gb} GB", + "admin_payment_months_label": "📅 Period: {months} mo.", "my_subscription_details": "🔐 My Subscription\n\n⏰ Status: {status}\n📅 Active until: {end_date}\n📆 Days left: {days_left}\n\n🔗 Configuration link:\n{config_link}\n\n📊 Traffic:\nLimit: {traffic_limit}\nUsed: {traffic_used}", + "my_traffic_details": "🔐 My Traffic\n\n⏰ Status: {status}\n📅 Valid until: {end_date}\n\n🔗 Configuration link:\n{config_link}\n\n📊 Traffic:\nLimit: {traffic_limit}\nUsed: {traffic_used}\nLeft: {traffic_left}", + "traffic_no_expiry": "no limit", "autorenew_enable_button": "🔄 Enable auto-renew", "autorenew_disable_button": "🛑 Disable auto-renew", "subscription_autorenew_updated": "Auto-renew settings updated.", @@ -439,6 +453,7 @@ "payment_method_tx_history_title": "📜 Transactions history", "payment_method_no_history": "No transactions history.", "subscription_purchase_title": "Subscription purchase for {months} mo.", + "traffic_purchase_title": "Traffic purchase {traffic_gb} GB", "autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.", "subscription_not_active": "You don't have an active subscription.", "error_service_unavailable": "Service unavailable. Please try again later.", diff --git a/locales/ru.json b/locales/ru.json index b3f669a..42b3e2b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -25,8 +25,11 @@ "error_displaying_menu": "Ошибка отображения меню.", "main_menu_unknown_action": "Неизвестное действие.", "select_subscription_period": "Выберите срок подписки:", + "select_traffic_package": "Выберите пакет трафика:", "subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}", + "buy_traffic_package_button": "{traffic_gb} ГБ - {price} {currency_symbol}", "choose_payment_method": "Выберите способ оплаты:", + "choose_payment_method_traffic": "Выберите способ оплаты пакета трафика:", "pay_button": "💳 Оплатить", "pay_with_yookassa_button": "💳 ЮKassa", "yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:", @@ -56,11 +59,15 @@ "cancel_button": "❌ Отмена", "payment_description_subscription": "Оплата подписки на {months} мес.", + "payment_description_traffic": "Пакет трафика {traffic_gb} ГБ", "payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:", + "payment_link_message_traffic": "Для оплаты пакета {traffic_gb} ГБ нажмите кнопку ниже:", "free_kassa_order_info": "Заказ №{order_id} от {date}", "payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.", + "payment_invoice_sent_message_traffic": "Счет на пакет {traffic_gb} ГБ отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.", "payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.", "payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", + "payment_successful_traffic_full": "✅ Оплата прошла успешно!\nВаш пакет {traffic_gb} ГБ активирован.\nДата действия: {end_date}\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.", "config_link_not_available": "недоступна, обратитесь в поддержку", @@ -80,6 +87,7 @@ "no_button": "Нет", "referral_program_info_new": "🎁 Реферальная программа\n\n📊 Твоя статистика:\n👥 Приглашено друзей: {invited_count}\n💳 Купили подписку: {purchased_count}\n\n🔗 Твоя ссылка:\n{referral_link}\n\n💰 Бонусы за приглашения:\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: {inviter_bonus_days} дн.\n ➢ Друг: {referee_bonus_days} дн.", + "referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.", "referral_share_message_button": "📩 Сообщение для друга", "referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", "friend_placeholder": "друг", @@ -131,6 +139,7 @@ "admin_csv_provider": "Платежная система", "admin_csv_status": "Статус", "admin_csv_description": "Описание", + "admin_csv_units": "Месяцы/ГБ", "admin_csv_months": "Месяцев", "admin_csv_created_at": "Дата создания", "admin_csv_provider_payment_id": "ID платежа в системе", @@ -297,6 +306,7 @@ "log_open_referrer_profile_button": "👤 Профиль пригласившего", "log_new_user_registration": "👤 Новый пользователь\n\n🆔 ID: {user_id}\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}", "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", + "log_payment_received_traffic": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 Трафик: {traffic_gb} ГБ\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", "log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}", "log_panel_sync": "{status_emoji} Синхронизация с панелью\n\n📊 Статус: {status}\n👥 Обработано пользователей: {users_processed}\n📋 Синхронизировано подписок: {subs_synced}\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}", @@ -416,7 +426,11 @@ "admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}", "admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}", "admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})", + "admin_payment_traffic_label": "🗂 Трафик: {traffic_gb} ГБ", + "admin_payment_months_label": "📅 Период: {months} мес.", "my_subscription_details": "🔐 Моя подписка\n\n⏰ Статус: {status}\n📅 Действует до: {end_date}\n📆 Осталось дней: {days_left}\n\n🔗 Ссылка на конфигурацию:\n{config_link}\n\n📊 Трафик:\nЛимит: {traffic_limit}\nИспользовано: {traffic_used}", + "my_traffic_details": "🔐 Мой трафик\n\n⏰ Статус: {status}\n📅 Действует до: {end_date}\n\n🔗 Ссылка на конфигурацию:\n{config_link}\n\n📊 Трафик:\nЛимит: {traffic_limit}\nИспользовано: {traffic_used}\nОсталось: {traffic_left}", + "traffic_no_expiry": "без ограничения", "autorenew_enable_button": "🔄 Включить автопродление", "autorenew_disable_button": "🛑 Отключить автопродление", "subscription_autorenew_updated": "Настройки автопродления обновлены.", @@ -439,6 +453,7 @@ "payment_method_tx_history_title": "📜 История операций", "payment_method_no_history": "История операций отсутствует.", "subscription_purchase_title": "Покупка подписки на {months} мес.", + "traffic_purchase_title": "Покупка {traffic_gb} ГБ", "autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».", "subscription_not_active": "У вас нет активной подписки.", "error_service_unavailable": "Сервис недоступен. Попробуйте позже.",