- 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>
485 lines
15 KiB
Python
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()
|