Files
orion/app/services/vendor_team_service.py
Samir Boulahtit 9d8d5e7138 feat: add subscription and billing system with Stripe integration
- Add database models for subscription tiers, vendor subscriptions,
  add-ons, billing history, and webhook events
- Implement BillingService for subscription operations
- Implement StripeService for Stripe API operations
- Implement StripeWebhookHandler for webhook event processing
- Add vendor billing API endpoints for subscription management
- Create vendor billing page with Alpine.js frontend
- Add limit enforcement for products and team members
- Add billing exceptions for proper error handling
- Create comprehensive unit tests (40 tests passing)
- Add subscription billing documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:29:44 +01:00

485 lines
15 KiB
Python

# app/services/vendor_team_service.py
"""
Vendor team management service.
Handles:
- Team member invitations
- Invitation acceptance
- Role assignment
- Permission management
"""
import logging
import secrets
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy.orm import Session
from app.core.permissions import get_preset_permissions
from app.exceptions import (
CannotRemoveOwnerException,
InvalidInvitationTokenException,
TeamInvitationAlreadyAcceptedException,
TeamMemberAlreadyExistsException,
UserNotFoundException,
)
from app.services.subscription_service import TierLimitExceededException
from middleware.auth import AuthManager
from models.database.user import User
from models.database.vendor import Role, Vendor, VendorUser, VendorUserType
logger = logging.getLogger(__name__)
class VendorTeamService:
"""Service for managing vendor team members."""
def __init__(self):
self.auth_manager = AuthManager()
def invite_team_member(
self,
db: Session,
vendor: Vendor,
inviter: User,
email: str,
role_name: str,
custom_permissions: list[str] | None = None,
) -> dict[str, Any]:
"""
Invite a new team member to a vendor.
Creates:
1. User account (if doesn't exist)
2. Role (if custom permissions provided)
3. VendorUser relationship with invitation token
Args:
db: Database session
vendor: Vendor to invite to
inviter: User sending the invitation
email: Email of person to invite
role_name: Role name (manager, staff, support, etc.)
custom_permissions: Optional custom permissions (overrides preset)
Returns:
Dict with invitation details
"""
try:
# Check team size limit from subscription
from app.services.subscription_service import subscription_service
subscription_service.check_team_limit(db, vendor.id)
# Check if user already exists
user = db.query(User).filter(User.email == email).first()
if user:
# Check if already a member
existing_membership = (
db.query(VendorUser)
.filter(
VendorUser.vendor_id == vendor.id,
VendorUser.user_id == user.id,
)
.first()
)
if existing_membership:
if existing_membership.is_active:
raise TeamMemberAlreadyExistsException(
email, vendor.vendor_code
)
# Reactivate old membership
existing_membership.is_active = (
False # Will be activated on acceptance
)
existing_membership.invitation_token = (
self._generate_invitation_token()
)
existing_membership.invitation_sent_at = datetime.utcnow()
existing_membership.invitation_accepted_at = None
db.flush()
logger.info(
f"Re-invited user {email} to vendor {vendor.vendor_code}"
)
return {
"invitation_token": existing_membership.invitation_token,
"email": email,
"existing_user": True,
}
else:
# Create new user account (inactive until invitation accepted)
username = email.split("@")[0]
# Ensure unique username
base_username = username
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{base_username}{counter}"
counter += 1
# Generate temporary password (user will set real one on activation)
temp_password = secrets.token_urlsafe(16)
user = User(
email=email,
username=username,
hashed_password=self.auth_manager.hash_password(temp_password),
role="vendor", # Platform role
is_active=False, # Will be activated when invitation accepted
is_email_verified=False,
)
db.add(user)
db.flush() # Get user.id
logger.info(f"Created new user account for invitation: {email}")
# Get or create role
role = self._get_or_create_role(
db=db,
vendor=vendor,
role_name=role_name,
custom_permissions=custom_permissions,
)
# Create vendor membership with invitation
invitation_token = self._generate_invitation_token()
vendor_user = VendorUser(
vendor_id=vendor.id,
user_id=user.id,
user_type=VendorUserType.TEAM_MEMBER.value,
role_id=role.id,
invited_by=inviter.id,
invitation_token=invitation_token,
invitation_sent_at=datetime.utcnow(),
is_active=False, # Will be activated on acceptance
)
db.add(vendor_user)
db.flush()
logger.info(
f"Invited {email} to vendor {vendor.vendor_code} "
f"as {role_name} by {inviter.username}"
)
# TODO: Send invitation email
# self._send_invitation_email(email, vendor, invitation_token)
return {
"invitation_token": invitation_token,
"email": email,
"role": role_name,
"existing_user": user.is_active,
}
except (TeamMemberAlreadyExistsException, TierLimitExceededException):
raise
except Exception as e:
logger.error(f"Error inviting team member: {str(e)}")
raise
def accept_invitation(
self,
db: Session,
invitation_token: str,
password: str,
first_name: str | None = None,
last_name: str | None = None,
) -> dict[str, Any]:
"""
Accept a team invitation and activate account.
Args:
db: Database session
invitation_token: Invitation token from email
password: New password to set
first_name: Optional first name
last_name: Optional last name
Returns:
Dict with user and vendor info
"""
try:
# Find invitation
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.invitation_token == invitation_token,
)
.first()
)
if not vendor_user:
raise InvalidInvitationTokenException()
# Check if already accepted
if vendor_user.invitation_accepted_at is not None:
raise TeamInvitationAlreadyAcceptedException()
# Check token expiration (7 days)
if vendor_user.invitation_sent_at:
expiry_date = vendor_user.invitation_sent_at + timedelta(days=7)
if datetime.utcnow() > expiry_date:
raise InvalidInvitationTokenException("Invitation has expired")
user = vendor_user.user
vendor = vendor_user.vendor
# Update user
user.hashed_password = self.auth_manager.hash_password(password)
user.is_active = True
user.is_email_verified = True
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
# Activate membership
vendor_user.is_active = True
vendor_user.invitation_accepted_at = datetime.utcnow()
vendor_user.invitation_token = None # Clear token
db.flush()
logger.info(
f"User {user.email} accepted invitation to vendor {vendor.vendor_code}"
)
return {
"user": user,
"vendor": vendor,
"role": vendor_user.role.name if vendor_user.role else "member",
}
except (
InvalidInvitationTokenException,
TeamInvitationAlreadyAcceptedException,
):
raise
except Exception as e:
logger.error(f"Error accepting invitation: {str(e)}")
raise
def remove_team_member(
self,
db: Session,
vendor: Vendor,
user_id: int,
) -> bool:
"""
Remove a team member from a vendor.
Cannot remove owner.
Args:
db: Database session
vendor: Vendor to remove from
user_id: User ID to remove
Returns:
True if removed
"""
try:
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.vendor_id == vendor.id,
VendorUser.user_id == user_id,
)
.first()
)
if not vendor_user:
raise UserNotFoundException(str(user_id))
# Cannot remove owner
if vendor_user.is_owner:
raise CannotRemoveOwnerException(vendor.vendor_code)
# Soft delete - just deactivate
vendor_user.is_active = False
logger.info(f"Removed user {user_id} from vendor {vendor.vendor_code}")
return True
except (UserNotFoundException, CannotRemoveOwnerException):
raise
except Exception as e:
logger.error(f"Error removing team member: {str(e)}")
raise
def update_member_role(
self,
db: Session,
vendor: Vendor,
user_id: int,
new_role_name: str,
custom_permissions: list[str] | None = None,
) -> VendorUser:
"""
Update a team member's role.
Args:
db: Database session
vendor: Vendor
user_id: User ID
new_role_name: New role name
custom_permissions: Optional custom permissions
Returns:
Updated VendorUser
"""
try:
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.vendor_id == vendor.id,
VendorUser.user_id == user_id,
)
.first()
)
if not vendor_user:
raise UserNotFoundException(str(user_id))
# Cannot change owner's role
if vendor_user.is_owner:
raise CannotRemoveOwnerException(vendor.vendor_code)
# Get or create new role
new_role = self._get_or_create_role(
db=db,
vendor=vendor,
role_name=new_role_name,
custom_permissions=custom_permissions,
)
vendor_user.role_id = new_role.id
db.flush()
logger.info(
f"Updated role for user {user_id} in vendor {vendor.vendor_code} "
f"to {new_role_name}"
)
return vendor_user
except (UserNotFoundException, CannotRemoveOwnerException):
raise
except Exception as e:
logger.error(f"Error updating member role: {str(e)}")
raise
def get_team_members(
self,
db: Session,
vendor: Vendor,
include_inactive: bool = False,
) -> list[dict[str, Any]]:
"""
Get all team members for a vendor.
Args:
db: Database session
vendor: Vendor
include_inactive: Include inactive members
Returns:
List of team member info
"""
query = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor.id,
)
if not include_inactive:
query = query.filter(VendorUser.is_active == True)
vendor_users = query.all()
members = []
for vu in vendor_users:
members.append(
{
"id": vu.user.id,
"email": vu.user.email,
"username": vu.user.username,
"full_name": vu.user.full_name,
"user_type": vu.user_type,
"role": vu.role.name if vu.role else "owner",
"permissions": vu.get_all_permissions(),
"is_active": vu.is_active,
"is_owner": vu.is_owner,
"invitation_pending": vu.is_invitation_pending,
"invited_at": vu.invitation_sent_at,
"accepted_at": vu.invitation_accepted_at,
}
)
return members
# Private helper methods
def _generate_invitation_token(self) -> str:
"""Generate a secure invitation token."""
return secrets.token_urlsafe(32)
def _get_or_create_role(
self,
db: Session,
vendor: Vendor,
role_name: str,
custom_permissions: list[str] | None = None,
) -> Role:
"""Get existing role or create new one with preset/custom permissions."""
# Try to find existing role with same name
role = (
db.query(Role)
.filter(
Role.vendor_id == vendor.id,
Role.name == role_name,
)
.first()
)
if role and custom_permissions is None:
# Use existing role
return role
# Determine permissions
if custom_permissions:
permissions = custom_permissions
else:
# Get preset permissions
permissions = list(get_preset_permissions(role_name))
if role:
# Update existing role with new permissions
role.permissions = permissions
else:
# Create new role
role = Role(
vendor_id=vendor.id,
name=role_name,
permissions=permissions,
)
db.add(role)
db.flush()
return role
def _send_invitation_email(self, email: str, vendor: Vendor, token: str):
"""Send invitation email (TODO: implement)."""
# TODO: Implement email sending
# Should include:
# - Link to accept invitation: /vendor/invitation/accept?token={token}
# - Vendor name
# - Inviter name
# - Expiry date
# Create service instance
vendor_team_service = VendorTeamService()