""" Repository for coupon data access and validation """ from datetime import datetime, timezone from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import and_, select from sqlalchemy.orm import selectinload from app.models.coupon import CouponModel, CouponRedemptionModel from shared.subscription.coupons import ( Coupon, CouponRedemption, CouponValidationResult, DiscountType, calculate_trial_end_date, format_discount_description ) class CouponRepository: """Data access layer for coupon operations""" def __init__(self, db: AsyncSession): self.db = db async def get_coupon_by_code(self, code: str) -> Optional[Coupon]: """ Retrieve coupon by code. Returns None if not found. """ result = await self.db.execute( select(CouponModel).where(CouponModel.code == code.upper()) ) coupon_model = result.scalar_one_or_none() if not coupon_model: return None return self._model_to_dataclass(coupon_model) async def validate_coupon( self, code: str, tenant_id: str ) -> CouponValidationResult: """ Validate a coupon code for a specific tenant. Checks: existence, validity, redemption limits, and if tenant already used it. """ # Get coupon coupon = await self.get_coupon_by_code(code) if not coupon: return CouponValidationResult( valid=False, coupon=None, error_message="Código de cupón inválido", discount_preview=None ) # Check if coupon can be redeemed can_redeem, reason = coupon.can_be_redeemed() if not can_redeem: error_messages = { "Coupon is inactive": "Este cupón no está activo", "Coupon is not yet valid": "Este cupón aún no es válido", "Coupon has expired": "Este cupón ha expirado", "Coupon has reached maximum redemptions": "Este cupón ha alcanzado su límite de usos" } return CouponValidationResult( valid=False, coupon=coupon, error_message=error_messages.get(reason, reason), discount_preview=None ) # Check if tenant already redeemed this coupon result = await self.db.execute( select(CouponRedemptionModel).where( and_( CouponRedemptionModel.tenant_id == tenant_id, CouponRedemptionModel.coupon_code == code.upper() ) ) ) existing_redemption = result.scalar_one_or_none() if existing_redemption: return CouponValidationResult( valid=False, coupon=coupon, error_message="Ya has utilizado este cupón", discount_preview=None ) # Generate discount preview discount_preview = self._generate_discount_preview(coupon) return CouponValidationResult( valid=True, coupon=coupon, error_message=None, discount_preview=discount_preview ) async def redeem_coupon( self, code: str, tenant_id: Optional[str], base_trial_days: int = 14 ) -> tuple[bool, Optional[CouponRedemption], Optional[str]]: """ Redeem a coupon for a tenant. For tenant-independent registrations, tenant_id can be None initially. Returns (success, redemption, error_message) """ # For tenant-independent registrations, skip tenant validation if tenant_id: # Validate first validation = await self.validate_coupon(code, tenant_id) if not validation.valid: return False, None, validation.error_message coupon = validation.coupon else: # Just get the coupon and validate its general availability coupon = await self.get_coupon_by_code(code) if not coupon: return False, None, "Código de cupón inválido" # Check if coupon can be redeemed can_redeem, reason = coupon.can_be_redeemed() if not can_redeem: error_messages = { "Coupon is inactive": "Este cupón no está activo", "Coupon is not yet valid": "Este cupón aún no es válido", "Coupon has expired": "Este cupón ha expirado", "Coupon has reached maximum redemptions": "Este cupón ha alcanzado su límite de usos" } return False, None, error_messages.get(reason, reason) # Calculate discount applied discount_applied = self._calculate_discount_applied( coupon, base_trial_days ) # Only create redemption record if tenant_id is provided # For tenant-independent subscriptions, skip redemption record creation if tenant_id: # Create redemption record redemption_model = CouponRedemptionModel( tenant_id=tenant_id, coupon_code=code.upper(), redeemed_at=datetime.now(timezone.utc), discount_applied=discount_applied, extra_data={ "coupon_type": coupon.discount_type.value, "coupon_value": coupon.discount_value } ) self.db.add(redemption_model) # Increment coupon redemption count result = await self.db.execute( select(CouponModel).where(CouponModel.code == code.upper()) ) coupon_model = result.scalar_one_or_none() if coupon_model: coupon_model.current_redemptions += 1 try: await self.db.commit() await self.db.refresh(redemption_model) redemption = CouponRedemption( id=str(redemption_model.id), tenant_id=redemption_model.tenant_id, coupon_code=redemption_model.coupon_code, redeemed_at=redemption_model.redeemed_at, discount_applied=redemption_model.discount_applied, extra_data=redemption_model.extra_data ) return True, redemption, None except Exception as e: await self.db.rollback() return False, None, f"Error al aplicar el cupón: {str(e)}" else: # For tenant-independent subscriptions, return discount without creating redemption # The redemption will be created when the tenant is linked redemption = CouponRedemption( id="pending", # Temporary ID tenant_id="pending", # Will be set during tenant linking coupon_code=code.upper(), redeemed_at=datetime.now(timezone.utc), discount_applied=discount_applied, extra_data={ "coupon_type": coupon.discount_type.value, "coupon_value": coupon.discount_value } ) return True, redemption, None async def get_redemption_by_tenant_and_code( self, tenant_id: str, code: str ) -> Optional[CouponRedemption]: """Get existing redemption for tenant and coupon code""" result = await self.db.execute( select(CouponRedemptionModel).where( and_( CouponRedemptionModel.tenant_id == tenant_id, CouponRedemptionModel.coupon_code == code.upper() ) ) ) redemption_model = result.scalar_one_or_none() if not redemption_model: return None return CouponRedemption( id=str(redemption_model.id), tenant_id=redemption_model.tenant_id, coupon_code=redemption_model.coupon_code, redeemed_at=redemption_model.redeemed_at, discount_applied=redemption_model.discount_applied, extra_data=redemption_model.extra_data ) async def get_coupon_usage_stats(self, code: str) -> Optional[dict]: """Get usage statistics for a coupon""" result = await self.db.execute( select(CouponModel).where(CouponModel.code == code.upper()) ) coupon_model = result.scalar_one_or_none() if not coupon_model: return None count_result = await self.db.execute( select(CouponRedemptionModel).where( CouponRedemptionModel.coupon_code == code.upper() ) ) redemptions_count = len(count_result.scalars().all()) return { "code": coupon_model.code, "current_redemptions": coupon_model.current_redemptions, "max_redemptions": coupon_model.max_redemptions, "redemptions_remaining": ( coupon_model.max_redemptions - coupon_model.current_redemptions if coupon_model.max_redemptions else None ), "active": coupon_model.active, "valid_from": coupon_model.valid_from.isoformat(), "valid_until": coupon_model.valid_until.isoformat() if coupon_model.valid_until else None } def _model_to_dataclass(self, model: CouponModel) -> Coupon: """Convert SQLAlchemy model to dataclass""" return Coupon( id=str(model.id), code=model.code, discount_type=DiscountType(model.discount_type), discount_value=model.discount_value, max_redemptions=model.max_redemptions, current_redemptions=model.current_redemptions, valid_from=model.valid_from, valid_until=model.valid_until, active=model.active, created_at=model.created_at, extra_data=model.extra_data ) def _generate_discount_preview(self, coupon: Coupon) -> dict: """Generate a preview of the discount to be applied""" description = format_discount_description(coupon) preview = { "description": description, "discount_type": coupon.discount_type.value, "discount_value": coupon.discount_value } if coupon.discount_type == DiscountType.TRIAL_EXTENSION: trial_end = calculate_trial_end_date(14, coupon.discount_value) preview["trial_end_date"] = trial_end.isoformat() preview["total_trial_days"] = 14 + coupon.discount_value return preview def _calculate_discount_applied( self, coupon: Coupon, base_trial_days: int ) -> dict: """Calculate the actual discount that will be applied""" discount = { "type": coupon.discount_type.value, "value": coupon.discount_value, "description": format_discount_description(coupon) } if coupon.discount_type == DiscountType.TRIAL_EXTENSION: total_trial_days = base_trial_days + coupon.discount_value trial_end = calculate_trial_end_date(base_trial_days, coupon.discount_value) discount["base_trial_days"] = base_trial_days discount["extension_days"] = coupon.discount_value discount["total_trial_days"] = total_trial_days discount["trial_end_date"] = trial_end.isoformat() elif coupon.discount_type == DiscountType.PERCENTAGE: discount["percentage_off"] = coupon.discount_value elif coupon.discount_type == DiscountType.FIXED_AMOUNT: discount["amount_off_cents"] = coupon.discount_value discount["amount_off_euros"] = coupon.discount_value / 100 return discount