Files
bakery-ia/services/tenant/app/repositories/coupon_repository.py

327 lines
12 KiB
Python
Raw Normal View History

2025-10-17 18:14:28 +02:00
"""
Repository for coupon data access and validation
"""
2026-01-13 22:22:38 +01:00
from datetime import datetime, timezone
2025-10-17 18:14:28 +02:00
from typing import Optional
2026-01-13 22:22:38 +01:00
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import and_, select
from sqlalchemy.orm import selectinload
2025-10-17 18:14:28 +02:00
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"""
2026-01-13 22:22:38 +01:00
def __init__(self, db: AsyncSession):
2025-10-17 18:14:28 +02:00
self.db = db
2026-01-13 22:22:38 +01:00
async def get_coupon_by_code(self, code: str) -> Optional[Coupon]:
2025-10-17 18:14:28 +02:00
"""
Retrieve coupon by code.
Returns None if not found.
"""
2026-01-13 22:22:38 +01:00
result = await self.db.execute(
select(CouponModel).where(CouponModel.code == code.upper())
)
coupon_model = result.scalar_one_or_none()
2025-10-17 18:14:28 +02:00
if not coupon_model:
return None
return self._model_to_dataclass(coupon_model)
2026-01-13 22:22:38 +01:00
async def validate_coupon(
2025-10-17 18:14:28 +02:00
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
2026-01-13 22:22:38 +01:00
coupon = await self.get_coupon_by_code(code)
2025-10-17 18:14:28 +02:00
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
2026-01-13 22:22:38 +01:00
result = await self.db.execute(
select(CouponRedemptionModel).where(
and_(
CouponRedemptionModel.tenant_id == tenant_id,
CouponRedemptionModel.coupon_code == code.upper()
)
2025-10-17 18:14:28 +02:00
)
2026-01-13 22:22:38 +01:00
)
existing_redemption = result.scalar_one_or_none()
2025-10-17 18:14:28 +02:00
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
)
2026-01-13 22:22:38 +01:00
async def redeem_coupon(
2025-10-17 18:14:28 +02:00
self,
code: str,
2026-01-13 22:22:38 +01:00
tenant_id: Optional[str],
2025-10-17 18:14:28 +02:00
base_trial_days: int = 14
) -> tuple[bool, Optional[CouponRedemption], Optional[str]]:
"""
Redeem a coupon for a tenant.
2026-01-13 22:22:38 +01:00
For tenant-independent registrations, tenant_id can be None initially.
2025-10-17 18:14:28 +02:00
Returns (success, redemption, error_message)
"""
2026-01-13 22:22:38 +01:00
# 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)
2025-10-17 18:14:28 +02:00
# Calculate discount applied
discount_applied = self._calculate_discount_applied(
coupon,
base_trial_days
)
2026-01-13 22:22:38 +01:00
# 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
}
)
2025-10-17 18:14:28 +02:00
2026-01-13 22:22:38 +01:00
self.db.add(redemption_model)
2025-10-17 18:14:28 +02:00
2026-01-13 22:22:38 +01:00
# 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
2025-10-17 18:14:28 +02:00
redemption = CouponRedemption(
2026-01-13 22:22:38 +01:00
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
}
2025-10-17 18:14:28 +02:00
)
return True, redemption, None
2026-01-13 22:22:38 +01:00
async def get_redemption_by_tenant_and_code(
2025-10-17 18:14:28 +02:00
self,
tenant_id: str,
code: str
) -> Optional[CouponRedemption]:
"""Get existing redemption for tenant and coupon code"""
2026-01-13 22:22:38 +01:00
result = await self.db.execute(
select(CouponRedemptionModel).where(
and_(
CouponRedemptionModel.tenant_id == tenant_id,
CouponRedemptionModel.coupon_code == code.upper()
)
2025-10-17 18:14:28 +02:00
)
2026-01-13 22:22:38 +01:00
)
redemption_model = result.scalar_one_or_none()
2025-10-17 18:14:28 +02:00
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
)
2026-01-13 22:22:38 +01:00
async def get_coupon_usage_stats(self, code: str) -> Optional[dict]:
2025-10-17 18:14:28 +02:00
"""Get usage statistics for a coupon"""
2026-01-13 22:22:38 +01:00
result = await self.db.execute(
select(CouponModel).where(CouponModel.code == code.upper())
)
coupon_model = result.scalar_one_or_none()
2025-10-17 18:14:28 +02:00
if not coupon_model:
return None
2026-01-13 22:22:38 +01:00
count_result = await self.db.execute(
select(CouponRedemptionModel).where(
CouponRedemptionModel.coupon_code == code.upper()
)
)
redemptions_count = len(count_result.scalars().all())
2025-10-17 18:14:28 +02:00
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