Skip to main content

Introducción

El monitoreo de uso permite a las clínicas consultar en tiempo real cuántas citas han creado en el período actual, cuántas quedan disponibles, y el costo estimado del mes considerando overage.

Endpoint REST API

GET /v1/subscription/usage

Devuelve estadísticas de uso detalladas para el período de facturación actual. Autenticación: Requiere token JWT válido (cookie access_token). Query Parameters:
  • clinic_id (UUID, required): ID de la clínica
Response Schema (UsageResponse):
{
  "period_start": "2026-01-01T00:00:00Z",
  "period_end": "2026-02-01T00:00:00Z",
  "days_remaining": 16,
  "plan_name": "STARTER",
  "plan_display_name": "Starter Plan",
  "appointment_limit": 50,
  "appointments_used": 35,
  "appointments_remaining": 15,
  "usage_percent": 70.0,
  "overage_enabled": true,
  "overage_count": 0,
  "overage_rate": 0.35,
  "conversations_count": 12,
  "estimated_monthly_cost": {
    "base_cost": 29.0,
    "overage_cost": 0.0,
    "total_cost": 29.0
  }
}
Gráfico de uso del plan mostrando citas consumidas, restantes y proyección de costos

Ejemplo de Uso

cURL

curl -X GET "https://api.sonrisafeliz.com/v1/subscription/usage?clinic_id=abc-123-def-456" \
  -H "Cookie: access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -H "Accept: application/json"

Python

import httpx
from uuid import UUID
from typing import TypedDict

class EstimatedCost(TypedDict):
    base_cost: float
    overage_cost: float
    total_cost: float

class UsageResponse(TypedDict):
    period_start: str
    period_end: str
    days_remaining: int
    plan_name: str
    plan_display_name: str
    appointment_limit: int
    appointments_used: int
    appointments_remaining: int
    usage_percent: float
    overage_enabled: bool
    overage_count: int
    overage_rate: float
    conversations_count: int
    estimated_monthly_cost: EstimatedCost

async def get_usage_stats(clinic_id: UUID, access_token: str) -> UsageResponse:
    """
    Obtiene estadísticas de uso de la clínica.

    Args:
        clinic_id: UUID de la clínica
        access_token: Token JWT de autenticación

    Returns:
        UsageResponse: Estadísticas de uso detalladas
    """
    async with httpx.AsyncClient() as client:
        response = await client.get(
            "https://api.sonrisafeliz.com/v1/subscription/usage",
            params={"clinic_id": str(clinic_id)},
            cookies={"access_token": access_token},
        )
        response.raise_for_status()
        return response.json()

# Ejemplo de uso
usage = await get_usage_stats(
    clinic_id=UUID("abc-123-def-456"),
    access_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
)

print(f"Plan: {usage['plan_name']}")
print(f"Uso: {usage['appointments_used']}/{usage['appointment_limit']} ({usage['usage_percent']:.1f}%)")
print(f"Quedan: {usage['appointments_remaining']} citas")
print(f"Días restantes: {usage['days_remaining']}")

if usage['overage_count'] > 0:
    print(f"⚠️ Overage: {usage['overage_count']} citas × €{usage['overage_rate']} = €{usage['estimated_monthly_cost']['overage_cost']:.2f}")

print(f"💰 Costo estimado: €{usage['estimated_monthly_cost']['total_cost']:.2f}")

JavaScript/TypeScript

interface EstimatedCost {
  base_cost: number;
  overage_cost: number;
  total_cost: number;
}

interface UsageResponse {
  period_start: string;
  period_end: string;
  days_remaining: number;
  plan_name: string;
  plan_display_name: string;
  appointment_limit: number;
  appointments_used: number;
  appointments_remaining: number;
  usage_percent: number;
  overage_enabled: boolean;
  overage_count: number;
  overage_rate: number;
  conversations_count: number;
  estimated_monthly_cost: EstimatedCost;
}

async function getUsageStats(
  clinicId: string,
  accessToken: string
): Promise<UsageResponse> {
  const response = await fetch(
    `https://api.sonrisafeliz.com/v1/subscription/usage?clinic_id=${clinicId}`,
    {
      method: 'GET',
      credentials: 'include',
      headers: {
        'Accept': 'application/json',
        'Cookie': `access_token=${accessToken}`,
      },
    }
  );

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  }

  return response.json();
}

// Ejemplo de uso
const usage = await getUsageStats(
  'abc-123-def-456',
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
);

console.log(`Plan: ${usage.plan_name}`);
console.log(`Uso: ${usage.appointments_used}/${usage.appointment_limit} (${usage.usage_percent.toFixed(1)}%)`);
console.log(`Quedan: ${usage.appointments_remaining} citas`);
console.log(`Días restantes: ${usage.days_remaining}`);

if (usage.overage_count > 0) {
  console.log(`⚠️ Overage: ${usage.overage_count} citas × €${usage.overage_rate} = €${usage.estimated_monthly_cost.overage_cost.toFixed(2)}`);
}

console.log(`💰 Costo estimado: €${usage.estimated_monthly_cost.total_cost.toFixed(2)}`);

Interpretación de Campos

Período de Facturación

CampoTipoDescripción
period_startdatetimeInicio del período actual (UTC)
period_enddatetimeFin del período actual (UTC)
days_remainingintegerDías hasta el próximo período
Cálculo de días restantes:
days_remaining = (period_end - datetime.now(timezone.utc)).days

Uso de Citas

CampoTipoDescripción
appointment_limitintegerLímite de citas del plan
appointments_usedintegerCitas creadas en el período actual
appointments_remainingintegerCitas disponibles (limit - used)
usage_percentfloatPorcentaje de uso (0-100+)
Cálculo de porcentaje de uso:
usage_percent = (appointments_used / appointment_limit) * 100
# Ejemplo: 35 / 50 * 100 = 70.0%
usage_percent puede ser mayor a 100% si overage está habilitado y la clínica excedió el límite.

Overage

CampoTipoDescripción
overage_enabledbooleanSi el overage está habilitado (allow_overage)
overage_countintegerCitas extras sobre el límite (max(0, used - limit))
overage_ratefloatPrecio por cita extra
Cálculo de overage:
overage_count = max(0, appointments_used - appointment_limit)
# Ejemplo: max(0, 65 - 50) = 15 citas en overage

Costo Estimado

CampoTipoDescripción
base_costfloatPrecio mensual del plan
overage_costfloatCosto adicional por overage (count × rate)
total_costfloatCosto total estimado del mes
Cálculo de costo estimado:
base_cost = plan.price_monthly
overage_cost = overage_count * plan.overage_rate if overage_enabled else 0
total_cost = base_cost + overage_cost

# Ejemplo:
# base_cost = 29.0 (STARTER)
# overage_count = 15
# overage_rate = 0.35
# overage_cost = 15 × 0.35 = 5.25
# total_cost = 29.0 + 5.25 = 34.25

Servicio de Suscripciones

El sistema incluye SubscriptionService.check_appointment_limit() para verificaciones de límite:
from app.services.subscription_service import SubscriptionService

service = SubscriptionService(db)

# Verificar si puede crear una cita nueva
limit_check = await service.check_appointment_limit(clinic_id)

print(f"Límite: {limit_check['limit']}")
print(f"Uso actual: {limit_check['current_count']}")
print(f"¿Excedido?: {limit_check['over_limit']}")
print(f"¿Overage permitido?: {limit_check['overage_allowed']}")

if limit_check['over_limit']:
    if limit_check['overage_allowed']:
        print(f"✅ Overage activo - Cargo adicional: €{limit_check['overage_cost']:.2f}")
    else:
        print("❌ Límite alcanzado - Upgrade requerido")
else:
    remaining = limit_check['limit'] - limit_check['current_count']
    print(f"✅ {remaining} citas disponibles")
Response Schema (check_appointment_limit):
{
    "limit": 50,                      # Límite del plan
    "current_count": 35,              # Citas usadas
    "over_limit": False,              # Si excedió el límite
    "overage_allowed": True,          # Si overage habilitado
    "overage_count": 0,               # Citas en overage
    "overage_cost": 0.0,              # Costo overage
}

Casos de Uso Comunes

1. Dashboard de Uso (Widget)

Componente Frontend: Widget de dashboard que muestra estadísticas de uso en tiempo real. Elementos visuales:
  • Progress circular: Muestra porcentaje de uso (0-100%)
    • Verde: uso normal (<80%)
    • Naranja: cerca del límite (80-100%)
    • Rojo: overage activo (>100%)
  • Estadísticas principales:
    • Citas usadas / límite
    • Citas disponibles o count de overage
    • Días restantes en período
  • Alerta de overage (si aplica):
    • Fondo naranja con borde
    • Cálculo: [overage_count] × €[overage_rate] = €[total]
  • Costo estimado:
    • Costo base del plan
    • Costo de overage (si aplica)
    • Total estimado del mes
Implementación: Usar GET /v1/subscription/usage en useEffect con polling cada 30-60s.

2. Alerta de Límite Próximo

async def check_and_alert_near_limit(clinic_id: UUID, db: AsyncSession) -> None:
    """
    Envía alerta si la clínica está cerca del límite de citas.

    Umbrales:
    - 80%: Alerta amarilla (advertencia)
    - 100%: Alerta roja (límite alcanzado, overage activo)
    """
    service = SubscriptionService(db)
    subscription = await service.get_subscription_for_clinic(clinic_id)

    usage_percent = (subscription.appointments_billed / subscription.appointment_limit) * 100

    if usage_percent >= 100 and not subscription.allow_overage:
        # Límite alcanzado, overage NO habilitado
        await send_email_alert(
            clinic_id=clinic_id,
            subject="🚨 Límite de citas alcanzado",
            template="limit_reached.html",
            context={
                "plan_name": subscription.plan_display_name,
                "limit": subscription.appointment_limit,
                "used": subscription.appointments_billed,
            },
        )
    elif usage_percent >= 100 and subscription.allow_overage:
        # Límite excedido, overage activo
        overage_count = subscription.appointments_billed - subscription.appointment_limit
        overage_cost = overage_count * subscription.overage_rate

        await send_email_alert(
            clinic_id=clinic_id,
            subject="⚠️ Overage activo en tu plan",
            template="overage_active.html",
            context={
                "plan_name": subscription.plan_display_name,
                "overage_count": overage_count,
                "overage_cost": overage_cost,
            },
        )
    elif usage_percent >= 80:
        # Advertencia - cerca del límite
        remaining = subscription.appointment_limit - subscription.appointments_billed

        await send_email_alert(
            clinic_id=clinic_id,
            subject="📊 Cerca del límite de citas",
            template="near_limit.html",
            context={
                "plan_name": subscription.plan_display_name,
                "usage_percent": usage_percent,
                "remaining": remaining,
            },
        )

3. Exportar Reporte de Uso

import csv
from datetime import datetime, timezone
from io import StringIO

async def export_usage_report(clinic_id: UUID, db: AsyncSession) -> str:
    """
    Genera reporte CSV de uso mensual.

    Returns:
        str: CSV content
    """
    service = SubscriptionService(db)
    subscription = await service.get_subscription_for_clinic(clinic_id)

    # Calcular métricas
    usage_percent = (subscription.appointments_billed / subscription.appointment_limit) * 100
    overage_count = max(0, subscription.appointments_billed - subscription.appointment_limit)
    overage_cost = overage_count * subscription.overage_rate if subscription.allow_overage else 0
    total_cost = subscription.price_monthly + overage_cost

    # Generar CSV
    output = StringIO()
    writer = csv.writer(output)

    # Header
    writer.writerow([
        "Período",
        "Plan",
        "Límite",
        "Usadas",
        "Disponibles",
        "Uso %",
        "Overage",
        "Costo Base",
        "Costo Overage",
        "Total",
    ])

    # Data
    writer.writerow([
        f"{subscription.billing_period_start.date()} - {subscription.billing_period_end.date()}",
        subscription.plan_display_name,
        subscription.appointment_limit,
        subscription.appointments_billed,
        max(0, subscription.appointment_limit - subscription.appointments_billed),
        f"{usage_percent:.1f}%",
        overage_count if subscription.allow_overage else "N/A",
        f"€{subscription.price_monthly:.2f}",
        f"€{overage_cost:.2f}" if subscription.allow_overage else "N/A",
        f"€{total_cost:.2f}",
    ])

    return output.getvalue()

# Ejemplo de uso
csv_content = await export_usage_report(clinic_id, db)

# Guardar a archivo
with open(f"usage_report_{datetime.now().date()}.csv", "w", encoding="utf-8") as f:
    f.write(csv_content)

4. Predicción de Fin de Mes

from datetime import datetime, timezone

async def predict_end_of_month_usage(clinic_id: UUID, db: AsyncSession) -> dict:
    """
    Predice uso de citas al final del mes basándose en tendencia actual.

    Returns:
        dict: {
            "current_usage": int,
            "days_elapsed": int,
            "days_total": int,
            "daily_average": float,
            "predicted_total": int,
            "predicted_overage": int,
            "predicted_cost": float,
        }
    """
    service = SubscriptionService(db)
    subscription = await service.get_subscription_for_clinic(clinic_id)

    now = datetime.now(timezone.utc)
    period_start = subscription.billing_period_start
    period_end = subscription.billing_period_end

    # Calcular días
    days_elapsed = (now - period_start).days
    days_total = (period_end - period_start).days

    if days_elapsed == 0:
        return {
            "current_usage": subscription.appointments_billed,
            "days_elapsed": 0,
            "days_total": days_total,
            "daily_average": 0.0,
            "predicted_total": subscription.appointments_billed,
            "predicted_overage": 0,
            "predicted_cost": subscription.price_monthly,
        }

    # Calcular promedio diario
    daily_average = subscription.appointments_billed / days_elapsed

    # Proyectar a fin de mes
    predicted_total = int(daily_average * days_total)

    # Calcular overage predicho
    predicted_overage = max(0, predicted_total - subscription.appointment_limit)
    predicted_overage_cost = predicted_overage * subscription.overage_rate if subscription.allow_overage else 0
    predicted_cost = subscription.price_monthly + predicted_overage_cost

    return {
        "current_usage": subscription.appointments_billed,
        "days_elapsed": days_elapsed,
        "days_total": days_total,
        "daily_average": daily_average,
        "predicted_total": predicted_total,
        "predicted_overage": predicted_overage,
        "predicted_cost": predicted_cost,
    }

# Ejemplo de uso
prediction = await predict_end_of_month_usage(clinic_id, db)

print(f"Uso actual: {prediction['current_usage']} citas")
print(f"Promedio diario: {prediction['daily_average']:.1f} citas/día")
print(f"Proyección fin de mes: {prediction['predicted_total']} citas")

if prediction['predicted_overage'] > 0:
    print(f"⚠️ Overage predicho: {prediction['predicted_overage']} citas")
    print(f"💰 Costo estimado: €{prediction['predicted_cost']:.2f}")

Reset de Contador

El contador appointments_billed se resetea automáticamente al inicio de cada nuevo período de facturación:

Webhook invoice.paid

# app/api/v1/stripe.py

async def _handle_invoice_paid(
    invoice: stripe.Invoice,
    db: AsyncSession,
):
    """
    Procesa factura pagada (renovación de período).

    Flow:
    1. Buscar subscription por stripe_subscription_id
    2. Fetch nueva información de período desde Stripe
    3. UPDATE billing_period_start y billing_period_end
    4. RESET appointments_billed a 0
    5. RESET conversations_count a 0
    """
    subscription_id = invoice.subscription

    # Buscar subscription
    subscription = await db.scalar(
        select(ClinicSubscription).where(
            ClinicSubscription.stripe_subscription_id == subscription_id
        )
    )

    if not subscription:
        raise ValueError(f"No subscription found with stripe_subscription_id={subscription_id}")

    # Fetch subscription desde Stripe para obtener nuevo período
    stripe_sub = stripe.Subscription.retrieve(subscription_id)
    new_period_start, new_period_end = calculate_billing_period(stripe_sub)

    # Actualizar período y resetear contadores
    subscription.billing_period_start = new_period_start
    subscription.billing_period_end = new_period_end
    subscription.appointments_billed = 0  # ✅ RESET
    subscription.conversations_count = 0  # ✅ RESET

    await db.commit()
El reset de appointments_billed es automático. No requiere intervención manual.

Errores Comunes

404 Not Found

{
  "detail": "No subscription found for clinic"
}
Causa: La clínica no tiene suscripción configurada. Solución: Ejecutar script de setup:
python scripts/setup_subscriptions.py --batch --plan FREE --yes

403 Forbidden

{
  "detail": "No access to clinic abc-123-def-456"
}
Causa: El usuario autenticado no tiene acceso a la clínica especificada. Solución: Verificar que el clinic_id en el token JWT coincide con el clinic_id del query parameter.

Próximos Pasos

Referencias