Files
bakery-ia/services/tenant

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

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
    customer_id VARCHAR(255),             -- Stripe customer ID
    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

CREATE TABLE tenant_subscriptions (
    id UUID PRIMARY KEY,
    tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
    subscription_id VARCHAR(255) UNIQUE,
    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

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)

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

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

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

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

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

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

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(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

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.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.subscription_id = stripe_subscription.id

        # Create subscription record
        subscription = TenantSubscription(
            tenant_id=tenant.id,
            subscription_id=stripe_subscription.id,
            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

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.subscription_id)

        # Update subscription items (Stripe handles proration automatically)
        stripe_subscription = stripe.Subscription.modify(
            subscription.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

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.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.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)

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.subscription_id)
            stripe.Subscription.modify(
                subscription.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,
            subscription_id=None,  # Linked to parent, no separate billing
            customer_id=parent.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

{
    "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

{
    "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)

{
    "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)

{
    "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)

# 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

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.