New enterprise feature
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user