feat(promo): add discount promo reservation timeout
This commit is contained in:
@@ -176,6 +176,9 @@ LOG_PROMO_ACTIVATIONS=True #
|
|||||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||||
|
|
||||||
|
# Discount promo reservation timeout
|
||||||
|
DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES=10 # Minutes to keep discount promo reservation before it expires
|
||||||
|
|
||||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||||
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""add active discount expiration
|
||||||
|
|
||||||
|
Revision ID: 0002_active_discount_expires_at
|
||||||
|
Revises: 0001_initial_schema
|
||||||
|
Create Date: 2026-02-08 00:00:01.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op, context
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0002_active_discount_expires_at"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0001_initial_schema"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
_INDEX_NAME = "idx_active_discounts_expires_at"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if context.is_offline_mode():
|
||||||
|
op.add_column(
|
||||||
|
"active_discounts",
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE active_discounts "
|
||||||
|
"SET expires_at = COALESCE(activated_at, NOW()) + INTERVAL '10 minutes' "
|
||||||
|
"WHERE expires_at IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.alter_column("active_discounts", "expires_at", nullable=False)
|
||||||
|
op.create_index(_INDEX_NAME, "active_discounts", ["expires_at"], unique=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if not inspector.has_table("active_discounts"):
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("active_discounts")}
|
||||||
|
if "expires_at" not in columns:
|
||||||
|
op.add_column(
|
||||||
|
"active_discounts",
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE active_discounts "
|
||||||
|
"SET expires_at = COALESCE(activated_at, NOW()) + INTERVAL '10 minutes' "
|
||||||
|
"WHERE expires_at IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.alter_column("active_discounts", "expires_at", nullable=False)
|
||||||
|
|
||||||
|
indexes = {index["name"] for index in inspector.get_indexes("active_discounts")}
|
||||||
|
if _INDEX_NAME not in indexes:
|
||||||
|
op.create_index(_INDEX_NAME, "active_discounts", ["expires_at"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if context.is_offline_mode():
|
||||||
|
op.drop_index(_INDEX_NAME, table_name="active_discounts")
|
||||||
|
op.drop_column("active_discounts", "expires_at")
|
||||||
|
return
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if not inspector.has_table("active_discounts"):
|
||||||
|
return
|
||||||
|
|
||||||
|
indexes = {index["name"] for index in inspector.get_indexes("active_discounts")}
|
||||||
|
if _INDEX_NAME in indexes:
|
||||||
|
op.drop_index(_INDEX_NAME, table_name="active_discounts")
|
||||||
|
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("active_discounts")}
|
||||||
|
if "expires_at" in columns:
|
||||||
|
op.drop_column("active_discounts", "expires_at")
|
||||||
@@ -144,6 +144,18 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
|
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Initialize promo discount expiration worker
|
||||||
|
try:
|
||||||
|
promo_code_service: Optional[PromoCodeService] = dispatcher.get("promo_code_service")
|
||||||
|
if promo_code_service:
|
||||||
|
await promo_code_service.setup_discount_expiration_worker(async_session_factory)
|
||||||
|
logging.info("STARTUP: Promo discount expiration worker initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"STARTUP: Failed to initialize promo discount expiration worker: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Automatic sync on startup
|
# Automatic sync on startup
|
||||||
try:
|
try:
|
||||||
logging.info("STARTUP: Running automatic panel sync...")
|
logging.info("STARTUP: Running automatic panel sync...")
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
import asyncio
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import Optional, Tuple, Dict
|
from typing import Optional, Tuple
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
from db.dal import promo_code_dal, user_dal, active_discount_dal, payment_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
|
from .subscription_service import SubscriptionService
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
@@ -23,6 +24,117 @@ class PromoCodeService:
|
|||||||
self.subscription_service = subscription_service
|
self.subscription_service = subscription_service
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.i18n = i18n
|
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(
|
async def apply_promo_code(
|
||||||
self,
|
self,
|
||||||
@@ -99,7 +211,26 @@ class PromoCodeService:
|
|||||||
code_input_upper = code_input.strip().upper()
|
code_input_upper = code_input.strip().upper()
|
||||||
|
|
||||||
# Check if user already has an active discount
|
# 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:
|
if existing_discount:
|
||||||
# Get the promo code for the existing discount
|
# Get the promo code for the existing discount
|
||||||
existing_promo = await promo_code_dal.get_promo_code_by_id(
|
existing_promo = await promo_code_dal.get_promo_code_by_id(
|
||||||
@@ -128,21 +259,37 @@ class PromoCodeService:
|
|||||||
if existing_activation:
|
if existing_activation:
|
||||||
return False, _("promo_code_already_used_by_user", code=code_input_upper)
|
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(
|
active_discount = await active_discount_dal.set_active_discount(
|
||||||
session,
|
session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
promo_code_id=promo_data.promo_code_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:
|
if not active_discount:
|
||||||
# This shouldn't happen since we checked above, but just in case
|
# This shouldn't happen since we checked above, but just in case
|
||||||
return False, _("error_applying_promo_discount")
|
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(
|
logging.info(
|
||||||
f"Discount promo code {code_input_upper} activated for user {user_id}: "
|
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
|
return True, promo_data.discount_percentage
|
||||||
|
|
||||||
@@ -155,10 +302,28 @@ class PromoCodeService:
|
|||||||
Get user's active discount if any.
|
Get user's active discount if any.
|
||||||
Returns: (discount_percentage, promo_code) or None
|
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:
|
if not active_discount:
|
||||||
return None
|
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
|
# Fetch promo code for code string
|
||||||
promo = await promo_code_dal.get_promo_code_by_id(
|
promo = await promo_code_dal.get_promo_code_by_id(
|
||||||
session, active_discount.promo_code_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"Promo code {promo.code} expired (valid_until: {promo.valid_until}). "
|
||||||
f"Clearing active discount for user {user_id}"
|
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 None
|
||||||
|
|
||||||
return (active_discount.discount_percentage, promo.code)
|
return (active_discount.discount_percentage, promo.code)
|
||||||
@@ -206,7 +373,7 @@ class PromoCodeService:
|
|||||||
payment_id: int
|
payment_id: int
|
||||||
) -> bool:
|
) -> 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.
|
Call this AFTER successful payment.
|
||||||
"""
|
"""
|
||||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
|
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||||
@@ -230,7 +397,11 @@ class PromoCodeService:
|
|||||||
)
|
)
|
||||||
return False
|
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:
|
if active_discount and active_discount.promo_code_id != promo_code_id:
|
||||||
logging.info(
|
logging.info(
|
||||||
"Active discount promo %s differs from payment promo %s; leaving active discount intact.",
|
"Active discount promo %s differs from payment promo %s; leaving active discount intact.",
|
||||||
@@ -239,6 +410,27 @@ class PromoCodeService:
|
|||||||
)
|
)
|
||||||
active_discount = None
|
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(
|
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||||
session, promo_code_id, user_id
|
session, promo_code_id, user_id
|
||||||
)
|
)
|
||||||
@@ -269,17 +461,6 @@ class PromoCodeService:
|
|||||||
)
|
)
|
||||||
return False
|
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:
|
if active_discount and active_discount.promo_code_id == promo_code_id:
|
||||||
await active_discount_dal.clear_active_discount(session, user_id)
|
await active_discount_dal.clear_active_discount(session, user_id)
|
||||||
|
|
||||||
|
|||||||
@@ -652,6 +652,10 @@ class Settings(BaseSettings):
|
|||||||
LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations")
|
LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations")
|
||||||
LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations")
|
LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations")
|
||||||
LOG_SUSPICIOUS_ACTIVITY: bool = Field(default=True, description="Send notifications for suspicious promo attempts")
|
LOG_SUSPICIOUS_ACTIVITY: bool = Field(default=True, description="Send notifications for suspicious promo attempts")
|
||||||
|
DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES: int = Field(
|
||||||
|
default=10,
|
||||||
|
description="How long a discount promo reservation is kept before user payment",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file='.env',
|
model_config = SettingsConfigDict(env_file='.env',
|
||||||
env_file_encoding='utf-8',
|
env_file_encoding='utf-8',
|
||||||
|
|||||||
@@ -1,38 +1,44 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from db.models import ActiveDiscount, PromoCode
|
from db.models import ActiveDiscount
|
||||||
|
|
||||||
|
|
||||||
async def set_active_discount(
|
async def set_active_discount(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
promo_code_id: int,
|
promo_code_id: int,
|
||||||
discount_percentage: int
|
discount_percentage: int,
|
||||||
|
expires_at: datetime,
|
||||||
) -> Optional[ActiveDiscount]:
|
) -> Optional[ActiveDiscount]:
|
||||||
"""
|
"""
|
||||||
Set active discount for user.
|
Set active discount for user.
|
||||||
Returns None if user already has an active discount (enforce one-at-a-time rule).
|
Returns None if user already has an active discount (enforce one-at-a-time rule).
|
||||||
"""
|
"""
|
||||||
# Check if user already has an active discount
|
now_utc = datetime.now(timezone.utc)
|
||||||
existing = await get_active_discount(session, user_id)
|
|
||||||
if existing:
|
existing = await get_active_discount(session, user_id, include_expired=True)
|
||||||
|
if existing and existing.expires_at > now_utc:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"User {user_id} already has active discount (promo_code_id: {existing.promo_code_id}). "
|
f"User {user_id} already has active discount (promo_code_id: {existing.promo_code_id}). "
|
||||||
f"Cannot activate new discount {promo_code_id}."
|
f"Cannot activate new discount {promo_code_id}."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if existing and existing.expires_at <= now_utc:
|
||||||
|
await clear_active_discount_if_expired(session, user_id, now=now_utc)
|
||||||
|
|
||||||
# Create new active discount
|
# Create new active discount
|
||||||
new_discount = ActiveDiscount(
|
new_discount = ActiveDiscount(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
promo_code_id=promo_code_id,
|
promo_code_id=promo_code_id,
|
||||||
discount_percentage=discount_percentage,
|
discount_percentage=discount_percentage,
|
||||||
activated_at=datetime.now(timezone.utc)
|
activated_at=now_utc,
|
||||||
|
expires_at=expires_at,
|
||||||
)
|
)
|
||||||
session.add(new_discount)
|
session.add(new_discount)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -46,10 +52,14 @@ async def set_active_discount(
|
|||||||
|
|
||||||
async def get_active_discount(
|
async def get_active_discount(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: int
|
user_id: int,
|
||||||
|
include_expired: bool = False,
|
||||||
) -> Optional[ActiveDiscount]:
|
) -> Optional[ActiveDiscount]:
|
||||||
"""Get active discount for user if exists."""
|
"""Get active discount for user if exists."""
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
stmt = select(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
|
stmt = select(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
|
||||||
|
if not include_expired:
|
||||||
|
stmt = stmt.where(ActiveDiscount.expires_at > now_utc)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -71,6 +81,71 @@ async def clear_active_discount(
|
|||||||
return cleared
|
return cleared
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_active_discount_if_expired(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Clear active discount for user only when it has already expired.
|
||||||
|
"""
|
||||||
|
now_utc = now or datetime.now(timezone.utc)
|
||||||
|
stmt = delete(ActiveDiscount).where(
|
||||||
|
ActiveDiscount.user_id == user_id,
|
||||||
|
ActiveDiscount.expires_at <= now_utc,
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.flush()
|
||||||
|
cleared = result.rowcount > 0
|
||||||
|
if cleared:
|
||||||
|
logging.info("Expired active discount cleared for user %s", user_id)
|
||||||
|
return cleared
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_active_discount_if_matches(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
promo_code_id: Optional[int] = None,
|
||||||
|
expires_at_lte: Optional[datetime] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Clear active discount for user only when additional constraints match.
|
||||||
|
"""
|
||||||
|
conditions = [ActiveDiscount.user_id == user_id]
|
||||||
|
if promo_code_id is not None:
|
||||||
|
conditions.append(ActiveDiscount.promo_code_id == promo_code_id)
|
||||||
|
if expires_at_lte is not None:
|
||||||
|
conditions.append(ActiveDiscount.expires_at <= expires_at_lte)
|
||||||
|
|
||||||
|
stmt = delete(ActiveDiscount).where(*conditions)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.flush()
|
||||||
|
cleared = result.rowcount > 0
|
||||||
|
if cleared:
|
||||||
|
logging.info(
|
||||||
|
"Active discount cleared for user %s by constrained cleanup.",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return cleared
|
||||||
|
|
||||||
|
|
||||||
|
async def get_expired_active_discounts(
|
||||||
|
session: AsyncSession,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> List[ActiveDiscount]:
|
||||||
|
"""Get expired active discount reservations for cleanup/notifications."""
|
||||||
|
now_utc = now or datetime.now(timezone.utc)
|
||||||
|
stmt = (
|
||||||
|
select(ActiveDiscount)
|
||||||
|
.where(ActiveDiscount.expires_at <= now_utc)
|
||||||
|
.order_by(ActiveDiscount.expires_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def clear_active_discounts_by_promo_code(
|
async def clear_active_discounts_by_promo_code(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
promo_code_id: int
|
promo_code_id: int
|
||||||
|
|||||||
+2
-1
@@ -207,7 +207,7 @@ class PromoCodeActivation(Base):
|
|||||||
|
|
||||||
|
|
||||||
class ActiveDiscount(Base):
|
class ActiveDiscount(Base):
|
||||||
"""Tracks pending discount promo codes awaiting payment (permanent until used)"""
|
"""Tracks pending discount promo code reservations awaiting payment."""
|
||||||
__tablename__ = "active_discounts"
|
__tablename__ = "active_discounts"
|
||||||
|
|
||||||
user_id = Column(
|
user_id = Column(
|
||||||
@@ -222,6 +222,7 @@ class ActiveDiscount(Base):
|
|||||||
)
|
)
|
||||||
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())
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
promo_code = relationship("PromoCode")
|
promo_code = relationship("PromoCode")
|
||||||
user = relationship("User")
|
user = relationship("User")
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||||
"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.",
|
||||||
|
"discount_promo_expired_need_reactivate": "⏰ The discount hold period has ended{code_part}.\n\nPlease enter the promo code again to apply the discount.",
|
||||||
"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}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Savings: {discount_amount}{currency_symbol}",
|
"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.",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||||
"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}%). Используйте его сначала или дождитесь окончания платежа.",
|
||||||
|
"discount_promo_expired_need_reactivate": "⏰ Время действия скидки истекло{code_part}.\n\nВведите промокод снова, чтобы применить скидку.",
|
||||||
"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}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Экономия: {discount_amount}{currency_symbol}",
|
"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": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
||||||
|
|||||||
Reference in New Issue
Block a user