New enterprise feature

This commit is contained in:
Urtzi Alfaro
2025-11-30 09:12:40 +01:00
parent f9d0eec6ec
commit 972db02f6d
176 changed files with 19741 additions and 1361 deletions

View File

@@ -15,6 +15,33 @@ The **Tenant Service** manages the multi-tenant SaaS architecture, handling tena
- **Tenant Branding** - Custom logos, colors (Enterprise tier)
- **Tenant Status** - Active, trial, suspended, cancelled
### 🆕 Enterprise Tier: Tenant Hierarchy Management (NEW)
- **Parent-Child Architecture** - Central production facilities (parents) coordinate multiple retail outlets (children)
- **Hierarchy Path Tracking** - Materialized path for efficient hierarchy queries (e.g., "parent_id.child_id")
- **Tenant Types** - Three types: standalone (single bakery), parent (central bakery), child (retail outlet)
- **Self-Referential Relationships** - SQLAlchemy parent-child relationships with cascade controls
- **Circular Hierarchy Prevention** - Database check constraints prevent invalid parent assignments
- **Network Admin Role** - Special role with full access across parent + all children
- **Hierarchical Access Control** - Parent admins view aggregated metrics from children (privacy-preserving)
### 🆕 Multi-Location Enterprise Support (NEW)
- **TenantLocation Model** - Separate physical locations with geo-coordinates
- **Location Types** - central_production (parent depot), retail_outlet (child stores)
- **Delivery Windows** - Configurable time windows per location for distribution scheduling
- **Operational Hours** - Business hours tracking per location
- **Capacity Tracking** - Production capacity (kg/day) for central facilities, storage capacity for outlets
- **Contact Information** - Location-specific contact person, phone, email
- **Delivery Radius** - Maximum delivery distance from central production (default 50km)
- **Schedule Configuration** - Per-location delivery day preferences (e.g., "Mon,Wed,Fri")
### 🆕 Enterprise Upgrade Path (NEW)
- **In-Place Upgrade** - Convert existing Professional tier tenant to Enterprise parent
- **Central Production Setup** - Automatic creation of central_production location on upgrade
- **Child Outlet Onboarding** - API endpoints for adding retail outlets to parent network
- **Settings Inheritance** - Child tenants inherit configurations from parent with override capability
- **Subscription Linking** - Child subscriptions automatically linked to parent billing
- **Quota Management** - Enforce maximum child tenants per parent (50 for Enterprise tier)
### Subscription Management
- **Stripe Integration** - Full Stripe API integration
- **Subscription Tiers** - Free, Pro, Enterprise plans
@@ -145,6 +172,16 @@ The **Tenant Service** manages the multi-tenant SaaS architecture, handling tena
- `GET /api/v1/tenants/invitations/{invitation_token}` - Get invitation details
- `POST /api/v1/tenants/invitations/{invitation_token}/accept` - Accept invitation
### 🆕 Enterprise Hierarchy Management (NEW)
- `POST /api/v1/tenants/{tenant_id}/upgrade-to-enterprise` - Upgrade tenant to Enterprise parent
- `POST /api/v1/tenants/{parent_id}/add-child-outlet` - Add child outlet to parent network
- `GET /api/v1/tenants/{tenant_id}/hierarchy` - Get tenant hierarchy information
- `GET /api/v1/users/{user_id}/tenant-hierarchy` - Get all tenants user can access (organized hierarchically)
- `GET /api/v1/tenants/{tenant_id}/locations` - List physical locations for tenant
- `POST /api/v1/tenants/{tenant_id}/locations` - Add new location (central_production or retail_outlet)
- `PUT /api/v1/tenants/{tenant_id}/locations/{location_id}` - Update location details
- `DELETE /api/v1/tenants/{tenant_id}/locations/{location_id}` - Remove location
### Billing & Usage
- `GET /api/v1/tenants/{tenant_id}/invoices` - List invoices
- `GET /api/v1/tenants/{tenant_id}/invoices/{invoice_id}` - Get invoice
@@ -194,6 +231,16 @@ CREATE TABLE tenants (
stripe_customer_id VARCHAR(255), -- Stripe customer ID
stripe_subscription_id VARCHAR(255), -- Stripe subscription ID
-- 🆕 Enterprise hierarchy fields (NEW)
parent_tenant_id UUID REFERENCES tenants(id) ON DELETE RESTRICT,
-- NULL for standalone/parent, set for children
tenant_type VARCHAR(50) DEFAULT 'standalone' NOT NULL,
-- standalone, parent, child
hierarchy_path VARCHAR(500), -- Materialized path (e.g., "parent_id.child_id")
CONSTRAINT chk_no_self_parent CHECK (id != parent_tenant_id),
-- Prevent circular hierarchy
-- Settings
timezone VARCHAR(50) DEFAULT 'Europe/Madrid',
language VARCHAR(10) DEFAULT 'es',
@@ -213,6 +260,10 @@ CREATE TABLE tenants (
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(email)
);
CREATE INDEX idx_tenants_parent_tenant_id ON tenants(parent_tenant_id);
CREATE INDEX idx_tenants_tenant_type ON tenants(tenant_type);
CREATE INDEX idx_tenants_hierarchy_path ON tenants(hierarchy_path);
```
**tenant_subscriptions**
@@ -251,7 +302,7 @@ CREATE TABLE tenant_members (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL, -- Link to auth service user
role VARCHAR(50) NOT NULL, -- owner, admin, manager, staff
role VARCHAR(50) NOT NULL, -- owner, admin, manager, staff, network_admin (🆕 NEW)
-- Permissions
permissions JSONB, -- Granular permissions
@@ -268,6 +319,51 @@ CREATE TABLE tenant_members (
);
```
**🆕 tenant_locations (NEW - Enterprise Tier)**
```sql
CREATE TABLE tenant_locations (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE NOT NULL,
-- Location identification
name VARCHAR(200) NOT NULL, -- E.g., "Central Bakery Madrid", "Outlet Barcelona"
location_type VARCHAR(50) NOT NULL, -- central_production, retail_outlet
-- Address
address TEXT NOT NULL,
city VARCHAR(100) DEFAULT 'Madrid',
postal_code VARCHAR(10) NOT NULL,
latitude FLOAT, -- GPS coordinates for routing
longitude FLOAT,
-- Capacity and operational config
capacity INTEGER, -- Production capacity (kg/day) or storage capacity
max_delivery_radius_km FLOAT DEFAULT 50.0, -- Maximum delivery distance from this location
operational_hours JSONB, -- {"monday": "06:00-20:00", ...}
delivery_windows JSONB, -- {"monday": "08:00-12:00,14:00-18:00", ...}
delivery_schedule_config JSONB, -- {"delivery_days": "Mon,Wed,Fri", "time_window": "07:00-10:00"}
-- Contact information
contact_person VARCHAR(200),
contact_phone VARCHAR(20),
contact_email VARCHAR(255),
-- Status
is_active BOOLEAN DEFAULT TRUE,
-- Metadata
metadata_ JSONB, -- Custom location metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_tenant_locations_tenant_id ON tenant_locations(tenant_id);
CREATE INDEX idx_tenant_locations_type ON tenant_locations(location_type);
CREATE INDEX idx_tenant_locations_active ON tenant_locations(is_active);
CREATE INDEX idx_tenant_locations_tenant_type ON tenant_locations(tenant_id, location_type);
```
**tenant_invitations**
```sql
CREATE TABLE tenant_invitations (
@@ -768,6 +864,249 @@ async def handle_payment_failed(stripe_invoice: dict):
await send_account_suspended_notification(tenant.id)
```
### 🆕 Enterprise Upgrade with Hierarchy Setup (NEW)
```python
async def upgrade_tenant_to_enterprise(
tenant_id: UUID,
location_data: dict,
user_id: UUID
) -> Tenant:
"""
Upgrade existing tenant to Enterprise tier with parent-child hierarchy support.
This workflow:
1. Verifies tenant can be upgraded (Professional tier)
2. Updates tenant to 'parent' type
3. Creates central_production location
4. Updates Stripe subscription to enterprise tier
5. Sets hierarchy_path for future children
"""
# Get existing tenant
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise ValueError("Tenant not found")
# Verify current tier allows upgrade
if tenant.subscription_tier not in ['pro', 'professional']:
raise ValueError("Only Professional tier tenants can be upgraded to Enterprise")
try:
# 1. Update tenant to parent type
tenant.tenant_type = 'parent'
tenant.hierarchy_path = str(tenant_id) # Root of hierarchy
tenant.subscription_tier = 'enterprise'
# 2. Create central production location
central_location = TenantLocation(
tenant_id=tenant_id,
name=location_data.get('location_name', 'Central Production Facility'),
location_type='central_production',
address=location_data.get('address', tenant.address_line1),
city=location_data.get('city', tenant.city),
postal_code=location_data.get('postal_code', tenant.postal_code),
latitude=location_data.get('latitude'),
longitude=location_data.get('longitude'),
capacity=location_data.get('production_capacity_kg', 1000),
is_active=True
)
db.add(central_location)
# 3. Update Stripe subscription to enterprise tier
subscription = await db.query(TenantSubscription).filter(
TenantSubscription.tenant_id == tenant_id,
TenantSubscription.status == 'active'
).first()
if subscription:
new_price_id = get_stripe_price_id('enterprise', subscription.plan_interval)
import stripe
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id)
stripe.Subscription.modify(
subscription.stripe_subscription_id,
items=[{
'id': stripe_subscription['items']['data'][0].id,
'price': new_price_id
}],
proration_behavior='always_invoice',
metadata={'tenant_id': str(tenant_id), 'upgraded_to_enterprise': True}
)
subscription.plan_tier = 'enterprise'
subscription.plan_amount = get_plan_amount('enterprise')
# 4. Update tenant limits for enterprise
tenant.max_locations = -1 # Unlimited locations
tenant.max_users = -1 # Unlimited users
tenant.max_transactions_per_month = -1 # Unlimited
# 5. Log upgrade event
audit = TenantAuditLog(
tenant_id=tenant_id,
user_id=user_id,
action='enterprise_upgrade',
details={
'previous_type': 'standalone',
'new_type': 'parent',
'central_location_id': str(central_location.id),
'production_capacity_kg': central_location.capacity
}
)
db.add(audit)
await db.commit()
# 6. Publish upgrade event
await publish_event('tenants', 'tenant.upgraded_to_enterprise', {
'tenant_id': str(tenant_id),
'tenant_type': 'parent',
'central_location_id': str(central_location.id)
})
logger.info("Tenant upgraded to enterprise",
tenant_id=str(tenant_id),
location_id=str(central_location.id))
return tenant
except Exception as e:
await db.rollback()
logger.error("Enterprise upgrade failed",
tenant_id=str(tenant_id),
error=str(e))
raise
async def add_child_outlet_to_parent(
parent_id: UUID,
child_data: dict,
user_id: UUID
) -> Tenant:
"""
Add a new child outlet to an enterprise parent tenant.
This creates:
1. New child tenant linked to parent
2. Retail outlet location for the child
3. Child subscription inheriting from parent
4. Settings copied from parent with overrides
"""
# Verify parent tenant
parent = await db.get(Tenant, parent_id)
if not parent or parent.tenant_type != 'parent':
raise ValueError("Parent tenant not found or not enterprise type")
# Check child quota (max 50 for enterprise)
child_count = await db.query(Tenant).filter(
Tenant.parent_tenant_id == parent_id
).count()
if child_count >= 50:
raise ValueError("Maximum number of child outlets (50) reached")
try:
# 1. Create child tenant
child_tenant = Tenant(
tenant_name=child_data['name'],
subdomain=child_data['subdomain'],
business_type=parent.business_type,
business_model=parent.business_model,
email=child_data.get('email', parent.email),
phone=child_data.get('phone', parent.phone),
address_line1=child_data['address'],
city=child_data.get('city', parent.city),
postal_code=child_data['postal_code'],
country='España',
parent_tenant_id=parent_id,
tenant_type='child',
hierarchy_path=f"{parent.hierarchy_path}.{uuid.uuid4()}",
owner_id=parent.owner_id, # Same owner as parent
status='active',
subscription_tier='enterprise', # Inherits from parent
is_active=True
)
db.add(child_tenant)
await db.flush() # Get child_tenant.id
# 2. Create retail outlet location
retail_location = TenantLocation(
tenant_id=child_tenant.id,
name=f"Outlet - {child_data['name']}",
location_type='retail_outlet',
address=child_data['address'],
city=child_data.get('city', parent.city),
postal_code=child_data['postal_code'],
latitude=child_data.get('latitude'),
longitude=child_data.get('longitude'),
delivery_windows=child_data.get('delivery_windows'),
delivery_schedule_config={
'delivery_days': child_data.get('delivery_days', 'Mon,Wed,Fri'),
'time_window': '07:00-10:00'
},
is_active=True
)
db.add(retail_location)
# 3. Create linked subscription (child shares parent subscription)
child_subscription = TenantSubscription(
tenant_id=child_tenant.id,
stripe_subscription_id=None, # Linked to parent, no separate billing
stripe_customer_id=parent.stripe_customer_id, # Same customer
plan_tier='enterprise',
plan_interval='month',
plan_amount=Decimal('0.00'), # No additional charge
status='active'
)
db.add(child_subscription)
# 4. Copy owner as member of child tenant
child_member = TenantMember(
tenant_id=child_tenant.id,
user_id=parent.owner_id,
role='admin', # Parent owner becomes admin of child
status='active'
)
db.add(child_member)
# 5. Log event
audit = TenantAuditLog(
tenant_id=parent_id,
user_id=user_id,
action='child_outlet_added',
details={
'child_tenant_id': str(child_tenant.id),
'child_name': child_data['name'],
'retail_location_id': str(retail_location.id)
}
)
db.add(audit)
await db.commit()
# 6. Publish event
await publish_event('tenants', 'tenant.child_outlet_added', {
'parent_tenant_id': str(parent_id),
'child_tenant_id': str(child_tenant.id),
'child_name': child_data['name'],
'location_id': str(retail_location.id)
})
logger.info("Child outlet added to parent",
parent_id=str(parent_id),
child_id=str(child_tenant.id))
return child_tenant
except Exception as e:
await db.rollback()
logger.error("Failed to add child outlet",
parent_id=str(parent_id),
error=str(e))
raise
```
## Events & Messaging
### Published Events (RabbitMQ)
@@ -799,6 +1138,34 @@ async def handle_payment_failed(stripe_invoice: dict):
}
```
**🆕 Tenant Upgraded to Enterprise Event (NEW)**
```json
{
"event_type": "tenant_upgraded_to_enterprise",
"tenant_id": "c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8",
"tenant_type": "parent",
"tenant_name": "Panadería Central - Obrador Madrid",
"central_location_id": "uuid",
"previous_type": "standalone",
"upgrade_timestamp": "2025-11-28T10:00:00Z"
}
```
**🆕 Child Outlet Added Event (NEW)**
```json
{
"event_type": "tenant_child_outlet_added",
"parent_tenant_id": "c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8",
"child_tenant_id": "d4e5f6a7-b8c9-410d-e2f3-a4b5c6d7e8f9",
"child_name": "Outlet Barcelona Gràcia",
"location_id": "uuid",
"location_type": "retail_outlet",
"latitude": 41.3874,
"longitude": 2.1686,
"timestamp": "2025-11-28T11:00:00Z"
}
```
## Custom Metrics (Prometheus)
```python