327 lines
12 KiB
Python
327 lines
12 KiB
Python
"""
|
|
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
|