Files
bakery-ia/services/tenant/app/repositories/coupon_repository.py
2026-01-13 22:22:38 +01:00

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