Merge pull request #159 from kavore/dev

bugfix
This commit is contained in:
kavore
2026-02-03 12:18:37 +03:00
committed by GitHub
17 changed files with 444 additions and 121 deletions
+2
View File
@@ -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
+12 -2
View File
@@ -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
) )
+1 -1
View File
@@ -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,
+45 -5
View File
@@ -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))
+28 -6
View File
@@ -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:
+29 -6
View File
@@ -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:
+29 -6
View File
@@ -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:
+79 -31
View File
@@ -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
+29 -6
View File
@@ -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:
+14 -27
View File
@@ -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 {
+55 -7
View File
@@ -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)
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "Ввод промокода отменен.",