2025-11-06 14:10:04 +01:00
# Tenant Service
## Overview
The **Tenant Service** manages the multi-tenant SaaS architecture, handling tenant (bakery) registration, subscription management via Stripe, team member administration, and billing. It provides tenant isolation, subscription tier enforcement, usage tracking, and automated billing workflows. This service is the foundation for scaling Bakery-IA to thousands of Spanish bakeries with a sustainable SaaS revenue model.
## Key Features
### Tenant Management
- **Tenant Registration** - New bakery signup and onboarding
- **Tenant Profiles** - Business information, settings, preferences
- **Multi-Location Support** - Multiple stores per tenant
- **Tenant Isolation** - Complete data separation between tenants
- **Tenant Settings** - Configurable features per tenant
- **Tenant Branding** - Custom logos, colors (Enterprise tier)
- **Tenant Status** - Active, trial, suspended, cancelled
2025-11-30 09:12:40 +01:00
### 🆕 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)
2025-11-06 14:10:04 +01:00
### Subscription Management
- **Stripe Integration** - Full Stripe API integration
- **Subscription Tiers** - Free, Pro, Enterprise plans
- **Trial Management** - 14-day free trials
- **Upgrade/Downgrade** - Self-service plan changes
- **Proration** - Automatic prorated billing
- **Payment Methods** - Credit cards, SEPA Direct Debit (Europe)
- **Invoicing** - Automatic invoice generation
### Team Management
- **Multi-User Access** - Invite team members to tenant
- **Role-Based Access Control** - Owner, Admin, Manager, Staff
- **Permission Management** - Granular feature permissions
- **Team Member Invitations** - Email invites with expiry
- **Team Member Removal** - Revoke access
- **Activity Tracking** - Team member audit logs
### Billing & Usage
- **Usage Tracking** - API calls, storage, transactions
- **Usage Limits** - Enforce tier limits
- **Overage Handling** - Charge for overages or block access
- **Billing History** - Complete invoice history
- **Payment Status** - Track payment success/failure
- **Failed Payment Handling** - Retry logic, suspension
- **Revenue Analytics** - MRR, churn, LTV tracking
### Subscription Tiers
**Free Tier (€0/month):**
- 1 location
- 100 transactions/month
- 1 user
- Email support
- Basic features only
**Pro Tier (€49/month):**
- 3 locations
- Unlimited transactions
- 5 users
- Priority email support
- All features
- WhatsApp notifications
- Advanced analytics
**Enterprise Tier (€149/month):**
- Unlimited locations
- Unlimited transactions
- Unlimited users
- Phone + email support
- All features
- Custom branding
- Dedicated account manager
- SLA guarantee
### Compliance & Security
- **GDPR Compliance** - Data protection built-in
- **Data Residency** - EU data storage (Spain/Germany)
- **Tenant Data Export** - Complete data export capability
- **Tenant Deletion** - GDPR-compliant account deletion
- **Audit Logging** - Complete tenant activity logs
- **Security Settings** - 2FA, IP whitelist (Enterprise)
## Business Value
### For Bakery Owners
- **Predictable Pricing** - Clear monthly costs
- **Start Free** - Try before buying with 14-day trial
- **Scale as You Grow** - Upgrade when needed
- **Team Collaboration** - Invite staff with appropriate access
- **Professional Invoicing** - Automatic Spanish tax-compliant invoices
- **Easy Cancellation** - Cancel anytime, no long-term commitment
### Quantifiable Impact
- **MRR per Customer**: €0-149/month based on tier
- **Customer Acquisition Cost**: €200-300 (PPC + sales)
- **Customer Lifetime Value**: €1,200-3,600 (avg 24-month retention)
- **Churn Rate**: < 10 %/ month target ( industry: 5-15 %)
- **Expansion Revenue**: 30-40% customers upgrade within 6 months
- **Payment Success Rate**: 95%+ with Stripe
### For Platform (Bakery-IA)
- **Scalable Revenue**: Subscription model scales with customers
- **Automated Billing**: No manual invoicing needed
- **European Payments**: SEPA support for Spanish/EU customers
- **Churn Prevention**: Usage tracking enables proactive retention
- **Expansion Opportunities**: Upsell based on usage
- **Financial Visibility**: Real-time revenue metrics
## Technology Stack
- **Framework**: FastAPI (Python 3.11+) - Async web framework
- **Database**: PostgreSQL 17 - Tenant and subscription data
- **Payments**: Stripe API - Payment processing
- **Caching**: Redis 7.4 - Subscription cache
- **Messaging**: RabbitMQ 4.1 - Event publishing
- **ORM**: SQLAlchemy 2.0 (async) - Database abstraction
- **Logging**: Structlog - Structured JSON logging
- **Metrics**: Prometheus Client - Subscription metrics
## API Endpoints (Key Routes)
### Tenant Management
- `POST /api/v1/tenants` - Create new tenant (signup)
- `GET /api/v1/tenants/{tenant_id}` - Get tenant details
- `PUT /api/v1/tenants/{tenant_id}` - Update tenant
- `DELETE /api/v1/tenants/{tenant_id}` - Delete tenant (GDPR)
- `GET /api/v1/tenants/{tenant_id}/settings` - Get settings
- `PUT /api/v1/tenants/{tenant_id}/settings` - Update settings
### Subscription Management
- `GET /api/v1/tenants/{tenant_id}/subscription` - Get subscription
- `POST /api/v1/tenants/{tenant_id}/subscription` - Create subscription
- `PUT /api/v1/tenants/{tenant_id}/subscription` - Update subscription (upgrade/downgrade)
- `DELETE /api/v1/tenants/{tenant_id}/subscription` - Cancel subscription
- `POST /api/v1/tenants/{tenant_id}/subscription/reactivate` - Reactivate cancelled subscription
### Payment Methods
- `GET /api/v1/tenants/{tenant_id}/payment-methods` - List payment methods
- `POST /api/v1/tenants/{tenant_id}/payment-methods` - Add payment method
- `PUT /api/v1/tenants/{tenant_id}/payment-methods/{pm_id}/default` - Set default
- `DELETE /api/v1/tenants/{tenant_id}/payment-methods/{pm_id}` - Remove payment method
### Team Management
- `GET /api/v1/tenants/{tenant_id}/members` - List team members
- `POST /api/v1/tenants/{tenant_id}/members/invite` - Invite team member
- `PUT /api/v1/tenants/{tenant_id}/members/{member_id}` - Update member role
- `DELETE /api/v1/tenants/{tenant_id}/members/{member_id}` - Remove member
- `GET /api/v1/tenants/invitations/{invitation_token}` - Get invitation details
- `POST /api/v1/tenants/invitations/{invitation_token}/accept` - Accept invitation
2025-11-30 09:12:40 +01:00
### 🆕 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
2025-11-06 14:10:04 +01:00
### Billing & Usage
- `GET /api/v1/tenants/{tenant_id}/invoices` - List invoices
- `GET /api/v1/tenants/{tenant_id}/invoices/{invoice_id}` - Get invoice
- `GET /api/v1/tenants/{tenant_id}/usage` - Current usage statistics
- `GET /api/v1/tenants/{tenant_id}/usage/history` - Historical usage
### Stripe Webhooks
- `POST /api/v1/stripe/webhooks` - Stripe webhook receiver
### Analytics (Internal)
- `GET /api/v1/tenants/analytics/mrr` - Monthly recurring revenue
- `GET /api/v1/tenants/analytics/churn` - Churn rate
- `GET /api/v1/tenants/analytics/ltv` - Customer lifetime value
## Database Schema
### Main Tables
**tenants**
```sql
CREATE TABLE tenants (
id UUID PRIMARY KEY,
tenant_name VARCHAR(255) NOT NULL,
business_legal_name VARCHAR(255),
tax_id VARCHAR(50), -- CIF/NIF for Spanish businesses
business_type VARCHAR(100), -- bakery, pastry_shop, cafe, franchise
-- Contact
email VARCHAR(255) NOT NULL,
phone VARCHAR(50),
address_line1 VARCHAR(255),
address_line2 VARCHAR(255),
city VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(100) DEFAULT 'España',
-- Status
status VARCHAR(50) DEFAULT 'trial', -- trial, active, suspended, cancelled
trial_ends_at TIMESTAMP,
suspended_at TIMESTAMP,
suspended_reason TEXT,
cancelled_at TIMESTAMP,
cancellation_reason TEXT,
-- Subscription
subscription_tier VARCHAR(50) DEFAULT 'free', -- free, pro, enterprise
2026-01-14 13:15:48 +01:00
customer_id VARCHAR(255), -- Stripe customer ID
subscription_id VARCHAR(255), -- Stripe subscription ID
2025-11-06 14:10:04 +01:00
2025-11-30 09:12:40 +01:00
-- 🆕 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
2025-11-06 14:10:04 +01:00
-- Settings
timezone VARCHAR(50) DEFAULT 'Europe/Madrid',
language VARCHAR(10) DEFAULT 'es',
currency VARCHAR(10) DEFAULT 'EUR',
settings JSONB, -- Custom settings
-- Usage limits
max_locations INTEGER DEFAULT 1,
max_users INTEGER DEFAULT 1,
max_transactions_per_month INTEGER DEFAULT 100,
-- Branding (Enterprise only)
logo_url VARCHAR(500),
primary_color VARCHAR(10),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(email)
);
2025-11-30 09:12:40 +01:00
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);
2025-11-06 14:10:04 +01:00
```
**tenant_subscriptions**
```sql
CREATE TABLE tenant_subscriptions (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
2026-01-14 13:15:48 +01:00
subscription_id VARCHAR(255) UNIQUE,
customer_id VARCHAR(255),
2025-11-06 14:10:04 +01:00
-- Plan details
plan_tier VARCHAR(50) NOT NULL, -- free, pro, enterprise
plan_interval VARCHAR(50) DEFAULT 'month', -- month, year
plan_amount DECIMAL(10, 2) NOT NULL, -- Monthly amount in euros
-- Status
status VARCHAR(50) NOT NULL, -- active, trialing, past_due, cancelled, unpaid
trial_start TIMESTAMP,
trial_end TIMESTAMP,
current_period_start TIMESTAMP,
current_period_end TIMESTAMP,
cancel_at_period_end BOOLEAN DEFAULT FALSE,
cancelled_at TIMESTAMP,
-- Payment
default_payment_method_id VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
**tenant_members**
```sql
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
2025-11-30 09:12:40 +01:00
role VARCHAR(50) NOT NULL, -- owner, admin, manager, staff, network_admin (🆕 NEW)
2025-11-06 14:10:04 +01:00
-- Permissions
permissions JSONB, -- Granular permissions
-- Status
status VARCHAR(50) DEFAULT 'active', -- active, inactive, invited
invited_by UUID,
invited_at TIMESTAMP,
accepted_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, user_id)
);
```
2025-11-30 09:12:40 +01:00
**🆕 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);
```
2025-11-06 14:10:04 +01:00
**tenant_invitations**
```sql
CREATE TABLE tenant_invitations (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
invitation_token VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL,
invited_by UUID NOT NULL,
status VARCHAR(50) DEFAULT 'pending', -- pending, accepted, expired, cancelled
expires_at TIMESTAMP NOT NULL,
accepted_at TIMESTAMP,
accepted_by UUID,
created_at TIMESTAMP DEFAULT NOW()
);
```
**tenant_usage**
```sql
CREATE TABLE tenant_usage (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
usage_date DATE NOT NULL,
-- Usage metrics
api_calls INTEGER DEFAULT 0,
transactions_count INTEGER DEFAULT 0,
storage_mb DECIMAL(10, 2) DEFAULT 0.00,
whatsapp_messages INTEGER DEFAULT 0,
sms_messages INTEGER DEFAULT 0,
emails_sent INTEGER DEFAULT 0,
-- Costs
estimated_cost DECIMAL(10, 4) DEFAULT 0.0000,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, usage_date)
);
```
**tenant_invoices**
```sql
CREATE TABLE tenant_invoices (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
stripe_invoice_id VARCHAR(255) UNIQUE,
-- Invoice details
invoice_number VARCHAR(100),
invoice_date DATE NOT NULL,
due_date DATE,
period_start DATE,
period_end DATE,
-- Amounts
subtotal DECIMAL(10, 2) NOT NULL,
tax_amount DECIMAL(10, 2) DEFAULT 0.00,
total_amount DECIMAL(10, 2) NOT NULL,
amount_paid DECIMAL(10, 2) DEFAULT 0.00,
amount_due DECIMAL(10, 2) NOT NULL,
currency VARCHAR(10) DEFAULT 'EUR',
-- Status
status VARCHAR(50) NOT NULL, -- draft, open, paid, void, uncollectible
paid_at TIMESTAMP,
-- PDF
invoice_pdf_url VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
```
**tenant_payment_methods**
```sql
CREATE TABLE tenant_payment_methods (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
stripe_payment_method_id VARCHAR(255) UNIQUE,
payment_method_type VARCHAR(50), -- card, sepa_debit
-- Card details (if card)
card_brand VARCHAR(50),
card_last_four VARCHAR(4),
card_exp_month INTEGER,
card_exp_year INTEGER,
-- SEPA details (if sepa_debit)
sepa_last_four VARCHAR(4),
sepa_bank_code VARCHAR(50),
sepa_country VARCHAR(10),
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
```
**tenant_audit_log**
```sql
CREATE TABLE tenant_audit_log (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID,
action VARCHAR(100) NOT NULL, -- tenant_created, subscription_upgraded, member_invited, etc.
resource_type VARCHAR(100),
resource_id VARCHAR(255),
details JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_audit_tenant_date (tenant_id, created_at DESC)
);
```
### Indexes for Performance
```sql
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_subscription_tier ON tenants(subscription_tier);
2026-01-14 13:15:48 +01:00
CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(subscription_id);
2025-11-06 14:10:04 +01:00
CREATE INDEX idx_subscriptions_status ON tenant_subscriptions(tenant_id, status);
CREATE INDEX idx_members_tenant ON tenant_members(tenant_id);
CREATE INDEX idx_members_user ON tenant_members(user_id);
CREATE INDEX idx_invitations_token ON tenant_invitations(invitation_token);
CREATE INDEX idx_usage_tenant_date ON tenant_usage(tenant_id, usage_date DESC);
CREATE INDEX idx_invoices_tenant ON tenant_invoices(tenant_id, invoice_date DESC);
```
## Business Logic Examples
### Tenant Registration with Stripe
```python
async def create_tenant_with_subscription(
tenant_data: TenantCreate,
plan_tier: str = 'pro',
payment_method_id: str = None
) -> Tenant:
"""
Create new tenant and Stripe subscription.
"""
import stripe
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
# Create tenant
tenant = Tenant(
tenant_name=tenant_data.tenant_name,
business_legal_name=tenant_data.business_legal_name,
tax_id=tenant_data.tax_id,
email=tenant_data.email,
phone=tenant_data.phone,
country='España',
status='trial' if not payment_method_id else 'active',
subscription_tier=plan_tier,
trial_ends_at=datetime.utcnow() + timedelta(days=14) if not payment_method_id else None
)
# Set tier limits
if plan_tier == 'free':
tenant.max_locations = 1
tenant.max_users = 1
tenant.max_transactions_per_month = 100
elif plan_tier == 'pro':
tenant.max_locations = 3
tenant.max_users = 5
tenant.max_transactions_per_month = -1 # Unlimited
elif plan_tier == 'enterprise':
tenant.max_locations = -1 # Unlimited
tenant.max_users = -1 # Unlimited
tenant.max_transactions_per_month = -1 # Unlimited
db.add(tenant)
await db.flush()
try:
# Create Stripe customer
stripe_customer = stripe.Customer.create(
email=tenant.email,
name=tenant.business_legal_name or tenant.tenant_name,
metadata={
'tenant_id': str(tenant.id),
'tax_id': tenant.tax_id
},
tax_id_data=[{
'type': 'eu_vat', # Spanish NIF/CIF
'value': tenant.tax_id
}] if tenant.tax_id else None
)
2026-01-14 13:15:48 +01:00
tenant.customer_id = stripe_customer.id
2025-11-06 14:10:04 +01:00
# Attach payment method if provided
if payment_method_id:
stripe.PaymentMethod.attach(
payment_method_id,
customer=stripe_customer.id
)
# Set as default
stripe.Customer.modify(
stripe_customer.id,
invoice_settings={'default_payment_method': payment_method_id}
)
# Get price ID for plan
price_id = get_stripe_price_id(plan_tier, 'month')
# Create subscription
subscription_params = {
'customer': stripe_customer.id,
'items': [{'price': price_id}],
'metadata': {'tenant_id': str(tenant.id)},
'payment_behavior': 'default_incomplete' if not payment_method_id else 'allow_incomplete'
}
# Add trial if no payment method
if not payment_method_id:
subscription_params['trial_period_days'] = 14
stripe_subscription = stripe.Subscription.create(**subscription_params)
2026-01-14 13:15:48 +01:00
tenant.subscription_id = stripe_subscription.id
2025-11-06 14:10:04 +01:00
# Create subscription record
subscription = TenantSubscription(
tenant_id=tenant.id,
2026-01-14 13:15:48 +01:00
subscription_id=stripe_subscription.id,
customer_id=stripe_customer.id,
2025-11-06 14:10:04 +01:00
plan_tier=plan_tier,
plan_interval='month',
plan_amount=get_plan_amount(plan_tier),
status=stripe_subscription.status,
trial_start=datetime.fromtimestamp(stripe_subscription.trial_start) if stripe_subscription.trial_start else None,
trial_end=datetime.fromtimestamp(stripe_subscription.trial_end) if stripe_subscription.trial_end else None,
current_period_start=datetime.fromtimestamp(stripe_subscription.current_period_start),
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end)
)
db.add(subscription)
# Create owner member record
owner_user = await create_user_from_tenant(tenant_data)
member = TenantMember(
tenant_id=tenant.id,
user_id=owner_user.id,
role='owner',
status='active'
)
db.add(member)
# Log audit
audit = TenantAuditLog(
tenant_id=tenant.id,
user_id=owner_user.id,
action='tenant_created',
details={
'plan_tier': plan_tier,
'trial': not bool(payment_method_id)
}
)
db.add(audit)
await db.commit()
# Publish event
await publish_event('tenants', 'tenant.created', {
'tenant_id': str(tenant.id),
'plan_tier': plan_tier,
'trial': not bool(payment_method_id)
})
logger.info("Tenant created with subscription",
tenant_id=str(tenant.id),
plan_tier=plan_tier)
return tenant
except stripe.error.StripeError as e:
# Rollback tenant creation
await db.rollback()
logger.error("Stripe subscription creation failed",
tenant_id=str(tenant.id) if tenant.id else None,
error=str(e))
raise Exception(f"Payment processing failed: {str(e)}")
def get_stripe_price_id(plan_tier: str, interval: str) -> str:
"""
Get Stripe price ID for plan tier and billing interval.
"""
# These would be created in Stripe dashboard
price_ids = {
('pro', 'month'): 'price_pro_monthly',
('pro', 'year'): 'price_pro_yearly',
('enterprise', 'month'): 'price_enterprise_monthly',
('enterprise', 'year'): 'price_enterprise_yearly'
}
return price_ids.get((plan_tier, interval))
def get_plan_amount(plan_tier: str) -> Decimal:
"""
Get plan amount in euros.
"""
amounts = {
'free': Decimal('0.00'),
'pro': Decimal('49.00'),
'enterprise': Decimal('149.00')
}
return amounts.get(plan_tier, Decimal('0.00'))
```
### Subscription Upgrade/Downgrade
```python
async def update_subscription(
tenant_id: UUID,
new_plan_tier: str,
user_id: UUID
) -> TenantSubscription:
"""
Upgrade or downgrade tenant subscription.
"""
import stripe
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
tenant = await db.get(Tenant, tenant_id)
subscription = await db.query(TenantSubscription).filter(
TenantSubscription.tenant_id == tenant_id,
TenantSubscription.status == 'active'
).first()
if not subscription:
raise ValueError("No active subscription found")
try:
# Get new price
new_price_id = get_stripe_price_id(new_plan_tier, subscription.plan_interval)
# Update Stripe subscription
2026-01-14 13:15:48 +01:00
stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id)
2025-11-06 14:10:04 +01:00
# Update subscription items (Stripe handles proration automatically)
stripe_subscription = stripe.Subscription.modify(
2026-01-14 13:15:48 +01:00
subscription.subscription_id,
2025-11-06 14:10:04 +01:00
items=[{
'id': stripe_subscription['items']['data'][0].id,
'price': new_price_id
}],
proration_behavior='always_invoice', # Create invoice for proration
metadata={'tenant_id': str(tenant_id)}
)
# Update tenant tier
old_tier = tenant.subscription_tier
tenant.subscription_tier = new_plan_tier
# Update limits
if new_plan_tier == 'free':
tenant.max_locations = 1
tenant.max_users = 1
tenant.max_transactions_per_month = 100
elif new_plan_tier == 'pro':
tenant.max_locations = 3
tenant.max_users = 5
tenant.max_transactions_per_month = -1
elif new_plan_tier == 'enterprise':
tenant.max_locations = -1
tenant.max_users = -1
tenant.max_transactions_per_month = -1
# Update subscription record
subscription.plan_tier = new_plan_tier
subscription.plan_amount = get_plan_amount(new_plan_tier)
subscription.status = stripe_subscription.status
# Log audit
audit = TenantAuditLog(
tenant_id=tenant_id,
user_id=user_id,
action='subscription_changed',
details={
'old_tier': old_tier,
'new_tier': new_plan_tier,
'change_type': 'upgrade' if get_plan_amount(new_plan_tier) > get_plan_amount(old_tier) else 'downgrade'
}
)
db.add(audit)
await db.commit()
# Publish event
await publish_event('tenants', 'tenant.subscription_changed', {
'tenant_id': str(tenant_id),
'old_tier': old_tier,
'new_tier': new_plan_tier
})
logger.info("Subscription updated",
tenant_id=str(tenant_id),
old_tier=old_tier,
new_tier=new_plan_tier)
return subscription
except stripe.error.StripeError as e:
logger.error("Subscription update failed",
tenant_id=str(tenant_id),
error=str(e))
raise Exception(f"Subscription update failed: {str(e)}")
```
### Stripe Webhook Handler
```python
async def handle_stripe_webhook(payload: bytes, sig_header: str):
"""
Handle Stripe webhook events.
"""
import stripe
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
webhook_secret = os.getenv('STRIPE_WEBHOOK_SECRET')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
except ValueError:
raise Exception("Invalid payload")
except stripe.error.SignatureVerificationError:
raise Exception("Invalid signature")
# Handle event types
if event['type'] == 'customer.subscription.updated':
subscription = event['data']['object']
await handle_subscription_updated(subscription)
elif event['type'] == 'customer.subscription.deleted':
subscription = event['data']['object']
await handle_subscription_cancelled(subscription)
elif event['type'] == 'invoice.paid':
invoice = event['data']['object']
await handle_invoice_paid(invoice)
elif event['type'] == 'invoice.payment_failed':
invoice = event['data']['object']
await handle_payment_failed(invoice)
elif event['type'] == 'customer.subscription.trial_will_end':
subscription = event['data']['object']
await handle_trial_ending(subscription)
logger.info("Stripe webhook processed",
event_type=event['type'],
event_id=event['id'])
async def handle_subscription_updated(stripe_subscription: dict):
"""
Handle subscription update from Stripe.
"""
tenant_id = UUID(stripe_subscription['metadata'].get('tenant_id'))
subscription = await db.query(TenantSubscription).filter(
2026-01-14 13:15:48 +01:00
TenantSubscription.subscription_id == stripe_subscription['id']
2025-11-06 14:10:04 +01:00
).first()
if subscription:
subscription.status = stripe_subscription['status']
subscription.current_period_start = datetime.fromtimestamp(stripe_subscription['current_period_start'])
subscription.current_period_end = datetime.fromtimestamp(stripe_subscription['current_period_end'])
subscription.cancel_at_period_end = stripe_subscription['cancel_at_period_end']
await db.commit()
async def handle_payment_failed(stripe_invoice: dict):
"""
Handle failed payment from Stripe.
"""
customer_id = stripe_invoice['customer']
tenant = await db.query(Tenant).filter(
2026-01-14 13:15:48 +01:00
Tenant.customer_id == customer_id
2025-11-06 14:10:04 +01:00
).first()
if tenant:
# Send notification
await send_payment_failed_notification(tenant.id)
# If 3rd failed attempt, suspend account
failed_attempts = await get_failed_payment_count(tenant.id)
if failed_attempts >= 3:
tenant.status = 'suspended'
tenant.suspended_at = datetime.utcnow()
tenant.suspended_reason = 'payment_failed'
await db.commit()
await send_account_suspended_notification(tenant.id)
```
2025-11-30 09:12:40 +01:00
### 🆕 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')
2026-01-14 13:15:48 +01:00
stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id)
2025-11-30 09:12:40 +01:00
stripe.Subscription.modify(
2026-01-14 13:15:48 +01:00
subscription.subscription_id,
2025-11-30 09:12:40 +01:00
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,
2026-01-14 13:15:48 +01:00
subscription_id=None, # Linked to parent, no separate billing
customer_id=parent.customer_id, # Same customer
2025-11-30 09:12:40 +01:00
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
```
2025-11-06 14:10:04 +01:00
## Events & Messaging
### Published Events (RabbitMQ)
**Exchange**: `tenants`
**Routing Keys**: `tenant.created` , `tenant.subscription_changed` , `tenant.cancelled`
**Tenant Created Event**
```json
{
"event_type": "tenant_created",
"tenant_id": "uuid",
"tenant_name": "Panadería García",
"plan_tier": "pro",
"trial": false,
"timestamp": "2025-11-06T10:00:00Z"
}
```
**Subscription Changed Event**
```json
{
"event_type": "tenant_subscription_changed",
"tenant_id": "uuid",
"old_tier": "pro",
"new_tier": "enterprise",
"change_type": "upgrade",
"timestamp": "2025-11-06T14:00:00Z"
}
```
2025-11-30 09:12:40 +01:00
**🆕 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"
}
```
2025-11-06 14:10:04 +01:00
## Custom Metrics (Prometheus)
```python
# Tenant metrics
tenants_total = Gauge(
'tenants_total',
'Total tenants',
['status', 'subscription_tier']
)
monthly_recurring_revenue_euros = Gauge(
'monthly_recurring_revenue_euros',
'Total MRR',
[]
)
churn_rate_percentage = Gauge(
'churn_rate_percentage_monthly',
'Monthly churn rate',
[]
)
trial_conversion_rate = Gauge(
'trial_conversion_rate_percentage',
'Trial to paid conversion rate',
[]
)
```
## Configuration
### Environment Variables
**Service Configuration:**
- `PORT` - Service port (default: 8017)
- `DATABASE_URL` - PostgreSQL connection string
- `REDIS_URL` - Redis connection string
- `RABBITMQ_URL` - RabbitMQ connection string
**Stripe Configuration:**
- `STRIPE_SECRET_KEY` - Stripe secret key
- `STRIPE_PUBLISHABLE_KEY` - Stripe publishable key
- `STRIPE_WEBHOOK_SECRET` - Stripe webhook signing secret
- `STRIPE_PRICE_PRO_MONTHLY` - Pro plan monthly price ID
- `STRIPE_PRICE_ENTERPRISE_MONTHLY` - Enterprise plan monthly price ID
**Trial Configuration:**
- `DEFAULT_TRIAL_DAYS` - Free trial length (default: 14)
- `TRIAL_REMINDER_DAYS` - Days before trial ends to remind (default: 3)
## Development Setup
### Prerequisites
- Python 3.11+
- PostgreSQL 17
- Redis 7.4
- RabbitMQ 4.1
- Stripe account
### Local Development
```bash
cd services/tenant
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
export DATABASE_URL=postgresql://user:pass@localhost:5432/tenant
export REDIS_URL=redis://localhost:6379/0
export RABBITMQ_URL=amqp://guest:guest@localhost:5672/
export STRIPE_SECRET_KEY=sk_test_your_key
export STRIPE_WEBHOOK_SECRET=whsec_your_secret
alembic upgrade head
python main.py
```
## Integration Points
### Dependencies
- **Stripe API** - Payment processing
- **Auth Service** - User management
- **PostgreSQL** - Tenant data
- **Redis** - Subscription caching
- **RabbitMQ** - Event publishing
### Dependents
- **All Services** - Tenant authentication and limits
- **Frontend Dashboard** - Subscription management UI
- **Billing** - Invoice generation
## Business Value for VUE Madrid
### Problem Statement
Manual billing and customer management doesn't scale:
- Manual invoicing time-consuming and error-prone
- No automated subscription management
- Difficult to track MRR and churn
- Complex European payment regulations (SEPA, VAT)
- No self-service tier changes
### Solution
Bakery-IA Tenant Service provides:
- **Automated Billing**: Stripe handles everything
- **European Payments**: SEPA Direct Debit for Spanish/EU
- **Self-Service**: Customers manage subscriptions
- **Revenue Visibility**: Real-time MRR, churn, LTV
- **Scalable**: Handle thousands of customers
### Quantifiable Impact
**Revenue Model:**
- €0-149/month per customer (€66 average)
- €60K/year at 100 customers
- €360K/year at 500 customers
- €1.8M/year at 2,000 customers
**Business Metrics:**
- 30-40% customers upgrade within 6 months
- 14-day trial → 35-45% conversion rate (industry standard)
- < 10 % monthly churn ( target )
- €1,200-3,600 customer LTV (24-month retention)
**Operational Efficiency:**
- 100% automated billing (zero manual invoicing)
- 95%+ payment success rate (Stripe)
- Self-service reduces support by 70%
### Target Market Fit (Spanish Bakeries)
- **SEPA Support**: Direct debit popular in Spain/EU
- **Spanish Invoicing**: Tax-compliant invoices automatic
- **Euro Currency**: Native euro pricing, no conversion
- **Affordable**: €49/month accessible for SMBs
- **Transparent**: Clear pricing, no hidden fees
### ROI for Platform
**Investment**: Stripe fees 1.4% + €0.25/transaction (EU cards)
**Customer Acquisition Cost**: €200-300
**Payback Period**: 3-5 months (at €66 average MRR)
**Annual Value**: €66 × 12 = €792/customer/year
**3-Year LTV**: €2,376/customer (assuming retention)
---
**Copyright © 2025 Bakery-IA. All rights reserved.**