1314 lines
45 KiB
Markdown
1314 lines
45 KiB
Markdown
# 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
|
||
|
||
### 🆕 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
|
||
- **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
|
||
|
||
### 🆕 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
|
||
- `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
|
||
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',
|
||
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)
|
||
);
|
||
|
||
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**
|
||
```sql
|
||
CREATE TABLE tenant_subscriptions (
|
||
id UUID PRIMARY KEY,
|
||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||
stripe_subscription_id VARCHAR(255) UNIQUE,
|
||
stripe_customer_id VARCHAR(255),
|
||
|
||
-- 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
|
||
role VARCHAR(50) NOT NULL, -- owner, admin, manager, staff, network_admin (🆕 NEW)
|
||
|
||
-- 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)
|
||
);
|
||
```
|
||
|
||
**🆕 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 (
|
||
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);
|
||
CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(stripe_subscription_id);
|
||
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
|
||
)
|
||
|
||
tenant.stripe_customer_id = stripe_customer.id
|
||
|
||
# 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)
|
||
|
||
tenant.stripe_subscription_id = stripe_subscription.id
|
||
|
||
# Create subscription record
|
||
subscription = TenantSubscription(
|
||
tenant_id=tenant.id,
|
||
stripe_subscription_id=stripe_subscription.id,
|
||
stripe_customer_id=stripe_customer.id,
|
||
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
|
||
stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id)
|
||
|
||
# Update subscription items (Stripe handles proration automatically)
|
||
stripe_subscription = stripe.Subscription.modify(
|
||
subscription.stripe_subscription_id,
|
||
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(
|
||
TenantSubscription.stripe_subscription_id == stripe_subscription['id']
|
||
).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(
|
||
Tenant.stripe_customer_id == customer_id
|
||
).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)
|
||
```
|
||
|
||
### 🆕 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)
|
||
|
||
**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"
|
||
}
|
||
```
|
||
|
||
**🆕 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
|
||
# 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.**
|