feat(promo): add discount promo reservation timeout
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Tuple, Dict
|
||||
from typing import Optional, Tuple
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
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
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
@@ -23,6 +24,117 @@ class PromoCodeService:
|
||||
self.subscription_service = subscription_service
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.discount_payment_timeout_minutes = max(
|
||||
1,
|
||||
int(getattr(settings, "DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES", 10) or 10),
|
||||
)
|
||||
self._discount_expiration_task: Optional[asyncio.Task] = None
|
||||
self._async_session_factory: Optional[sessionmaker] = None
|
||||
|
||||
async def setup_discount_expiration_worker(
|
||||
self,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> None:
|
||||
"""Attach DB session factory and start background cleanup loop."""
|
||||
self._async_session_factory = async_session_factory
|
||||
if self._discount_expiration_task and not self._discount_expiration_task.done():
|
||||
return
|
||||
self._discount_expiration_task = asyncio.create_task(
|
||||
self._discount_expiration_loop(),
|
||||
name="PromoDiscountExpirationLoop",
|
||||
)
|
||||
logging.info("PromoCodeService: started discount expiration background worker.")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Gracefully stop background workers."""
|
||||
if not self._discount_expiration_task:
|
||||
return
|
||||
self._discount_expiration_task.cancel()
|
||||
try:
|
||||
await self._discount_expiration_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logging.exception("PromoCodeService: failed while stopping expiration worker")
|
||||
finally:
|
||||
self._discount_expiration_task = None
|
||||
|
||||
async def _discount_expiration_loop(self) -> None:
|
||||
"""Periodically clears expired discount reservations and notifies users."""
|
||||
while True:
|
||||
try:
|
||||
if not self._async_session_factory:
|
||||
await asyncio.sleep(30)
|
||||
continue
|
||||
|
||||
await self._process_expired_discounts_once()
|
||||
except asyncio.CancelledError:
|
||||
logging.info("PromoCodeService: discount expiration loop cancelled.")
|
||||
raise
|
||||
except Exception:
|
||||
logging.exception("PromoCodeService: unhandled error in discount expiration loop")
|
||||
|
||||
await asyncio.sleep(30)
|
||||
|
||||
async def _process_expired_discounts_once(self) -> None:
|
||||
if not self._async_session_factory:
|
||||
return
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
async with self._async_session_factory() as session:
|
||||
expired_discounts = await active_discount_dal.get_expired_active_discounts(
|
||||
session,
|
||||
now=now_utc,
|
||||
limit=100,
|
||||
)
|
||||
if not expired_discounts:
|
||||
return
|
||||
|
||||
for expired in expired_discounts:
|
||||
cleared = await active_discount_dal.clear_active_discount_if_matches(
|
||||
session,
|
||||
user_id=expired.user_id,
|
||||
promo_code_id=expired.promo_code_id,
|
||||
expires_at_lte=now_utc,
|
||||
)
|
||||
if not cleared:
|
||||
continue
|
||||
|
||||
await promo_code_dal.decrement_promo_code_usage(session, expired.promo_code_id)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, expired.user_id)
|
||||
user_lang = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, expired.promo_code_id)
|
||||
promo_code = promo.code if promo else ""
|
||||
message_text = self.i18n.gettext(
|
||||
user_lang,
|
||||
"discount_promo_expired_need_reactivate",
|
||||
code_part=(f" (<code>{promo_code}</code>)" if promo_code else ""),
|
||||
)
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=expired.user_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send discount expiration message to user %s",
|
||||
expired.user_id,
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"Expired discount reservation removed: user=%s, promo=%s",
|
||||
expired.user_id,
|
||||
expired.promo_code_id,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
async def apply_promo_code(
|
||||
self,
|
||||
@@ -99,7 +211,26 @@ class PromoCodeService:
|
||||
code_input_upper = code_input.strip().upper()
|
||||
|
||||
# Check if user already has an active discount
|
||||
existing_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
existing_discount = await active_discount_dal.get_active_discount(
|
||||
session,
|
||||
user_id,
|
||||
include_expired=True,
|
||||
)
|
||||
if existing_discount:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if existing_discount.expires_at <= now_utc:
|
||||
cleared = await active_discount_dal.clear_active_discount_if_expired(
|
||||
session,
|
||||
user_id,
|
||||
now=now_utc,
|
||||
)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(
|
||||
session,
|
||||
existing_discount.promo_code_id,
|
||||
)
|
||||
existing_discount = None
|
||||
|
||||
if existing_discount:
|
||||
# Get the promo code for the existing discount
|
||||
existing_promo = await promo_code_dal.get_promo_code_by_id(
|
||||
@@ -128,21 +259,37 @@ class PromoCodeService:
|
||||
if existing_activation:
|
||||
return False, _("promo_code_already_used_by_user", code=code_input_upper)
|
||||
|
||||
# Set active discount
|
||||
# Reserve discount for limited time and count activation immediately
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=self.discount_payment_timeout_minutes,
|
||||
)
|
||||
active_discount = await active_discount_dal.set_active_discount(
|
||||
session,
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_data.promo_code_id,
|
||||
discount_percentage=promo_data.discount_percentage
|
||||
discount_percentage=promo_data.discount_percentage,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
if not active_discount:
|
||||
# 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_if_matches(
|
||||
session,
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_data.promo_code_id,
|
||||
)
|
||||
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
|
||||
|
||||
logging.info(
|
||||
f"Discount promo code {code_input_upper} activated for user {user_id}: "
|
||||
f"{promo_data.discount_percentage}% off"
|
||||
f"{promo_data.discount_percentage}% off until {expires_at.isoformat()}"
|
||||
)
|
||||
return True, promo_data.discount_percentage
|
||||
|
||||
@@ -155,10 +302,28 @@ class PromoCodeService:
|
||||
Get user's active discount if any.
|
||||
Returns: (discount_percentage, promo_code) or None
|
||||
"""
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
active_discount = await active_discount_dal.get_active_discount(
|
||||
session,
|
||||
user_id,
|
||||
include_expired=True,
|
||||
)
|
||||
if not active_discount:
|
||||
return None
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if active_discount.expires_at <= now_utc:
|
||||
cleared = await active_discount_dal.clear_active_discount_if_expired(
|
||||
session,
|
||||
user_id,
|
||||
now=now_utc,
|
||||
)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(
|
||||
session,
|
||||
active_discount.promo_code_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Fetch promo code for code string
|
||||
promo = await promo_code_dal.get_promo_code_by_id(
|
||||
session, active_discount.promo_code_id
|
||||
@@ -175,7 +340,9 @@ class PromoCodeService:
|
||||
f"Promo code {promo.code} expired (valid_until: {promo.valid_until}). "
|
||||
f"Clearing active discount for user {user_id}"
|
||||
)
|
||||
await active_discount_dal.clear_active_discount(session, user_id)
|
||||
cleared = await active_discount_dal.clear_active_discount(session, user_id)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(session, promo.promo_code_id)
|
||||
return None
|
||||
|
||||
return (active_discount.discount_percentage, promo.code)
|
||||
@@ -206,7 +373,7 @@ class PromoCodeService:
|
||||
payment_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Consume active discount: link activation to payment, increment usage, clear active discount.
|
||||
Consume active discount: link reservation to payment and clear active discount.
|
||||
Call this AFTER successful payment.
|
||||
"""
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||
@@ -230,7 +397,11 @@ class PromoCodeService:
|
||||
)
|
||||
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,
|
||||
include_expired=True,
|
||||
)
|
||||
if active_discount and active_discount.promo_code_id != promo_code_id:
|
||||
logging.info(
|
||||
"Active discount promo %s differs from payment promo %s; leaving active discount intact.",
|
||||
@@ -239,6 +410,27 @@ class PromoCodeService:
|
||||
)
|
||||
active_discount = None
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if (
|
||||
active_discount
|
||||
and active_discount.promo_code_id == promo_code_id
|
||||
and active_discount.expires_at <= now_utc
|
||||
):
|
||||
logging.info(
|
||||
"Discount reservation expired before payment consumption (user=%s, promo=%s)",
|
||||
user_id,
|
||||
promo_code_id,
|
||||
)
|
||||
cleared = await active_discount_dal.clear_active_discount_if_matches(
|
||||
session,
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_code_id,
|
||||
expires_at_lte=now_utc,
|
||||
)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(session, promo_code_id)
|
||||
return False
|
||||
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_code_id, user_id
|
||||
)
|
||||
@@ -269,17 +461,6 @@ class PromoCodeService:
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user