@@ -81,6 +81,8 @@ def build_core_services(
|
|||||||
|
|
||||||
# Wire services that depend on each other
|
# Wire services that depend on each other
|
||||||
try:
|
try:
|
||||||
|
# Allow subscription service to consume promo codes
|
||||||
|
setattr(subscription_service, "promo_code_service", promo_code_service)
|
||||||
# Attach YooKassa to subscription service for auto-renew charges
|
# Attach YooKassa to subscription service for auto-renew charges
|
||||||
setattr(subscription_service, "yookassa_service", yookassa_service)
|
setattr(subscription_service, "yookassa_service", yookassa_service)
|
||||||
# Allow panel webhook to trigger renewals through subscription service
|
# Allow panel webhook to trigger renewals through subscription service
|
||||||
|
|||||||
@@ -190,9 +190,19 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
|||||||
# Both failed
|
# Both failed
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
f"Promo code '{code_input}' application failed for user {user.id}. "
|
||||||
|
f"Bonus reason: {result}. Discount reason: {result_discount}"
|
||||||
)
|
)
|
||||||
response_to_user_text = result # Original error message from bonus code attempt
|
bonus_not_found_text = _(
|
||||||
|
"promo_code_not_found", code=code_input.upper()
|
||||||
|
)
|
||||||
|
discount_not_found_text = _(
|
||||||
|
"promo_code_not_found_or_not_discount", code=code_input.upper()
|
||||||
|
)
|
||||||
|
if result != bonus_not_found_text and result_discount == discount_not_found_text:
|
||||||
|
response_to_user_text = result
|
||||||
|
else:
|
||||||
|
response_to_user_text = result_discount # Prefer the discount attempt error
|
||||||
reply_markup = get_back_to_main_menu_markup(
|
reply_markup = get_back_to_main_menu_markup(
|
||||||
current_lang, i18n
|
current_lang, i18n
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -675,7 +675,7 @@ async def main_action_callback_handler(
|
|||||||
|
|
||||||
if action == "subscribe":
|
if action == "subscribe":
|
||||||
await user_subscription_handlers.display_subscription_options(
|
await user_subscription_handlers.display_subscription_options(
|
||||||
callback, i18n_data, settings, session)
|
callback, i18n_data, settings, session, promo_code_service=promo_code_service)
|
||||||
elif action == "my_subscription":
|
elif action == "my_subscription":
|
||||||
await user_subscription_handlers.my_subscription_command_handler(
|
await user_subscription_handlers.my_subscription_command_handler(
|
||||||
callback, i18n_data, settings, panel_service, subscription_service,
|
callback, i18n_data, settings, panel_service, subscription_service,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from aiogram import Router, F, types, Bot
|
from aiogram import Router, F, types, Bot
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||||
@@ -39,7 +40,13 @@ def _hwid_callback_token(hwid: Optional[str]) -> str:
|
|||||||
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
|
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
async def display_subscription_options(
|
||||||
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
promo_code_service=None,
|
||||||
|
):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
|
||||||
@@ -61,21 +68,46 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
|||||||
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||||
|
|
||||||
|
options_are_stars = False
|
||||||
if traffic_mode:
|
if traffic_mode:
|
||||||
if traffic_packages:
|
if traffic_packages:
|
||||||
options = traffic_packages
|
options = traffic_packages
|
||||||
elif stars_traffic_packages:
|
elif stars_traffic_packages:
|
||||||
options = stars_traffic_packages
|
options = stars_traffic_packages
|
||||||
currency_symbol_val = "⭐"
|
currency_symbol_val = "⭐"
|
||||||
|
options_are_stars = True
|
||||||
else:
|
else:
|
||||||
options = {}
|
options = {}
|
||||||
else:
|
else:
|
||||||
options = settings.subscription_options
|
options = settings.subscription_options
|
||||||
|
|
||||||
if options:
|
display_options = options
|
||||||
|
if options and promo_code_service:
|
||||||
|
try:
|
||||||
|
active_discount_info = await promo_code_service.get_user_active_discount(
|
||||||
|
session, event.from_user.id
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
active_discount_info = None
|
||||||
|
if active_discount_info:
|
||||||
|
discount_pct, _promo_code = active_discount_info
|
||||||
|
discounted_options = {}
|
||||||
|
for period, price in options.items():
|
||||||
|
if price is None:
|
||||||
|
discounted_options[period] = price
|
||||||
|
else:
|
||||||
|
discounted_price, _ = promo_code_service.calculate_discounted_price(
|
||||||
|
price, discount_pct
|
||||||
|
)
|
||||||
|
if options_are_stars:
|
||||||
|
discounted_price = math.ceil(discounted_price)
|
||||||
|
discounted_options[period] = discounted_price
|
||||||
|
display_options = discounted_options
|
||||||
|
|
||||||
|
if display_options:
|
||||||
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
|
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
|
||||||
reply_markup = get_subscription_options_keyboard(
|
reply_markup = get_subscription_options_keyboard(
|
||||||
options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
|
display_options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
text_content = get_text("no_subscription_options_available")
|
text_content = get_text("no_subscription_options_available")
|
||||||
@@ -104,8 +136,16 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
|||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "main_action:subscribe")
|
@router.callback_query(F.data == "main_action:subscribe")
|
||||||
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
async def reshow_subscription_options_callback(
|
||||||
await display_subscription_options(callback, i18n_data, settings, session)
|
callback: types.CallbackQuery,
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
promo_code_service=None,
|
||||||
|
):
|
||||||
|
await display_subscription_options(
|
||||||
|
callback, i18n_data, settings, session, promo_code_service=promo_code_service
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def my_subscription_command_handler(
|
async def my_subscription_command_handler(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import F, Router, types
|
from aiogram import F, Router, types
|
||||||
@@ -52,26 +53,45 @@ async def select_subscription_period_callback_handler(
|
|||||||
|
|
||||||
# Check for active discount and apply if exists
|
# Check for active discount and apply if exists
|
||||||
discount_text = ""
|
discount_text = ""
|
||||||
if promo_code_service and price_rub:
|
if promo_code_service and (price_rub is not None or stars_price is not None):
|
||||||
active_discount_info = await promo_code_service.get_user_active_discount(
|
active_discount_info = await promo_code_service.get_user_active_discount(
|
||||||
session, callback.from_user.id
|
session, callback.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
if active_discount_info:
|
if active_discount_info:
|
||||||
discount_pct, promo_code = active_discount_info
|
discount_pct, promo_code = active_discount_info
|
||||||
original_price_rub = price_rub
|
if price_rub is not None:
|
||||||
price_rub, discount_amt = promo_code_service.calculate_discounted_price(
|
original_price_rub = price_rub
|
||||||
price_rub, discount_pct
|
price_rub, discount_amt = promo_code_service.calculate_discounted_price(
|
||||||
)
|
price_rub, discount_pct
|
||||||
discount_text = get_text(
|
)
|
||||||
"active_discount_notice",
|
discount_text = get_text(
|
||||||
code=promo_code,
|
"active_discount_notice",
|
||||||
discount_pct=discount_pct,
|
code=promo_code,
|
||||||
original_price=original_price_rub,
|
discount_pct=discount_pct,
|
||||||
discounted_price=price_rub,
|
original_price=original_price_rub,
|
||||||
discount_amount=discount_amt
|
discounted_price=price_rub,
|
||||||
)
|
discount_amount=discount_amt,
|
||||||
# Note: Stars prices typically don't get discounts (can be added if needed)
|
currency_symbol=currency_symbol_val,
|
||||||
|
)
|
||||||
|
if stars_price is not None:
|
||||||
|
original_stars_price = stars_price
|
||||||
|
discounted_stars_price, _ = promo_code_service.calculate_discounted_price(
|
||||||
|
float(stars_price), discount_pct
|
||||||
|
)
|
||||||
|
discounted_stars_price = math.ceil(discounted_stars_price)
|
||||||
|
stars_price = discounted_stars_price
|
||||||
|
if not discount_text:
|
||||||
|
discount_amt = original_stars_price - discounted_stars_price
|
||||||
|
discount_text = get_text(
|
||||||
|
"active_discount_notice",
|
||||||
|
code=promo_code,
|
||||||
|
discount_pct=discount_pct,
|
||||||
|
original_price=original_stars_price,
|
||||||
|
discounted_price=discounted_stars_price,
|
||||||
|
discount_amount=discount_amt,
|
||||||
|
currency_symbol="⭐",
|
||||||
|
)
|
||||||
|
|
||||||
if price_rub is None:
|
if price_rub is None:
|
||||||
if traffic_mode and not price_source and stars_price is not None:
|
if traffic_mode and not price_source and stars_price is not None:
|
||||||
|
|||||||
@@ -88,13 +88,35 @@ async def _initiate_yk_payment(
|
|||||||
if active_discount:
|
if active_discount:
|
||||||
# Price is already discounted, calculate original price backwards
|
# Price is already discounted, calculate original price backwards
|
||||||
discount_pct = active_discount.discount_percentage
|
discount_pct = active_discount.discount_percentage
|
||||||
original_price = price_rub / (1 - discount_pct / 100)
|
|
||||||
discount_amount = original_price - price_rub
|
|
||||||
active_promo_code_id = active_discount.promo_code_id
|
active_promo_code_id = active_discount.promo_code_id
|
||||||
logging.info(
|
denominator = 1 - discount_pct / 100
|
||||||
f"Recording {discount_pct}% discount for YooKassa payment: "
|
if denominator <= 0:
|
||||||
f"original {original_price:.2f} -> final {price_rub}"
|
price_source = (
|
||||||
)
|
getattr(settings, "traffic_packages", {}) or {}
|
||||||
|
if sale_mode == "traffic"
|
||||||
|
else (settings.subscription_options or {})
|
||||||
|
)
|
||||||
|
fallback_original = price_source.get(months)
|
||||||
|
if fallback_original is not None:
|
||||||
|
original_price = fallback_original
|
||||||
|
discount_amount = original_price - price_rub
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for YooKassa payment: "
|
||||||
|
f"original {original_price:.2f} -> final {price_rub}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
"YooKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||||
|
discount_pct,
|
||||||
|
months,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
original_price = price_rub / denominator
|
||||||
|
discount_amount = original_price - price_rub
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for YooKassa payment: "
|
||||||
|
f"original {original_price:.2f} -> final {price_rub}"
|
||||||
|
)
|
||||||
|
|
||||||
payment_description = (
|
payment_description = (
|
||||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||||
|
|||||||
@@ -82,13 +82,35 @@ class CryptoPayService:
|
|||||||
if active_discount:
|
if active_discount:
|
||||||
# Price is already discounted, calculate original price backwards
|
# Price is already discounted, calculate original price backwards
|
||||||
discount_pct = active_discount.discount_percentage
|
discount_pct = active_discount.discount_percentage
|
||||||
original_amount = amount / (1 - discount_pct / 100)
|
|
||||||
discount_amount = original_amount - amount
|
|
||||||
promo_code_id = active_discount.promo_code_id
|
promo_code_id = active_discount.promo_code_id
|
||||||
logging.info(
|
denominator = 1 - discount_pct / 100
|
||||||
f"Recording {discount_pct}% discount for CryptoPay payment: "
|
if denominator <= 0:
|
||||||
f"original {original_amount:.2f} -> final {amount}"
|
price_source = (
|
||||||
)
|
getattr(self.settings, "traffic_packages", {}) or {}
|
||||||
|
if sale_mode == "traffic"
|
||||||
|
else (self.settings.subscription_options or {})
|
||||||
|
)
|
||||||
|
fallback_original = price_source.get(months)
|
||||||
|
if fallback_original is not None:
|
||||||
|
original_amount = fallback_original
|
||||||
|
discount_amount = original_amount - amount
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for CryptoPay payment: "
|
||||||
|
f"original {original_amount:.2f} -> final {amount}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
"CryptoPay discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||||
|
discount_pct,
|
||||||
|
months,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
original_amount = amount / denominator
|
||||||
|
discount_amount = original_amount - amount
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for CryptoPay payment: "
|
||||||
|
f"original {original_amount:.2f} -> final {amount}"
|
||||||
|
)
|
||||||
|
|
||||||
# Create pending payment in DB and commit to persist
|
# Create pending payment in DB and commit to persist
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -96,13 +96,36 @@ class FreeKassaService:
|
|||||||
if active_discount:
|
if active_discount:
|
||||||
# Price is already discounted, calculate original price backwards
|
# Price is already discounted, calculate original price backwards
|
||||||
discount_pct = active_discount.discount_percentage
|
discount_pct = active_discount.discount_percentage
|
||||||
original_amount = amount / (1 - discount_pct / 100)
|
|
||||||
discount_amount = original_amount - amount
|
|
||||||
promo_code_id = active_discount.promo_code_id
|
promo_code_id = active_discount.promo_code_id
|
||||||
logging.info(
|
denominator = 1 - discount_pct / 100
|
||||||
f"Recording {discount_pct}% discount for FreeKassa payment: "
|
if denominator <= 0:
|
||||||
f"original {original_amount:.2f} -> final {amount}"
|
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
|
||||||
)
|
price_source = (
|
||||||
|
getattr(self.settings, "traffic_packages", {}) or {}
|
||||||
|
if traffic_mode
|
||||||
|
else (self.settings.subscription_options or {})
|
||||||
|
)
|
||||||
|
fallback_original = price_source.get(months)
|
||||||
|
if fallback_original is not None:
|
||||||
|
original_amount = fallback_original
|
||||||
|
discount_amount = original_amount - amount
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for FreeKassa payment: "
|
||||||
|
f"original {original_amount:.2f} -> final {amount}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
"FreeKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||||
|
discount_pct,
|
||||||
|
months,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
original_amount = amount / denominator
|
||||||
|
discount_amount = original_amount - amount
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for FreeKassa payment: "
|
||||||
|
f"original {original_amount:.2f} -> final {amount}"
|
||||||
|
)
|
||||||
|
|
||||||
# Update payment record with discount metadata
|
# Update payment record with discount metadata
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -94,13 +94,36 @@ class PlategaService:
|
|||||||
if active_discount:
|
if active_discount:
|
||||||
# Price is already discounted, calculate original price backwards
|
# Price is already discounted, calculate original price backwards
|
||||||
discount_pct = active_discount.discount_percentage
|
discount_pct = active_discount.discount_percentage
|
||||||
original_amount = amount / (1 - discount_pct / 100)
|
|
||||||
discount_amount = original_amount - amount
|
|
||||||
promo_code_id = active_discount.promo_code_id
|
promo_code_id = active_discount.promo_code_id
|
||||||
logging.info(
|
denominator = 1 - discount_pct / 100
|
||||||
f"Recording {discount_pct}% discount for Platega payment: "
|
if denominator <= 0:
|
||||||
f"original {original_amount:.2f} -> final {amount}"
|
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
|
||||||
)
|
price_source = (
|
||||||
|
getattr(self.settings, "traffic_packages", {}) or {}
|
||||||
|
if traffic_mode
|
||||||
|
else (self.settings.subscription_options or {})
|
||||||
|
)
|
||||||
|
fallback_original = price_source.get(months)
|
||||||
|
if fallback_original is not None:
|
||||||
|
original_amount = fallback_original
|
||||||
|
discount_amount = original_amount - amount
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for Platega payment: "
|
||||||
|
f"original {original_amount:.2f} -> final {amount}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
"Platega discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||||
|
discount_pct,
|
||||||
|
months,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
original_amount = amount / denominator
|
||||||
|
discount_amount = original_amount - amount
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for Platega payment: "
|
||||||
|
f"original {original_amount:.2f} -> final {amount}"
|
||||||
|
)
|
||||||
|
|
||||||
# Update payment record with discount metadata
|
# Update payment record with discount metadata
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from aiogram import Bot
|
|||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
from db.dal import promo_code_dal, user_dal, active_discount_dal
|
from db.dal import promo_code_dal, user_dal, active_discount_dal, payment_dal
|
||||||
from db.models import PromoCode, User
|
from db.models import PromoCode, User
|
||||||
|
|
||||||
from .subscription_service import SubscriptionService
|
from .subscription_service import SubscriptionService
|
||||||
@@ -206,40 +206,88 @@ class PromoCodeService:
|
|||||||
payment_id: int
|
payment_id: int
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Consume active discount: record activation, increment usage, clear active discount.
|
Consume active discount: link activation to payment, increment usage, clear active discount.
|
||||||
Call this AFTER successful payment.
|
Call this AFTER successful payment.
|
||||||
"""
|
"""
|
||||||
|
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||||
|
if not payment_record:
|
||||||
|
logging.warning(
|
||||||
|
"Payment %s not found for discount consumption (user %s).",
|
||||||
|
payment_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not payment_record.discount_applied:
|
||||||
|
return False
|
||||||
|
|
||||||
|
promo_code_id = payment_record.promo_code_id
|
||||||
|
if not promo_code_id:
|
||||||
|
logging.warning(
|
||||||
|
"Payment %s for user %s has discount_applied but no promo_code_id.",
|
||||||
|
payment_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||||
if not active_discount:
|
if active_discount and active_discount.promo_code_id != promo_code_id:
|
||||||
return False
|
|
||||||
|
|
||||||
# Record activation
|
|
||||||
activation_recorded = await promo_code_dal.record_promo_activation(
|
|
||||||
session,
|
|
||||||
active_discount.promo_code_id,
|
|
||||||
user_id,
|
|
||||||
payment_id=payment_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Increment usage
|
|
||||||
promo_incremented = await promo_code_dal.increment_promo_code_usage(
|
|
||||||
session,
|
|
||||||
active_discount.promo_code_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clear active discount
|
|
||||||
await active_discount_dal.clear_active_discount(session, user_id)
|
|
||||||
|
|
||||||
if activation_recorded and promo_incremented:
|
|
||||||
await session.flush()
|
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, "
|
"Active discount promo %s differs from payment promo %s; leaving active discount intact.",
|
||||||
f"payment {payment_id}"
|
active_discount.promo_code_id,
|
||||||
|
promo_code_id,
|
||||||
)
|
)
|
||||||
return True
|
active_discount = None
|
||||||
|
|
||||||
|
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||||
|
session, promo_code_id, user_id
|
||||||
|
)
|
||||||
|
if existing_activation:
|
||||||
|
if existing_activation.payment_id is None:
|
||||||
|
updated_payment = await promo_code_dal.set_activation_payment_id(
|
||||||
|
session, promo_code_id, user_id, payment_id
|
||||||
|
)
|
||||||
|
if updated_payment:
|
||||||
|
logging.info(
|
||||||
|
"Linked discount promo %s activation to payment %s for user %s.",
|
||||||
|
promo_code_id,
|
||||||
|
payment_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logging.error(
|
activation_recorded = await promo_code_dal.record_promo_activation(
|
||||||
f"Failed to consume discount for user {user_id}, "
|
session,
|
||||||
f"promo {active_discount.promo_code_id}"
|
promo_code_id,
|
||||||
|
user_id,
|
||||||
|
payment_id=payment_id,
|
||||||
)
|
)
|
||||||
return False
|
if not activation_recorded:
|
||||||
|
logging.error(
|
||||||
|
"Failed to record discount activation for user %s, promo %s.",
|
||||||
|
user_id,
|
||||||
|
promo_code_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
promo_incremented = await promo_code_dal.increment_promo_code_usage(
|
||||||
|
session, promo_code_id, allow_overflow=True
|
||||||
|
)
|
||||||
|
if not promo_incremented:
|
||||||
|
logging.error(
|
||||||
|
"Failed to increment discount usage for user %s, promo %s.",
|
||||||
|
user_id,
|
||||||
|
promo_code_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if active_discount and active_discount.promo_code_id == promo_code_id:
|
||||||
|
await active_discount_dal.clear_active_discount(session, user_id)
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
logging.info(
|
||||||
|
"Discount consumed for user %s, promo %s, payment %s",
|
||||||
|
user_id,
|
||||||
|
promo_code_id,
|
||||||
|
payment_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -117,13 +117,36 @@ class SeverPayService:
|
|||||||
if active_discount:
|
if active_discount:
|
||||||
# Price is already discounted, calculate original price backwards
|
# Price is already discounted, calculate original price backwards
|
||||||
discount_pct = active_discount.discount_percentage
|
discount_pct = active_discount.discount_percentage
|
||||||
original_amount = amount / (1 - discount_pct / 100)
|
|
||||||
discount_amount = original_amount - amount
|
|
||||||
promo_code_id = active_discount.promo_code_id
|
promo_code_id = active_discount.promo_code_id
|
||||||
logging.info(
|
denominator = 1 - discount_pct / 100
|
||||||
f"Recording {discount_pct}% discount for SeverPay payment: "
|
if denominator <= 0:
|
||||||
f"original {original_amount:.2f} -> final {amount}"
|
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
|
||||||
)
|
price_source = (
|
||||||
|
getattr(self.settings, "traffic_packages", {}) or {}
|
||||||
|
if traffic_mode
|
||||||
|
else (self.settings.subscription_options or {})
|
||||||
|
)
|
||||||
|
fallback_original = price_source.get(months)
|
||||||
|
if fallback_original is not None:
|
||||||
|
original_amount = fallback_original
|
||||||
|
discount_amount = original_amount - amount
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for SeverPay payment: "
|
||||||
|
f"original {original_amount:.2f} -> final {amount}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
"SeverPay discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||||
|
discount_pct,
|
||||||
|
months,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
original_amount = amount / denominator
|
||||||
|
discount_amount = original_amount - amount
|
||||||
|
logging.info(
|
||||||
|
f"Recording {discount_pct}% discount for SeverPay payment: "
|
||||||
|
f"original {original_amount:.2f} -> final {amount}"
|
||||||
|
)
|
||||||
|
|
||||||
# Update payment record with discount metadata
|
# Update payment record with discount metadata
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
|
|||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
|
||||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, active_discount_dal
|
from db.dal import user_dal, subscription_dal, promo_code_dal, user_billing_dal
|
||||||
from bot.utils.date_utils import add_months
|
from bot.utils.date_utils import add_months
|
||||||
from bot.utils.config_link import prepare_config_links
|
from bot.utils.config_link import prepare_config_links
|
||||||
from db.models import User, Subscription
|
from db.models import User, Subscription
|
||||||
@@ -615,7 +615,7 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
if activation:
|
if activation:
|
||||||
await promo_code_dal.increment_promo_code_usage(
|
await promo_code_dal.increment_promo_code_usage(
|
||||||
session, promo_code_id_from_payment
|
session, promo_code_id_from_payment, allow_overflow=True
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
@@ -691,33 +691,20 @@ class SubscriptionService:
|
|||||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||||
|
|
||||||
# NEW: Consume discount promo code if payment had one
|
# Consume discount promo code if payment had one
|
||||||
try:
|
try:
|
||||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
promo_code_service = getattr(self, "promo_code_service", None)
|
||||||
if payment_record and payment_record.discount_applied:
|
if not promo_code_service:
|
||||||
# This payment had a discount applied - consume it
|
from .promo_code_service import PromoCodeService
|
||||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
|
||||||
if active_discount:
|
promo_code_service = PromoCodeService(
|
||||||
# Record promo activation
|
self.settings, self, self.bot, self.i18n
|
||||||
await promo_code_dal.record_promo_activation(
|
)
|
||||||
session,
|
await promo_code_service.consume_discount(session, user_id, payment_db_id)
|
||||||
active_discount.promo_code_id,
|
|
||||||
user_id,
|
|
||||||
payment_id=payment_db_id
|
|
||||||
)
|
|
||||||
# Increment usage
|
|
||||||
await promo_code_dal.increment_promo_code_usage(
|
|
||||||
session,
|
|
||||||
active_discount.promo_code_id
|
|
||||||
)
|
|
||||||
# Clear active discount
|
|
||||||
await active_discount_dal.clear_active_discount(session, user_id)
|
|
||||||
logging.info(
|
|
||||||
f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, "
|
|
||||||
f"payment {payment_db_id}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to consume discount for user {user_id}, payment {payment_db_id}: {e}")
|
logging.error(
|
||||||
|
f"Failed to consume discount for user {user_id}, payment {payment_db_id}: {e}"
|
||||||
|
)
|
||||||
# Don't fail the subscription activation if discount consumption fails
|
# Don't fail the subscription activation if discount consumption fails
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -159,22 +159,51 @@ async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[Pr
|
|||||||
|
|
||||||
|
|
||||||
async def increment_promo_code_usage(
|
async def increment_promo_code_usage(
|
||||||
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
|
session: AsyncSession,
|
||||||
|
promo_code_id: int,
|
||||||
|
allow_overflow: bool = False) -> Optional[PromoCode]:
|
||||||
|
conditions = [PromoCode.promo_code_id == promo_code_id]
|
||||||
|
if not allow_overflow:
|
||||||
|
conditions.append(PromoCode.current_activations < PromoCode.max_activations)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
update(PromoCode)
|
||||||
|
.where(*conditions)
|
||||||
|
.values(current_activations=PromoCode.current_activations + 1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
if result.rowcount and result.rowcount > 0:
|
||||||
|
await session.flush()
|
||||||
|
return await get_promo_code_by_id(session, promo_code_id)
|
||||||
|
|
||||||
promo = await get_promo_code_by_id(session, promo_code_id)
|
promo = await get_promo_code_by_id(session, promo_code_id)
|
||||||
if promo:
|
if promo:
|
||||||
if promo.current_activations < promo.max_activations:
|
if allow_overflow:
|
||||||
promo.current_activations += 1
|
logging.warning(
|
||||||
await session.flush()
|
f"Failed to increment promo usage for promo {promo.code} (ID: {promo_code_id})."
|
||||||
await session.refresh(promo)
|
)
|
||||||
return promo
|
|
||||||
else:
|
else:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
|
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
|
||||||
)
|
)
|
||||||
return None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def decrement_promo_code_usage(
|
||||||
|
session: AsyncSession, promo_code_id: int) -> bool:
|
||||||
|
stmt = (
|
||||||
|
update(PromoCode)
|
||||||
|
.where(
|
||||||
|
PromoCode.promo_code_id == promo_code_id,
|
||||||
|
PromoCode.current_activations > 0,
|
||||||
|
)
|
||||||
|
.values(current_activations=PromoCode.current_activations - 1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.flush()
|
||||||
|
return bool(result.rowcount and result.rowcount > 0)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_activation_for_promo(
|
async def get_user_activation_for_promo(
|
||||||
session: AsyncSession, promo_code_id: int,
|
session: AsyncSession, promo_code_id: int,
|
||||||
user_id: int) -> Optional[PromoCodeActivation]:
|
user_id: int) -> Optional[PromoCodeActivation]:
|
||||||
@@ -230,3 +259,22 @@ async def record_promo_activation(
|
|||||||
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}"
|
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}"
|
||||||
)
|
)
|
||||||
return new_activation
|
return new_activation
|
||||||
|
|
||||||
|
|
||||||
|
async def set_activation_payment_id(
|
||||||
|
session: AsyncSession,
|
||||||
|
promo_code_id: int,
|
||||||
|
user_id: int,
|
||||||
|
payment_id: int) -> bool:
|
||||||
|
stmt = (
|
||||||
|
update(PromoCodeActivation)
|
||||||
|
.where(
|
||||||
|
PromoCodeActivation.promo_code_id == promo_code_id,
|
||||||
|
PromoCodeActivation.user_id == user_id,
|
||||||
|
PromoCodeActivation.payment_id == None,
|
||||||
|
)
|
||||||
|
.values(payment_id=payment_id)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.flush()
|
||||||
|
return bool(result.rowcount and result.rowcount > 0)
|
||||||
|
|||||||
@@ -168,6 +168,48 @@ def _migration_0004_add_discount_promo_codes(connection: Connection) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0005_fix_active_discounts_fk_cascade(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
if not inspector.has_table("active_discounts"):
|
||||||
|
return
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"DELETE FROM active_discounts ad "
|
||||||
|
"WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.user_id = ad.user_id) "
|
||||||
|
"OR NOT EXISTS (SELECT 1 FROM promo_codes p WHERE p.promo_code_id = ad.promo_code_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS active_discounts_user_id_fkey")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS fk_active_discounts_user")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS active_discounts_promo_code_id_fkey")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS fk_active_discounts_promo_code")
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE active_discounts "
|
||||||
|
"ADD CONSTRAINT fk_active_discounts_user "
|
||||||
|
"FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE active_discounts "
|
||||||
|
"ADD CONSTRAINT fk_active_discounts_promo_code "
|
||||||
|
"FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
MIGRATIONS: List[Migration] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -189,6 +231,11 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Add support for percentage discount promo codes",
|
description="Add support for percentage discount promo codes",
|
||||||
upgrade=_migration_0004_add_discount_promo_codes,
|
upgrade=_migration_0004_add_discount_promo_codes,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0005_fix_active_discounts_fk_cascade",
|
||||||
|
description="Ensure active_discounts FKs cascade on user/promo delete",
|
||||||
|
upgrade=_migration_0005_fix_active_discounts_fk_cascade,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -210,8 +210,16 @@ class ActiveDiscount(Base):
|
|||||||
"""Tracks pending discount promo codes awaiting payment (permanent until used)"""
|
"""Tracks pending discount promo codes awaiting payment (permanent until used)"""
|
||||||
__tablename__ = "active_discounts"
|
__tablename__ = "active_discounts"
|
||||||
|
|
||||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
|
user_id = Column(
|
||||||
promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=False)
|
BigInteger,
|
||||||
|
ForeignKey("users.user_id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
promo_code_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("promo_codes.promo_code_id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
discount_percentage = Column(Integer, nullable=False)
|
discount_percentage = Column(Integer, nullable=False)
|
||||||
activated_at = Column(DateTime(timezone=True), server_default=func.now())
|
activated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@
|
|||||||
"discount_promo_code_applied_success": "✅ Promo code <code>{code}</code> activated!\n\n💰 A {discount}% discount will be applied to your next purchase.\n\nSelect a plan for payment.",
|
"discount_promo_code_applied_success": "✅ Promo code <code>{code}</code> activated!\n\n💰 A {discount}% discount will be applied to your next purchase.\n\nSelect a plan for payment.",
|
||||||
"discount_promo_already_active": "❌ You already have an active discount promo code (<code>{code}</code>, -{discount_pct}%). Use it first or wait until the payment is complete.",
|
"discount_promo_already_active": "❌ You already have an active discount promo code (<code>{code}</code>, -{discount_pct}%). Use it first or wait until the payment is complete.",
|
||||||
"promo_code_not_found_or_not_discount": "❌ Promo code <code>{code}</code> not found or is invalid.",
|
"promo_code_not_found_or_not_discount": "❌ Promo code <code>{code}</code> not found or is invalid.",
|
||||||
"active_discount_notice": "🎁 Active discount: <code>{code}</code> (-{discount_pct}%)\n💵 Price: <s>{original_price}</s> ➔ <b>{discounted_price}</b>\n💰 Savings: {discount_amount}",
|
"active_discount_notice": "🎁 Active discount: <code>{code}</code> (-{discount_pct}%)\n💵 Price: <s>{original_price}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Savings: {discount_amount}{currency_symbol}",
|
||||||
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
|
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
|
||||||
"promo_input_cancelled_short": "Promo code entry cancelled.",
|
"promo_input_cancelled_short": "Promo code entry cancelled.",
|
||||||
"trial_feature_disabled": "Free trial is currently unavailable.",
|
"trial_feature_disabled": "Free trial is currently unavailable.",
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@
|
|||||||
"discount_promo_code_applied_success": "✅ Промокод <code>{code}</code> активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.",
|
"discount_promo_code_applied_success": "✅ Промокод <code>{code}</code> активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.",
|
||||||
"discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку (<code>{code}</code>, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.",
|
"discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку (<code>{code}</code>, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.",
|
||||||
"promo_code_not_found_or_not_discount": "❌ Промокод <code>{code}</code> не найден или недействителен.",
|
"promo_code_not_found_or_not_discount": "❌ Промокод <code>{code}</code> не найден или недействителен.",
|
||||||
"active_discount_notice": "🎁 Активна скидка: <code>{code}</code> (-{discount_pct}%)\n💵 Цена: <s>{original_price}</s> ➔ <b>{discounted_price}</b>\n💰 Экономия: {discount_amount}",
|
"active_discount_notice": "🎁 Активна скидка: <code>{code}</code> (-{discount_pct}%)\n💵 Цена: <s>{original_price}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Экономия: {discount_amount}{currency_symbol}",
|
||||||
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
||||||
"error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.",
|
"error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.",
|
||||||
"promo_input_cancelled_short": "Ввод промокода отменен.",
|
"promo_input_cancelled_short": "Ввод промокода отменен.",
|
||||||
|
|||||||
Reference in New Issue
Block a user