Improve public pages
This commit is contained in:
@@ -45,7 +45,7 @@ class CloneOrchestrator:
|
||||
name="inventory",
|
||||
url=os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000"),
|
||||
required=False, # Optional - provides ingredients/recipes
|
||||
timeout=10.0
|
||||
timeout=5.0 # Reduced from 10s - optimized data volume
|
||||
),
|
||||
ServiceDefinition(
|
||||
name="recipes",
|
||||
@@ -63,7 +63,7 @@ class CloneOrchestrator:
|
||||
name="sales",
|
||||
url=os.getenv("SALES_SERVICE_URL", "http://sales-service:8000"),
|
||||
required=False, # Optional - provides sales history
|
||||
timeout=10.0
|
||||
timeout=5.0 # Reduced from 10s - optimized to 30 days history
|
||||
),
|
||||
ServiceDefinition(
|
||||
name="orders",
|
||||
|
||||
@@ -112,7 +112,7 @@ async def create_stock_batches_for_ingredient(
|
||||
base_date: datetime
|
||||
) -> list:
|
||||
"""
|
||||
Create 3-5 stock batches for a single ingredient with varied properties
|
||||
Create 1-2 stock batches for a single ingredient (optimized for demo performance)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -124,7 +124,7 @@ async def create_stock_batches_for_ingredient(
|
||||
List of created Stock instances
|
||||
"""
|
||||
stocks = []
|
||||
num_batches = random.randint(3, 5)
|
||||
num_batches = random.randint(1, 2) # Reduced from 3-5 for faster demo loading
|
||||
|
||||
for i in range(num_batches):
|
||||
# Calculate expiration days offset
|
||||
|
||||
@@ -236,24 +236,24 @@ async def seed_sales(sales_db: AsyncSession):
|
||||
|
||||
results = []
|
||||
|
||||
# Seed for San Pablo (Traditional Bakery) - 90 days of history
|
||||
# Seed for San Pablo (Traditional Bakery) - 30 days of history (optimized for fast demo loading)
|
||||
logger.info("")
|
||||
result_san_pablo = await seed_sales_for_tenant(
|
||||
sales_db,
|
||||
DEMO_TENANT_SAN_PABLO,
|
||||
"Panadería San Pablo (Traditional)",
|
||||
SAN_PABLO_PRODUCTS,
|
||||
days_of_history=90
|
||||
days_of_history=30
|
||||
)
|
||||
results.append(result_san_pablo)
|
||||
|
||||
# Seed for La Espiga (Central Workshop) - 90 days of history
|
||||
# Seed for La Espiga (Central Workshop) - 30 days of history (optimized for fast demo loading)
|
||||
result_la_espiga = await seed_sales_for_tenant(
|
||||
sales_db,
|
||||
DEMO_TENANT_LA_ESPIGA,
|
||||
"Panadería La Espiga (Central Workshop)",
|
||||
LA_ESPIGA_PRODUCTS,
|
||||
days_of_history=90
|
||||
days_of_history=30
|
||||
)
|
||||
results.append(result_la_espiga)
|
||||
|
||||
@@ -331,7 +331,7 @@ async def main():
|
||||
logger.info("🎉 Success! Sales history is ready for cloning.")
|
||||
logger.info("")
|
||||
logger.info("Sales data includes:")
|
||||
logger.info(" • 90 days of historical sales")
|
||||
logger.info(" • 30 days of historical sales (optimized for demo performance)")
|
||||
logger.info(" • 4 product types per tenant")
|
||||
logger.info(" • Realistic weekly patterns (higher on weekends)")
|
||||
logger.info(" • Random variance and occasional closures")
|
||||
|
||||
@@ -85,20 +85,77 @@ def get_payment_service():
|
||||
async def register_bakery(
|
||||
bakery_data: BakeryRegistration,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service),
|
||||
payment_service: PaymentService = Depends(get_payment_service)
|
||||
):
|
||||
"""Register a new bakery/tenant with enhanced validation and features"""
|
||||
|
||||
try:
|
||||
# Validate coupon if provided
|
||||
coupon_validation = None
|
||||
if bakery_data.coupon_code:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
|
||||
async with database_manager.get_session() as session:
|
||||
# Temp tenant ID for validation (will be replaced with actual after creation)
|
||||
temp_tenant_id = f"temp_{current_user['user_id']}"
|
||||
|
||||
coupon_validation = payment_service.validate_coupon_code(
|
||||
bakery_data.coupon_code,
|
||||
temp_tenant_id,
|
||||
session
|
||||
)
|
||||
|
||||
if not coupon_validation["valid"]:
|
||||
logger.warning(
|
||||
"Invalid coupon code provided during registration",
|
||||
coupon_code=bakery_data.coupon_code,
|
||||
error=coupon_validation["error_message"]
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=coupon_validation["error_message"]
|
||||
)
|
||||
|
||||
# Create bakery/tenant
|
||||
result = await tenant_service.create_bakery(
|
||||
bakery_data,
|
||||
current_user["user_id"]
|
||||
)
|
||||
|
||||
# If coupon was validated, redeem it now with actual tenant_id
|
||||
if coupon_validation and coupon_validation["valid"]:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
|
||||
async with database_manager.get_session() as session:
|
||||
success, discount, error = payment_service.redeem_coupon(
|
||||
bakery_data.coupon_code,
|
||||
result.id,
|
||||
session
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
"Coupon redeemed successfully",
|
||||
tenant_id=result.id,
|
||||
coupon_code=bakery_data.coupon_code,
|
||||
discount=discount
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to redeem coupon after registration",
|
||||
tenant_id=result.id,
|
||||
coupon_code=bakery_data.coupon_code,
|
||||
error=error
|
||||
)
|
||||
|
||||
logger.info("Bakery registered successfully",
|
||||
name=bakery_data.name,
|
||||
owner_email=current_user.get('email'),
|
||||
tenant_id=result.id)
|
||||
tenant_id=result.id,
|
||||
coupon_applied=bakery_data.coupon_code is not None)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ AuditLog = create_audit_log_model(Base)
|
||||
|
||||
# Import all models to register them with the Base metadata
|
||||
from .tenants import Tenant, TenantMember, Subscription
|
||||
from .coupon import CouponModel, CouponRedemptionModel
|
||||
|
||||
# List all models for easier access
|
||||
__all__ = [
|
||||
@@ -20,4 +21,6 @@ __all__ = [
|
||||
"TenantMember",
|
||||
"Subscription",
|
||||
"AuditLog",
|
||||
"CouponModel",
|
||||
"CouponRedemptionModel",
|
||||
]
|
||||
|
||||
64
services/tenant/app/models/coupon.py
Normal file
64
services/tenant/app/models/coupon.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
SQLAlchemy models for coupon system
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, JSON, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
from shared.database import Base
|
||||
|
||||
|
||||
class CouponModel(Base):
|
||||
"""Coupon configuration table"""
|
||||
__tablename__ = "coupons"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
discount_type = Column(String(20), nullable=False) # trial_extension, percentage, fixed_amount
|
||||
discount_value = Column(Integer, nullable=False) # Days/percentage/cents depending on type
|
||||
max_redemptions = Column(Integer, nullable=True) # None = unlimited
|
||||
current_redemptions = Column(Integer, nullable=False, default=0)
|
||||
valid_from = Column(DateTime(timezone=True), nullable=False)
|
||||
valid_until = Column(DateTime(timezone=True), nullable=True) # None = no expiry
|
||||
active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
extra_data = Column(JSON, nullable=True) # Renamed from metadata to avoid SQLAlchemy reserved name
|
||||
|
||||
# Relationships
|
||||
redemptions = relationship("CouponRedemptionModel", back_populates="coupon")
|
||||
|
||||
# Indexes for performance
|
||||
__table_args__ = (
|
||||
Index('idx_coupon_code_active', 'code', 'active'),
|
||||
Index('idx_coupon_valid_dates', 'valid_from', 'valid_until'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Coupon(code='{self.code}', type='{self.discount_type}', value={self.discount_value})>"
|
||||
|
||||
|
||||
class CouponRedemptionModel(Base):
|
||||
"""Coupon redemption history table"""
|
||||
__tablename__ = "coupon_redemptions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(String(255), nullable=False, index=True)
|
||||
coupon_code = Column(String(50), ForeignKey('coupons.code'), nullable=False)
|
||||
redeemed_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
discount_applied = Column(JSON, nullable=False) # Details of discount applied
|
||||
extra_data = Column(JSON, nullable=True) # Renamed from metadata to avoid SQLAlchemy reserved name
|
||||
|
||||
# Relationships
|
||||
coupon = relationship("CouponModel", back_populates="redemptions")
|
||||
|
||||
# Constraints
|
||||
__table_args__ = (
|
||||
Index('idx_redemption_tenant', 'tenant_id'),
|
||||
Index('idx_redemption_coupon', 'coupon_code'),
|
||||
Index('idx_redemption_tenant_coupon', 'tenant_id', 'coupon_code'), # Prevent duplicate redemptions
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CouponRedemption(tenant_id='{self.tenant_id}', code='{self.coupon_code}')>"
|
||||
277
services/tenant/app/repositories/coupon_repository.py
Normal file
277
services/tenant/app/repositories/coupon_repository.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Repository for coupon data access and validation
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
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: Session):
|
||||
self.db = db
|
||||
|
||||
def get_coupon_by_code(self, code: str) -> Optional[Coupon]:
|
||||
"""
|
||||
Retrieve coupon by code.
|
||||
Returns None if not found.
|
||||
"""
|
||||
coupon_model = self.db.query(CouponModel).filter(
|
||||
CouponModel.code == code.upper()
|
||||
).first()
|
||||
|
||||
if not coupon_model:
|
||||
return None
|
||||
|
||||
return self._model_to_dataclass(coupon_model)
|
||||
|
||||
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 = 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
|
||||
existing_redemption = self.db.query(CouponRedemptionModel).filter(
|
||||
and_(
|
||||
CouponRedemptionModel.tenant_id == tenant_id,
|
||||
CouponRedemptionModel.coupon_code == code.upper()
|
||||
)
|
||||
).first()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def redeem_coupon(
|
||||
self,
|
||||
code: str,
|
||||
tenant_id: str,
|
||||
base_trial_days: int = 14
|
||||
) -> tuple[bool, Optional[CouponRedemption], Optional[str]]:
|
||||
"""
|
||||
Redeem a coupon for a tenant.
|
||||
Returns (success, redemption, error_message)
|
||||
"""
|
||||
# Validate first
|
||||
validation = self.validate_coupon(code, tenant_id)
|
||||
if not validation.valid:
|
||||
return False, None, validation.error_message
|
||||
|
||||
coupon = validation.coupon
|
||||
|
||||
# Calculate discount applied
|
||||
discount_applied = self._calculate_discount_applied(
|
||||
coupon,
|
||||
base_trial_days
|
||||
)
|
||||
|
||||
# Create redemption record
|
||||
redemption_model = CouponRedemptionModel(
|
||||
tenant_id=tenant_id,
|
||||
coupon_code=code.upper(),
|
||||
redeemed_at=datetime.utcnow(),
|
||||
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
|
||||
coupon_model = self.db.query(CouponModel).filter(
|
||||
CouponModel.code == code.upper()
|
||||
).first()
|
||||
if coupon_model:
|
||||
coupon_model.current_redemptions += 1
|
||||
|
||||
try:
|
||||
self.db.commit()
|
||||
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:
|
||||
self.db.rollback()
|
||||
return False, None, f"Error al aplicar el cupón: {str(e)}"
|
||||
|
||||
def get_redemption_by_tenant_and_code(
|
||||
self,
|
||||
tenant_id: str,
|
||||
code: str
|
||||
) -> Optional[CouponRedemption]:
|
||||
"""Get existing redemption for tenant and coupon code"""
|
||||
redemption_model = self.db.query(CouponRedemptionModel).filter(
|
||||
and_(
|
||||
CouponRedemptionModel.tenant_id == tenant_id,
|
||||
CouponRedemptionModel.coupon_code == code.upper()
|
||||
)
|
||||
).first()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def get_coupon_usage_stats(self, code: str) -> Optional[dict]:
|
||||
"""Get usage statistics for a coupon"""
|
||||
coupon_model = self.db.query(CouponModel).filter(
|
||||
CouponModel.code == code.upper()
|
||||
).first()
|
||||
|
||||
if not coupon_model:
|
||||
return None
|
||||
|
||||
redemptions_count = self.db.query(CouponRedemptionModel).filter(
|
||||
CouponRedemptionModel.coupon_code == code.upper()
|
||||
).count()
|
||||
|
||||
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
|
||||
@@ -18,6 +18,7 @@ class BakeryRegistration(BaseModel):
|
||||
phone: str = Field(..., min_length=9, max_length=20)
|
||||
business_type: str = Field(default="bakery")
|
||||
business_model: Optional[str] = Field(default="individual_bakery")
|
||||
coupon_code: Optional[str] = Field(None, max_length=50, description="Promotional coupon code")
|
||||
|
||||
@validator('phone')
|
||||
def validate_spanish_phone(cls, v):
|
||||
|
||||
@@ -6,12 +6,14 @@ This service abstracts payment provider interactions and makes the system paymen
|
||||
import structlog
|
||||
from typing import Dict, Any, Optional
|
||||
import uuid
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.clients.payment_client import PaymentProvider, PaymentCustomer, Subscription, PaymentMethod
|
||||
from shared.clients.stripe_client import StripeProvider
|
||||
from shared.database.base import create_database_manager
|
||||
from app.repositories.subscription_repository import SubscriptionRepository
|
||||
from app.repositories.coupon_repository import CouponRepository
|
||||
from app.models.tenants import Subscription as SubscriptionModel
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -19,18 +21,19 @@ logger = structlog.get_logger()
|
||||
|
||||
class PaymentService:
|
||||
"""Service for handling payment provider interactions"""
|
||||
|
||||
def __init__(self):
|
||||
|
||||
def __init__(self, db_session: Optional[Session] = None):
|
||||
# Initialize payment provider based on configuration
|
||||
# For now, we'll use Stripe, but this can be swapped for other providers
|
||||
self.payment_provider: PaymentProvider = StripeProvider(
|
||||
api_key=settings.STRIPE_SECRET_KEY,
|
||||
webhook_secret=settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
|
||||
|
||||
# Initialize database components
|
||||
self.database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
self.subscription_repo = SubscriptionRepository(SubscriptionModel, None) # Will be set in methods
|
||||
self.db_session = db_session # Optional session for coupon operations
|
||||
|
||||
async def create_customer(self, user_data: Dict[str, Any]) -> PaymentCustomer:
|
||||
"""Create a customer in the payment provider system"""
|
||||
@@ -68,35 +71,121 @@ class PaymentService:
|
||||
logger.error("Failed to create subscription in payment provider", error=str(e))
|
||||
raise e
|
||||
|
||||
def validate_coupon_code(
|
||||
self,
|
||||
coupon_code: str,
|
||||
tenant_id: str,
|
||||
db_session: Session
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate a coupon code for a tenant.
|
||||
Returns validation result with discount preview.
|
||||
"""
|
||||
try:
|
||||
coupon_repo = CouponRepository(db_session)
|
||||
validation = coupon_repo.validate_coupon(coupon_code, tenant_id)
|
||||
|
||||
return {
|
||||
"valid": validation.valid,
|
||||
"error_message": validation.error_message,
|
||||
"discount_preview": validation.discount_preview,
|
||||
"coupon": {
|
||||
"code": validation.coupon.code,
|
||||
"discount_type": validation.coupon.discount_type.value,
|
||||
"discount_value": validation.coupon.discount_value
|
||||
} if validation.coupon else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to validate coupon", error=str(e), coupon_code=coupon_code)
|
||||
return {
|
||||
"valid": False,
|
||||
"error_message": "Error al validar el cupón",
|
||||
"discount_preview": None,
|
||||
"coupon": None
|
||||
}
|
||||
|
||||
def redeem_coupon(
|
||||
self,
|
||||
coupon_code: str,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
base_trial_days: int = 14
|
||||
) -> tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
Redeem a coupon for a tenant.
|
||||
Returns (success, discount_applied, error_message)
|
||||
"""
|
||||
try:
|
||||
coupon_repo = CouponRepository(db_session)
|
||||
success, redemption, error = coupon_repo.redeem_coupon(
|
||||
coupon_code,
|
||||
tenant_id,
|
||||
base_trial_days
|
||||
)
|
||||
|
||||
if success and redemption:
|
||||
return True, redemption.discount_applied, None
|
||||
else:
|
||||
return False, None, error
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to redeem coupon", error=str(e), coupon_code=coupon_code)
|
||||
return False, None, f"Error al aplicar el cupón: {str(e)}"
|
||||
|
||||
async def process_registration_with_subscription(
|
||||
self,
|
||||
user_data: Dict[str, Any],
|
||||
plan_id: str,
|
||||
self,
|
||||
user_data: Dict[str, Any],
|
||||
plan_id: str,
|
||||
payment_method_id: str,
|
||||
use_trial: bool = False
|
||||
use_trial: bool = False,
|
||||
coupon_code: Optional[str] = None,
|
||||
db_session: Optional[Session] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Process user registration with subscription creation"""
|
||||
try:
|
||||
# Create customer in payment provider
|
||||
customer = await self.create_customer(user_data)
|
||||
|
||||
# Determine trial period
|
||||
trial_period_days = None
|
||||
if use_trial:
|
||||
trial_period_days = 90 # 3 months trial for pilot users
|
||||
|
||||
|
||||
# Determine trial period (default 14 days)
|
||||
trial_period_days = 14 if use_trial else 0
|
||||
|
||||
# Apply coupon if provided
|
||||
coupon_discount = None
|
||||
if coupon_code and db_session:
|
||||
# Redeem coupon
|
||||
success, discount, error = self.redeem_coupon(
|
||||
coupon_code,
|
||||
user_data.get('tenant_id'),
|
||||
db_session,
|
||||
trial_period_days
|
||||
)
|
||||
|
||||
if success and discount:
|
||||
coupon_discount = discount
|
||||
# Update trial period if coupon extends it
|
||||
if discount.get("type") == "trial_extension":
|
||||
trial_period_days = discount.get("total_trial_days", trial_period_days)
|
||||
logger.info(
|
||||
"Coupon applied successfully",
|
||||
coupon_code=coupon_code,
|
||||
extended_trial_days=trial_period_days
|
||||
)
|
||||
else:
|
||||
logger.warning("Failed to apply coupon", error=error, coupon_code=coupon_code)
|
||||
|
||||
# Create subscription
|
||||
subscription = await self.create_subscription(
|
||||
customer.id,
|
||||
plan_id,
|
||||
payment_method_id,
|
||||
trial_period_days
|
||||
trial_period_days if trial_period_days > 0 else None
|
||||
)
|
||||
|
||||
|
||||
# Save subscription to database
|
||||
async with self.database_manager.get_session() as session:
|
||||
self.subscription_repo.session = session
|
||||
subscription_record = await self.subscription_repo.create({
|
||||
subscription_data = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'tenant_id': user_data.get('tenant_id'),
|
||||
'customer_id': customer.id,
|
||||
@@ -106,15 +195,26 @@ class PaymentService:
|
||||
'current_period_start': subscription.current_period_start,
|
||||
'current_period_end': subscription.current_period_end,
|
||||
'created_at': subscription.created_at,
|
||||
'trial_period_days': trial_period_days
|
||||
})
|
||||
|
||||
return {
|
||||
'trial_period_days': trial_period_days if trial_period_days > 0 else None
|
||||
}
|
||||
subscription_record = await self.subscription_repo.create(subscription_data)
|
||||
|
||||
result = {
|
||||
'customer_id': customer.id,
|
||||
'subscription_id': subscription.id,
|
||||
'status': subscription.status,
|
||||
'trial_period_days': trial_period_days
|
||||
}
|
||||
|
||||
# Include coupon info if applied
|
||||
if coupon_discount:
|
||||
result['coupon_applied'] = {
|
||||
'code': coupon_code,
|
||||
'discount': coupon_discount
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to process registration with subscription", error=str(e))
|
||||
raise e
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""add_coupon_system
|
||||
|
||||
Revision ID: 20251017_0000
|
||||
Revises: 20251016_0000
|
||||
Create Date: 2025-10-17 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
import uuid
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20251017_0000'
|
||||
down_revision = '20251016_0000'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create coupons table
|
||||
op.create_table(
|
||||
'coupons',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
|
||||
sa.Column('code', sa.String(50), nullable=False, unique=True),
|
||||
sa.Column('discount_type', sa.String(20), nullable=False),
|
||||
sa.Column('discount_value', sa.Integer(), nullable=False),
|
||||
sa.Column('max_redemptions', sa.Integer(), nullable=True),
|
||||
sa.Column('current_redemptions', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('extra_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
# Create indexes for coupons table
|
||||
op.create_index('idx_coupon_code_active', 'coupons', ['code', 'active'])
|
||||
op.create_index('idx_coupon_valid_dates', 'coupons', ['valid_from', 'valid_until'])
|
||||
|
||||
# Create coupon_redemptions table
|
||||
op.create_table(
|
||||
'coupon_redemptions',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
|
||||
sa.Column('tenant_id', sa.String(255), nullable=False),
|
||||
sa.Column('coupon_code', sa.String(50), nullable=False),
|
||||
sa.Column('redeemed_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('discount_applied', postgresql.JSON(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('extra_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.ForeignKeyConstraint(['coupon_code'], ['coupons.code'], name='fk_coupon_redemption_code'),
|
||||
)
|
||||
|
||||
# Create indexes for coupon_redemptions table
|
||||
op.create_index('idx_redemption_tenant', 'coupon_redemptions', ['tenant_id'])
|
||||
op.create_index('idx_redemption_coupon', 'coupon_redemptions', ['coupon_code'])
|
||||
op.create_index('idx_redemption_tenant_coupon', 'coupon_redemptions', ['tenant_id', 'coupon_code'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes first
|
||||
op.drop_index('idx_redemption_tenant_coupon', table_name='coupon_redemptions')
|
||||
op.drop_index('idx_redemption_coupon', table_name='coupon_redemptions')
|
||||
op.drop_index('idx_redemption_tenant', table_name='coupon_redemptions')
|
||||
op.drop_index('idx_coupon_valid_dates', table_name='coupons')
|
||||
op.drop_index('idx_coupon_code_active', table_name='coupons')
|
||||
|
||||
# Drop tables
|
||||
op.drop_table('coupon_redemptions')
|
||||
op.drop_table('coupons')
|
||||
102
services/tenant/scripts/seed_pilot_coupon.py
Normal file
102
services/tenant/scripts/seed_pilot_coupon.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Seed script to create the PILOT2025 coupon for the pilot customer program.
|
||||
This coupon provides 3 months (90 days) free trial extension for the first 20 customers.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..')))
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.coupon import CouponModel
|
||||
from shared.database import get_db
|
||||
|
||||
|
||||
def seed_pilot_coupon(db: Session):
|
||||
"""Create or update the PILOT2025 coupon"""
|
||||
|
||||
coupon_code = "PILOT2025"
|
||||
|
||||
# Check if coupon already exists
|
||||
existing_coupon = db.query(CouponModel).filter(
|
||||
CouponModel.code == coupon_code
|
||||
).first()
|
||||
|
||||
if existing_coupon:
|
||||
print(f"✓ Coupon {coupon_code} already exists")
|
||||
print(f" Current redemptions: {existing_coupon.current_redemptions}/{existing_coupon.max_redemptions}")
|
||||
print(f" Active: {existing_coupon.active}")
|
||||
print(f" Valid from: {existing_coupon.valid_from}")
|
||||
print(f" Valid until: {existing_coupon.valid_until}")
|
||||
return existing_coupon
|
||||
|
||||
# Create new coupon
|
||||
now = datetime.utcnow()
|
||||
valid_until = now + timedelta(days=180) # Valid for 6 months
|
||||
|
||||
coupon = CouponModel(
|
||||
id=uuid.uuid4(),
|
||||
code=coupon_code,
|
||||
discount_type="trial_extension",
|
||||
discount_value=90, # 90 days = 3 months
|
||||
max_redemptions=20, # First 20 pilot customers
|
||||
current_redemptions=0,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
active=True,
|
||||
created_at=now,
|
||||
extra_data={
|
||||
"program": "pilot_launch_2025",
|
||||
"description": "Programa piloto - 3 meses gratis para los primeros 20 clientes",
|
||||
"terms": "Válido para nuevos registros únicamente. Un cupón por cliente."
|
||||
}
|
||||
)
|
||||
|
||||
db.add(coupon)
|
||||
db.commit()
|
||||
db.refresh(coupon)
|
||||
|
||||
print(f"✓ Successfully created coupon: {coupon_code}")
|
||||
print(f" Type: Trial Extension")
|
||||
print(f" Value: 90 days (3 months)")
|
||||
print(f" Max redemptions: 20")
|
||||
print(f" Valid from: {coupon.valid_from}")
|
||||
print(f" Valid until: {coupon.valid_until}")
|
||||
print(f" ID: {coupon.id}")
|
||||
|
||||
return coupon
|
||||
|
||||
|
||||
def main():
|
||||
"""Main execution function"""
|
||||
print("=" * 60)
|
||||
print("Seeding PILOT2025 Coupon for Pilot Customer Program")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
try:
|
||||
# Get database session
|
||||
db = next(get_db())
|
||||
|
||||
# Seed the coupon
|
||||
seed_pilot_coupon(db)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("✓ Coupon seeding completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error seeding coupon: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user