feat(promo): Enhance promo code functionality and discount consumption logic

- Integrated promo code service into subscription service to streamline discount consumption during payment processing.
- Improved error handling and logging for promo code activation and usage increment.
- Updated payment services to handle discount calculations more robustly, including fallback mechanisms for invalid discount scenarios.
- Added new methods for managing promo code activations and usage in the database layer.
This commit is contained in:
kavore
2026-02-01 19:29:31 +03:00
parent 9b4a58f82f
commit 484327a032
10 changed files with 319 additions and 98 deletions
+2
View File
@@ -81,6 +81,8 @@ def build_core_services(
# Wire services that depend on each other
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
setattr(subscription_service, "yookassa_service", yookassa_service)
# Allow panel webhook to trigger renewals through subscription service
+3 -2
View File
@@ -190,9 +190,10 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
# Both failed
await session.rollback()
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
response_to_user_text = result_discount # Prefer the discount attempt error
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
@@ -88,13 +88,35 @@ async def _initiate_yk_payment(
if active_discount:
# Price is already discounted, calculate original price backwards
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
logging.info(
f"Recording {discount_pct}% discount for YooKassa payment: "
f"original {original_price:.2f} -> final {price_rub}"
)
denominator = 1 - discount_pct / 100
if denominator <= 0:
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 = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
+28 -6
View File
@@ -82,13 +82,35 @@ class CryptoPayService:
if active_discount:
# Price is already discounted, calculate original price backwards
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
logging.info(
f"Recording {discount_pct}% discount for CryptoPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
denominator = 1 - discount_pct / 100
if denominator <= 0:
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
try:
+29 -6
View File
@@ -96,13 +96,36 @@ class FreeKassaService:
if active_discount:
# Price is already discounted, calculate original price backwards
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
logging.info(
f"Recording {discount_pct}% discount for FreeKassa payment: "
f"original {original_amount:.2f} -> final {amount}"
)
denominator = 1 - discount_pct / 100
if denominator <= 0:
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
try:
+29 -6
View File
@@ -94,13 +94,36 @@ class PlategaService:
if active_discount:
# Price is already discounted, calculate original price backwards
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
logging.info(
f"Recording {discount_pct}% discount for Platega payment: "
f"original {original_amount:.2f} -> final {amount}"
)
denominator = 1 - discount_pct / 100
if denominator <= 0:
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
try:
+108 -30
View File
@@ -6,7 +6,7 @@ from aiogram import Bot
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 .subscription_service import SubscriptionService
@@ -140,6 +140,36 @@ class PromoCodeService:
# This shouldn't happen since we checked above, but just in case
return False, _("error_applying_promo_discount")
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session, promo_data.promo_code_id
)
if not promo_incremented:
await active_discount_dal.clear_active_discount(session, user_id)
logging.info(
"Discount promo %s reached max activations during activation for user %s.",
promo_data.code,
user_id,
)
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
activation_recorded = await promo_code_dal.record_promo_activation(
session,
promo_data.promo_code_id,
user_id,
payment_id=None,
)
if not activation_recorded:
await active_discount_dal.clear_active_discount(session, user_id)
await promo_code_dal.decrement_promo_code_usage(
session, promo_data.promo_code_id
)
logging.error(
"Failed to record discount activation for user %s, promo %s.",
user_id,
promo_data.promo_code_id,
)
return False, _("error_applying_promo_discount")
logging.info(
f"Discount promo code {code_input_upper} activated for user {user_id}: "
f"{promo_data.discount_percentage}% off"
@@ -209,37 +239,85 @@ class PromoCodeService:
Consume active discount: record activation, increment usage, clear active discount.
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)
if not active_discount:
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()
if active_discount and active_discount.promo_code_id != promo_code_id:
logging.info(
f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, "
f"payment {payment_id}"
"Active discount promo %s differs from payment promo %s; leaving active discount intact.",
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:
logging.error(
f"Failed to consume discount for user {user_id}, "
f"promo {active_discount.promo_code_id}"
activation_recorded = await promo_code_dal.record_promo_activation(
session,
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
)
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:
# Price is already discounted, calculate original price backwards
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
logging.info(
f"Recording {discount_pct}% discount for SeverPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
denominator = 1 - discount_pct / 100
if denominator <= 0:
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
try:
+13 -26
View File
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
from aiogram import Bot
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.config_link import prepare_config_links
from db.models import User, Subscription
@@ -691,33 +691,20 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
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:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if payment_record and payment_record.discount_applied:
# This payment had a discount applied - consume it
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Record promo activation
await promo_code_dal.record_promo_activation(
session,
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}"
)
promo_code_service = getattr(self, "promo_code_service", None)
if not promo_code_service:
from .promo_code_service import PromoCodeService
promo_code_service = PromoCodeService(
self.settings, self, self.bot, self.i18n
)
await promo_code_service.consume_discount(session, user_id, payment_db_id)
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
return {
+50 -10
View File
@@ -160,21 +160,42 @@ async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[Pr
async def increment_promo_code_usage(
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
stmt = (
update(PromoCode)
.where(
PromoCode.promo_code_id == promo_code_id,
PromoCode.current_activations < PromoCode.max_activations,
)
.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)
if promo:
if promo.current_activations < promo.max_activations:
promo.current_activations += 1
await session.flush()
await session.refresh(promo)
return promo
else:
logging.warning(
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
)
return None
logging.warning(
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
)
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(
session: AsyncSession, promo_code_id: int,
user_id: int) -> Optional[PromoCodeActivation]:
@@ -230,3 +251,22 @@ async def record_promo_activation(
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}"
)
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)