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
}
}
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
| Campo | Tipo | Descripción |
|---|
period_start | datetime | Inicio del período actual (UTC) |
period_end | datetime | Fin del período actual (UTC) |
days_remaining | integer | Dí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
| Campo | Tipo | Descripción |
|---|
appointment_limit | integer | Límite de citas del plan |
appointments_used | integer | Citas creadas en el período actual |
appointments_remaining | integer | Citas disponibles (limit - used) |
usage_percent | float | Porcentaje 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
| Campo | Tipo | Descripción |
|---|
overage_enabled | boolean | Si el overage está habilitado (allow_overage) |
overage_count | integer | Citas extras sobre el límite (max(0, used - limit)) |
overage_rate | float | Precio 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
| Campo | Tipo | Descripción |
|---|
base_cost | float | Precio mensual del plan |
overage_cost | float | Costo adicional por overage (count × rate) |
total_cost | float | Costo 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
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