Skip to content

API de Administração

Endpoints exclusivos para usuários com user_type='admin' para gerenciar usuários, subscriptions, webhook logs e estatísticas.


👥 Gestão de Usuários (STI)

A API utiliza Single Table Inheritance (STI) para usuários. Todos são armazenados na collection users, diferenciados por user_type:

Endpoint user_type Descrição
GET /api/v1/users admin Lista apenas administradores
GET /api/v1/customers customer Lista apenas clientes

GET /api/v1/users - Listar Admins

Buscar usuários administradores (apenas user_type='admin').

Headers:

Authorization: Bearer <admin_token>
Content-Type: application/json

Query Parameters:

Parâmetro Tipo Descrição
query str Query MongoDB em JSON
current_page int Página atual (default: 0)
qty_docs_page int Itens por página (default: 10)

Response (200 OK):

{
  "docs": [
    {
      "_id": "507f1f77bcf86cd799439011",
      "username": "admin_john",
      "email": "john@admin.com",
      "user_type": "admin",
      "name": "John Doe",
      "avatar_id": null,
      "enable": true,
      "created_at": "2026-01-01T00:00:00Z"
    }
  ],
  "links": [
    {"link_type": "GET", "rel": "self", "href": "..."},
    {"link_type": "GET", "rel": "get document", "href": ".../{id}"},
    {"link_type": "DELETE", "rel": "delete document", "href": ".../{id}"},
    {"link_type": "POST", "rel": "insert document", "href": "..."},
    {"link_type": "PUT", "rel": "update document", "href": ".../{id}"}
  ],
  "msg": "ok",
  "pagination": {
    "current_page": 0,
    "qty_docs_page": 10,
    "qty_of_pages": 1,
    "qty_total_docs": 1
  }
}

POST /api/v1/users - Criar Admin

Criar novo usuário administrador.

Request:

[{
  "username": "new_admin",
  "email": "admin@example.com",
  "password": "SecurePassword123!",
  "name": "New Admin",
  "user_type": "admin"
}]

PUT /api/v1/users - Atualizar Admin

DELETE /api/v1/users - Deletar Admin


🔑 Alterar Senha (Admin)

Use o endpoint dedicado POST /api/v1/auth/change-password para alterar a senha.

Este endpoint funciona para Admin e Customer. Não requer o hash da senha armazenado no banco — apenas a senha atual e a nova senha.

Endpoint antigo descontinuado

O padrão anterior de enviar PUT /api/v1/my-user com o objeto password contendo hash, password e new_password está descontinuado. Use POST /api/v1/auth/change-password conforme documentado abaixo.

Fluxo de Alteração de Senha

sequenceDiagram
    participant Frontend
    participant API
    participant MongoDB

    Frontend->>API: POST /auth/change-password
    Note over Frontend: Authorization: Bearer <admin_token>
    API->>MongoDB: Buscar admin pelo token
    API->>API: Verificar senha atual (Argon2)
    alt Senha atual correta
        API->>API: Hash nova senha (Argon2)
        API->>MongoDB: Atualizar password hash
        API-->>Frontend: 200 OK
    else Senha atual incorreta
        API-->>Frontend: 400 Bad Request
    end

Request

POST /api/v1/auth/change-password
Authorization: Bearer eyJhbGci...
Content-Type: application/json

{
  "current_password": "senha_atual_123",
  "new_password": "nova_senha_segura_456"
}
Campo Tipo Obrigatório Descrição
current_password string Sim Senha atual em texto plano
new_password string Sim Nova senha (mínimo 8 caracteres)

Response (200 OK)

{
  "message": "Password changed successfully",
  "password_reset_required": false
}

Errors

400 - Senha Atual Incorreta

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Current password is incorrect",
    "field": "password"
  }
}

400 - Nova Senha Muito Curta

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Password must be at least 8 characters",
    "field": "new_password"
  }
}

Exemplo Flutter/Dart

import 'dart:convert';
import 'package:http/http.dart' as http;

class AdminPasswordService {
    final String baseUrl;
    final String token;

    AdminPasswordService({required this.baseUrl, required this.token});

    Future<void> changePassword({
        required String currentPassword,
        required String newPassword,
    }) async {
        final response = await http.post(
            Uri.parse('$baseUrl/api/v1/auth/change-password'),
            headers: {
                'Authorization': 'Bearer $token',
                'Content-Type': 'application/json',
            },
            body: jsonEncode({
                'current_password': currentPassword,
                'new_password': newPassword,
            }),
        );

        if (response.statusCode != 200) {
            final error = jsonDecode(response.body);
            throw Exception(
                error['error']?['message'] ?? 'Falha ao alterar senha',
            );
        }
    }
}

Para mais detalhes sobre autenticação e fluxo de reset de senha obrigatório (usuários migrados), consulte API de Autenticação.


✏️ PUT /api/v1/my-user — Alterar Dados Pessoais (Admin)

Atualizar dados pessoais do administrador autenticado.

Todos os campos são opcionais. Envie apenas os campos que deseja alterar.

Request

PUT /api/v1/my-user
Authorization: Bearer eyJhbGci...
Content-Type: application/json

[{
  "name": "John Doe Updated",
  "email": "john.new@admin.com"
}]

Campos Editáveis

Campo Tipo Validação Descrição
username string 3-50 chars, único Nome de usuário
email string Email válido, único Email
name string 3-200 chars Nome de exibição
avatar_id ObjectId ID válido do GridFS Avatar

Response (200 OK)

{
  "docs": [{
    "_id": "507f1f77bcf86cd799439011",
    "username": "admin_john",
    "email": "john.new@admin.com",
    "name": "John Doe Updated",
    "updated_at": "2026-01-26T15:45:00Z"
  }],
  "info": {},
  "links": [
    {"link_type": "GET", "rel": "self", "href": "..."},
    {"link_type": "GET", "rel": "get document", "href": ".../{id}"},
    {"link_type": "DELETE", "rel": "delete document", "href": ".../{id}"},
    {"link_type": "POST", "rel": "insert document", "href": "..."},
    {"link_type": "PUT", "rel": "update document", "href": ".../{id}"}
  ],
  "msg": "ok",
  "pagination": {
    "current_page": 0,
    "qty_docs_page": 1,
    "qty_of_pages": 1,
    "qty_total_docs": 1
  }
}

Errors

409 - Username Já Existe

{
  "error": {
    "code": "DUPLICATE_KEY",
    "message": "Username already exists",
    "field": "username"
  }
}

409 - Email Já Existe

{
  "error": {
    "code": "DUPLICATE_KEY",
    "message": "Email already exists",
    "field": "email"
  }
}

Exemplo Flutter/Dart

import 'dart:convert';
import 'package:http/http.dart' as http;

class AdminProfileService {
    final String baseUrl;
    final String token;

    AdminProfileService({required this.baseUrl, required this.token});

    Future<Map<String, dynamic>> updateProfile({
        String? name,
        String? email,
        String? username,
        String? avatarId,
    }) async {
        final payload = <String, dynamic>{};
        if (name != null) payload['name'] = name;
        if (email != null) payload['email'] = email;
        if (username != null) payload['username'] = username;
        if (avatarId != null) payload['avatar_id'] = avatarId;

        final response = await http.put(
            Uri.parse('$baseUrl/api/v1/my-user'),
            headers: {
                'Authorization': 'Bearer $token',
                'Content-Type': 'application/json',
            },
            body: jsonEncode([payload]),
        );

        if (response.statusCode != 200) {
            final error = jsonDecode(response.body);
            throw Exception(
                error['error']?['message'] ?? 'Falha ao atualizar perfil',
            );
        }

        final data = jsonDecode(response.body);
        return data['docs'][0] as Map<String, dynamic>;
    }
}

// Uso:
// final service = AdminProfileService(
//  baseUrl: 'https://fdplay-api.infraifd.com',
//  token: token,
// );
// final updatedAdmin = await service.updateProfile(
//  name: 'John Doe Updated',
//  email: 'john.new@admin.com',
// );

🔐 Autenticação Admin

Todos os endpoints nesta seção exigem:

  1. Token JWT válido - Authorization: Bearer <token>
  2. user_type='admin' - Verificado automaticamente

Resposta 403 para não-admins:

{
  "detail": "Admin access required"
}

Gestao de Assinaturas — Rotas por Gateway

O sistema possui rotas admin separadas para cada gateway de pagamento. O frontend deve exibir menus distintos:

Menu Prefixo Gateway
Stripe /api/v1/admin/stripe/subscriptions Subscriptions com gateway='stripe'
Asaas /api/v1/admin/asaas/subscriptions Subscriptions com gateway='asaas'

Rotas Genericas vs Gateway-Specific

As rotas genericas em /api/v1/subscriptions continuam funcionando para listagem e edicao manual. As rotas gateway-specific em /admin/stripe/ e /admin/asaas/ sao recomendadas para operacoes que interagem com a API do gateway (cancelar, estornar, consultar pagamentos).


Admin Stripe

Prefixo: /api/v1/admin/stripe

Metodo Endpoint Descricao
GET /admin/stripe/subscriptions Listar subscriptions Stripe
GET /admin/stripe/subscriptions/stats Estatisticas Stripe
POST /admin/stripe/subscriptions/{id}/cancel Cancelar via Stripe API
GET /admin/stripe/subscriptions/{id}/payments Historico de pagamentos (charges reais)
POST /admin/stripe/subscriptions/{id}/refund Estornar pagamento Stripe
GET /admin/stripe/subscriptions/{id}/refunds Historico de estornos

GET /admin/stripe/subscriptions

Lista subscriptions com gateway='stripe'.

GET /api/v1/admin/stripe/subscriptions
Authorization: Bearer <admin_token>

Mesmos query parameters (query, current_page, qty_docs_page, sort).

GET /admin/stripe/subscriptions/stats

Estatisticas agregadas de subscriptions Stripe.

GET /api/v1/admin/stripe/subscriptions/stats
Authorization: Bearer <admin_token>

Response (200 OK):

{
  "gateway": "stripe",
  "total_subscriptions": 270,
  "active": 230,
  "suspended": 15,
  "cancelled": 20,
  "pending": 5,
  "expired": 0,
  "monthly_recurring_revenue": 685700,
  "average_subscription_value": 2981
}

POST /admin/stripe/subscriptions/{id}/cancel

Cancelar subscription via Stripe API (cancelamento imediato, diferente do cancelamento do customer que e no fim do periodo).

POST /api/v1/admin/stripe/subscriptions/507f1f77bcf86cd799439011/cancel
Authorization: Bearer <admin_token>

Processamento:

  1. Valida que gateway == 'stripe' (rejeita outros gateways com 422)
  2. Chama StripeClient.cancel_subscription(stripe_subscription_id, at_period_end=False)
  3. Atualiza status='cancelled' no MongoDB
  4. Limpa current_subscription_id do customer

Response (200 OK):

{
  "message": "Stripe subscription cancelled by admin",
  "subscription_id": "507f1f77bcf86cd799439011",
  "customer_id": "507f1f77bcf86cd799439012",
  "gateway": "stripe",
  "stripe_subscription_id": "sub_1RChxWG6Qr6aGiCI",
  "cancelled_at": "2026-03-01T15:00:00Z",
  "gateway_response": {"status": "canceled"}
}

GET /admin/stripe/subscriptions/{id}/payments

Historico de pagamentos reais via Stripe API (charges do customer).

GET /api/v1/admin/stripe/subscriptions/507f1f77bcf86cd799439011/payments
Authorization: Bearer <admin_token>

Response (200 OK):

{
  "subscription_id": "507f1f77bcf86cd799439011",
  "gateway": "stripe",
  "stripe_subscription_id": "sub_1RChxWG6Qr6aGiCI",
  "stripe_customer_id": "cus_RU4xxxxxxx",
  "payments": [
    {
      "id": "ch_3RChxWG6Qr6aGiCI",
      "payment_intent": "pi_3RChxWG6Qr6aGiCI",
      "status": "succeeded",
      "amount": 2990,
      "amount_refunded": 0,
      "refunded": false,
      "currency": "brl",
      "created": 1709312400,
      "description": "Subscription creation"
    }
  ]
}

Dados reais

Os gateways retornam dados reais: Stripe via Charge.list(customer=cus_xxx), com fallback para webhook_logs.

POST /admin/stripe/subscriptions/{id}/refund

Estornar pagamento Stripe.

POST /api/v1/admin/stripe/subscriptions/507f1f77bcf86cd799439011/refund
Authorization: Bearer <admin_token>
Content-Type: application/json

{
  "reason": "Customer requested refund"
}
Campo Tipo Obrigatorio Descricao
payment_intent_id string Nao Stripe PaymentIntent ID (pi_xxx). Se omitido, auto-detecta o charge mais recente nao estornado
reason string Sim Motivo do reembolso (5-500 chars)

Auto-deteccao: Se payment_intent_id nao for fornecido, o sistema:

  1. Busca stripe_customer_id do customer
  2. Lista os 10 charges mais recentes via Charge.list()
  3. Seleciona o primeiro charge com status='succeeded' e refunded=false
  4. Usa o payment_intent desse charge

Response (200 OK):

{
  "message": "Stripe refund processed successfully",
  "subscription_id": "507f1f77bcf86cd799439011",
  "gateway": "stripe",
  "payment_intent_id": "pi_3RChxWG6Qr6aGiCI",
  "refund_amount": 2990,
  "gateway_response": {"id": "re_xxx", "status": "succeeded"},
  "reason": "Customer requested refund",
  "refunded_at": "2026-03-01T15:00:00Z",
  "admin": "admin_john"
}

GET /admin/stripe/subscriptions/{id}/refunds

Historico completo de estornos e chargebacks de uma subscription Stripe.

GET /api/v1/admin/stripe/subscriptions/507f1f77bcf86cd799439011/refunds
Authorization: Bearer <admin_token>

Response (200 OK):

{
  "subscription_id": "507f1f77bcf86cd799439011",
  "gateway": "stripe",
  "total_refunded": 2990,
  "has_chargeback": false,
  "chargeback_count": 0,
  "refunds": [
    {
      "event_id": "REFUND-507f1f77bcf86cd799439011-20260301150000",
      "type": "ADMIN.REFUND.STRIPE_REFUND",
      "payment_intent_id": "pi_3RChxWG6Qr6aGiCI",
      "reason": "Customer requested refund",
      "admin": "admin_john",
      "processed_at": "2026-03-01T15:00:00Z"
    }
  ],
  "chargebacks": []
}

Admin Asaas

Prefixo: /api/v1/admin/asaas

Metodo Endpoint Descricao
GET /admin/asaas/subscriptions Listar subscriptions Asaas
GET /admin/asaas/subscriptions/stats Estatisticas Asaas
POST /admin/asaas/subscriptions/{id}/cancel Cancelar via Asaas API
GET /admin/asaas/subscriptions/{id}/payments Historico de pagamentos (pagamentos reais)
POST /admin/asaas/subscriptions/{id}/refund Estornar pagamento Asaas
GET /admin/asaas/subscriptions/{id}/refunds Historico de estornos

GET /admin/asaas/subscriptions

Lista subscriptions com gateway='asaas'.

GET /api/v1/admin/asaas/subscriptions
Authorization: Bearer <admin_token>

Mesmos query parameters (query, current_page, qty_docs_page, sort).

GET /admin/asaas/subscriptions/stats

Estatisticas agregadas de subscriptions Asaas.

GET /api/v1/admin/asaas/subscriptions/stats
Authorization: Bearer <admin_token>

Response (200 OK):

{
  "gateway": "asaas",
  "total_subscriptions": 150,
  "active": 120,
  "suspended": 10,
  "cancelled": 15,
  "pending": 5,
  "expired": 0,
  "monthly_recurring_revenue": 358800,
  "average_subscription_value": 2990
}

POST /admin/asaas/subscriptions/{id}/cancel

Cancelar subscription via Asaas API (cancelamento imediato).

POST /api/v1/admin/asaas/subscriptions/507f1f77bcf86cd799439011/cancel
Authorization: Bearer <admin_token>

Processamento:

  1. Valida que gateway == 'asaas' (rejeita outros gateways com 422)
  2. Chama AsaasClient.cancel_subscription(asaas_subscription_id)
  3. Atualiza status='cancelled' no MongoDB
  4. Limpa current_subscription_id do customer

Response (200 OK):

{
  "message": "Asaas subscription cancelled by admin",
  "subscription_id": "507f1f77bcf86cd799439011",
  "customer_id": "507f1f77bcf86cd799439012",
  "gateway": "asaas",
  "asaas_subscription_id": "sub_abc123",
  "cancelled_at": "2026-03-01T15:00:00Z",
  "gateway_response": {"deleted": true}
}

GET /admin/asaas/subscriptions/{id}/payments

Historico de pagamentos reais via Asaas API (pagamentos da subscription).

GET /api/v1/admin/asaas/subscriptions/507f1f77bcf86cd799439011/payments
Authorization: Bearer <admin_token>

Response (200 OK):

{
  "subscription_id": "507f1f77bcf86cd799439011",
  "gateway": "asaas",
  "asaas_subscription_id": "sub_abc123",
  "asaas_customer_id": "cus_000012345678",
  "payments": [
    {
      "id": "pay_abc123",
      "status": "RECEIVED",
      "billing_type": "CREDIT_CARD",
      "value": 29.90,
      "net_value": 27.41,
      "due_date": "2026-03-01",
      "payment_date": "2026-03-01",
      "description": "Assinatura mensal",
      "refunded": false,
      "refunded_value": 0
    }
  ]
}

POST /admin/asaas/subscriptions/{id}/refund

Estornar pagamento Asaas.

POST /api/v1/admin/asaas/subscriptions/507f1f77bcf86cd799439011/refund
Authorization: Bearer <admin_token>
Content-Type: application/json

{
  "reason": "Customer requested refund"
}
Campo Tipo Obrigatorio Descricao
payment_id string Nao Asaas Payment ID (pay_xxx). Se omitido, auto-detecta o pagamento mais recente nao estornado
value float Nao Valor a estornar em reais (None = reembolso total)
reason string Sim Motivo do reembolso (5-500 chars)

Auto-deteccao: Se payment_id nao for fornecido, o sistema:

  1. Busca asaas_subscription_id da subscription
  2. Lista os 10 pagamentos mais recentes via AsaasClient.list_subscription_payments()
  3. Seleciona o primeiro pagamento com status='RECEIVED' ou 'CONFIRMED' e sem refundedValue
  4. Usa o id desse pagamento

Response (200 OK):

{
  "message": "Asaas refund processed successfully",
  "subscription_id": "507f1f77bcf86cd799439011",
  "gateway": "asaas",
  "payment_id": "pay_abc123",
  "refund_amount_centavos": 2990,
  "gateway_response": {"status": "REFUNDED"},
  "reason": "Customer requested refund",
  "refunded_at": "2026-03-01T15:00:00Z",
  "admin": "admin_john"
}

GET /admin/asaas/subscriptions/{id}/refunds

Historico completo de estornos e chargebacks de uma subscription Asaas.

GET /api/v1/admin/asaas/subscriptions/507f1f77bcf86cd799439011/refunds
Authorization: Bearer <admin_token>

Response (200 OK):

{
  "subscription_id": "507f1f77bcf86cd799439011",
  "gateway": "asaas",
  "total_refunded": 2990,
  "has_chargeback": false,
  "chargeback_count": 0,
  "refunds": [
    {
      "event_id": "REFUND-507f1f77bcf86cd799439011-20260301150000",
      "type": "ADMIN.REFUND.ASAAS_REFUND",
      "payment_id": "pay_abc123",
      "reason": "Customer requested refund",
      "admin": "admin_john",
      "processed_at": "2026-03-01T15:00:00Z"
    }
  ],
  "chargebacks": []
}

Rotas Genericas (Todos os Gateways)

As rotas abaixo continuam funcionando para todos os gateways (Stripe e Asaas):

Metodo Endpoint Descricao
GET /subscriptions Listar todas as subscriptions
PUT /subscriptions Editar subscription manualmente
GET /subscriptions/stats Estatisticas globais
GET /subscriptions/{id}/payments Historico de pagamentos (generico)
POST /subscriptions/{id}/cancel Cancelar (generico — despacha por gateway)
POST /subscriptions/{id}/refund Estornar (generico — despacha por gateway)
GET /subscriptions/{id}/refunds Historico de estornos
POST /subscriptions/{id}/chargeback Gerenciar chargeback

Recomendacao

Para cancelamento e estorno, prefira usar as rotas gateway-specific (/admin/stripe/... ou /admin/asaas/...). Elas validam o gateway antes de operar e evitam operacoes acidentais no gateway errado.


Chargeback / Disputa

POST /api/v1/subscriptions/{id}/chargeback — Gerenciar Chargeback

Suspender ou resolver manualmente um chargeback/disputa em uma subscription (funciona para ambos os gateways).

Headers:

Authorization: Bearer <admin_token>
Content-Type: application/json

Path Parameters:

Parametro Tipo Descricao
id OID ID da subscription (MongoDB)

Request Body:

{
  "action": "suspend",
  "reason": "Chargeback recebido do emissor do cartao",
  "dispute_id": "DIS_ABC123"
}
Campo Tipo Obrigatorio Descricao
action string Sim "suspend" ou "resolve"
reason string Sim Motivo (5-500 caracteres)
dispute_id string Nao ID da disputa no gateway (referencia)

Acoes:

Acao Efeito
suspend Suspende subscription, marca has_chargeback=True, incrementa chargeback_count, bloqueia acesso do customer
resolve Marca has_chargeback=False (NAO reativa automaticamente)

Resolve nao reativa automaticamente

A acao resolve apenas limpa o flag has_chargeback. Para reativar a subscription, use PUT /api/v1/subscriptions para alterar o status manualmente.


Fluxo Completo: Chargeback

sequenceDiagram
    participant Gateway as Stripe/Asaas
    participant Webhook
    participant DB
    participant Admin
    participant API

    Gateway->>Webhook: PAYMENT.CHARGEBACK / charge.dispute.created
    Webhook->>DB: Suspender subscription
    Webhook->>DB: Bloquear customer
    Note over DB: status=suspended<br/>has_chargeback=true<br/>current_subscription_id=null

    Admin->>API: GET /admin/{gateway}/subscriptions/{id}/refunds
    API-->>Admin: Historico de chargebacks

    alt Disputa vencida pelo merchant
        Admin->>API: POST /subscriptions/{id}/chargeback<br/>action=resolve
        API->>DB: has_chargeback=false
        Admin->>API: PUT /subscriptions<br/>status=active
        API->>DB: Reativar subscription
    else Disputa perdida
        Admin->>API: POST /admin/{gateway}/subscriptions/{id}/cancel
        API->>DB: Cancelar subscription
    end

📝 Logs de Webhooks

GET /api/v1/webhook-logs - Listar Logs

Buscar logs de webhooks recebidos dos gateways.

Headers:

Authorization: Bearer <admin_token>
Content-Type: application/json

Query Parameters:

Parâmetro Tipo Descrição
event_type str Filtrar por tipo: SUBSCRIPTION.UPDATED, PAYMENT.PAID, etc.
signature_valid bool Filtrar por validade de assinatura
processed bool Filtrar por status de processamento
start_date str (ISO) Data de início (received_at >= start_date)
end_date str (ISO) Data de fim (received_at <= end_date)
current_page int Página atual (default: 0)
qty_docs_page int Itens por página (default: 50)

Exemplos:

# Buscar todos os logs
GET /api/v1/webhook-logs

# Buscar logs de SUBSCRIPTION.UPDATED
GET /api/v1/webhook-logs?event_type=SUBSCRIPTION.UPDATED

# Buscar logs com assinatura inválida (possível ataque)
GET /api/v1/webhook-logs?signature_valid=false

# Buscar logs não processados (falhas)
GET /api/v1/webhook-logs?processed=false

# Buscar logs de hoje
GET /api/v1/webhook-logs?start_date=2026-01-08T00:00:00Z&end_date=2026-01-08T23:59:59Z

# Buscar logs de uma subscription específica
GET /api/v1/webhook-logs?query={"payload.data.id":"SUBS_XYZ789"}

Response (200 OK):

{
  "logs": [
    {
      "_id": "507f1f77bcf86cd799439011",
      "event_id": "EVT_ABC123DEF456",
      "event_type": "SUBSCRIPTION.UPDATED",
      "payload": {
        "id": "EVT_ABC123DEF456",
        "type": "SUBSCRIPTION.UPDATED",
        "created_at": "2026-01-08T14:30:00Z",
        "data": {
          "id": "SUBS_XYZ789",
          "status": "ACTIVE"
        }
      },
      "signature": "sha256=abc123...",
      "signature_valid": true,
      "processed": true,
      "result": {
        "status": "processed",
        "subscription_id": "507f1f77bcf86cd799439012",
        "new_status": "active"
      },
      "received_at": "2026-01-08T14:30:00.123Z",
      "processed_at": "2026-01-08T14:30:00.456Z"
    }
  ],
  "total": 1,
  "current_page": 0,
  "total_pages": 1
}

GET /api/v1/webhook-logs/stats - Estatísticas de Webhooks

Obter métricas de performance e confiabilidade dos webhooks.

Headers:

Authorization: Bearer <admin_token>
Content-Type: application/json

Query Parameters (Opcional):

Parâmetro Tipo Descrição
start_date str (ISO) Data de início para cálculo
end_date str (ISO) Data de fim para cálculo

Response (200 OK):

{
  "total_received": 5420,
  "total_processed": 5385,
  "total_failed": 35,
  "success_rate": 0.9935,
  "invalid_signatures": 2,
  "by_event_type": {
    "SUBSCRIPTION.UPDATED": 3200,
    "PAYMENT.PAID": 2100,
    "SUBSCRIPTION.CANCELLED": 85,
    "PAYMENT.FAILED": 35
  },
  "avg_processing_time_ms": 245,
  "last_received_at": "2026-01-08T15:00:00Z"
}

Campos:

Campo Descrição
total_received Total de webhooks recebidos
total_processed Webhooks processados com sucesso
total_failed Webhooks com falha de processamento
success_rate Taxa de sucesso (0.9935 = 99.35%)
invalid_signatures Webhooks com assinatura HMAC inválida
by_event_type Contagem por tipo de evento
avg_processing_time_ms Tempo médio de processamento (ms)
last_received_at Timestamp do último webhook recebido

Fluxo Frontend: Dashboard Admin

O frontend admin deve ter menus separados por gateway para gerenciar subscriptions:

Menu Admin
├── Asaas
│   ├── Subscriptions (GET /admin/asaas/subscriptions)
│   ├── Stats (GET /admin/asaas/subscriptions/stats)
│   └── Acoes: Cancel, Refund
└── Stripe
    ├── Subscriptions (GET /admin/stripe/subscriptions)
    ├── Stats (GET /admin/stripe/subscriptions/stats)
    └── Acoes: Cancel, Refund, Payment History

Exemplo Flutter/Dart (gateway-aware):

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

/// Service generico para admin de subscriptions por gateway
class AdminSubscriptionService {
  final String baseUrl;
  final String token;
  final String gateway; // 'asaas' ou 'stripe'

  AdminSubscriptionService({
    required this.baseUrl,
    required this.token,
    required this.gateway,
  });

  String get _prefix => '$baseUrl/api/v1/admin/$gateway';

  Map<String, String> get _headers => {
    'Authorization': 'Bearer $token',
    'Content-Type': 'application/json',
  };

  /// Listar subscriptions do gateway
  Future<Map<String, dynamic>> listSubscriptions({int page = 0}) async {
    final response = await http.get(
      Uri.parse('$_prefix/subscriptions?current_page=$page'),
      headers: _headers,
    );
    return jsonDecode(response.body);
  }

  /// Estatisticas do gateway
  Future<Map<String, dynamic>> getStats() async {
    final response = await http.get(
      Uri.parse('$_prefix/subscriptions/stats'),
      headers: _headers,
    );
    return jsonDecode(response.body);
  }

  /// Cancelar subscription
  Future<Map<String, dynamic>> cancel(String subscriptionId) async {
    final response = await http.post(
      Uri.parse('$_prefix/subscriptions/$subscriptionId/cancel'),
      headers: _headers,
    );
    return jsonDecode(response.body);
  }

  /// Historico de pagamentos
  Future<Map<String, dynamic>> getPayments(String subscriptionId) async {
    final response = await http.get(
      Uri.parse('$_prefix/subscriptions/$subscriptionId/payments'),
      headers: _headers,
    );
    return jsonDecode(response.body);
  }

  /// Estornar pagamento
  Future<Map<String, dynamic>> refund(
    String subscriptionId, {
    required String reason,
    String? paymentIntentId, // Stripe only
    String? paymentId,       // Asaas only
  }) async {
    final body = <String, dynamic>{'reason': reason};
    if (gateway == 'stripe' && paymentIntentId != null) {
      body['payment_intent_id'] = paymentIntentId;
    }
    if (gateway == 'asaas' && paymentId != null) {
      body['payment_id'] = paymentId;
    }

    final response = await http.post(
      Uri.parse('$_prefix/subscriptions/$subscriptionId/refund'),
      headers: _headers,
      body: jsonEncode(body),
    );
    return jsonDecode(response.body);
  }
}

// Uso:
// final asaasAdmin = AdminSubscriptionService(
//   baseUrl: 'https://fdplay-api.infraifd.com',
//   token: adminToken,
//   gateway: 'asaas',
// );
// final stripeAdmin = AdminSubscriptionService(
//   baseUrl: 'https://fdplay-api.infraifd.com',
//   token: adminToken,
//   gateway: 'stripe',
// );
// final stats = await stripeAdmin.getStats();

🔍 Troubleshooting

Assinatura Ativa Mas Cliente Não Consegue Acessar Vídeos

Diagnóstico:

  1. Verificar current_subscription_id do customer:

    GET /api/v1/users?query={"email":"customer@example.com"}
    # Check: current_subscription_id deve estar preenchido
    

  2. Verificar status da subscription:

    GET /api/v1/subscriptions?customer_email=customer@example.com
    # Check: status deve ser 'active'
    

  3. Verificar logs de webhooks:

    GET /api/v1/webhook-logs?query={"payload.data.customer.email":"customer@example.com"}
    # Check: processed=true, signature_valid=true
    

Solução:

Se current_subscription_id estiver null mas subscription estiver ativa:

PUT /api/v1/users
[
  {
    "_id": "<customer_id>",
    "current_subscription_id": "<subscription_id>"
  }
]

Webhook Não Processado (processed=false)

Diagnóstico:

GET /api/v1/webhook-logs?processed=false
# Check result.error para ver o motivo

Causas Comuns:

  1. Subscription não encontrada:
    {
      "result": {
        "status": "error",
        "error": "Subscription SUBS_XYZ789 not found in database"
      }
    }
    

Solução: Verificar se subscription foi criada antes do webhook. Se não, criar manualmente.

  1. Assinatura inválida:
    {
      "signature_valid": false
    }
    

Solução: Verificar webhook_secret em .secrets/credentials.toml.


🖼️ Avatar do Admin

O campo avatar_id armazena uma referência ao GridFS para a imagem de perfil do admin.

Estrutura do Campo

Campo Tipo Descrição
avatar_id ObjectId \| null ID do arquivo no GridFS

Upload e Atualização

O fluxo é idêntico ao do Customer:

  1. Upload da imagem via POST /api/v1/archive-records
  2. Atualizar admin com o file_id retornado
PUT /api/v1/users
Content-Type: application/json
Authorization: Bearer <admin_token>

[{
  "_id": "<admin_id>",
  "avatar_id": "507f1f77bcf86cd799439012"
}]

Buscar Avatar

GET /api/v1/archive-records/<avatar_id>
Authorization: Bearer <token>

Ou sem header (para Image.network, <img>, etc.):

GET /api/v1/archive-records/<avatar_id>?token=<access_token>

⚙️ Configuração da Plataforma

O admin pode alterar configurações da plataforma em runtime. Atualmente suporta a definição do gateway de pagamento padrão.

GET /api/v1/config/payment-gateway — Consultar Gateway Padrão

Endpoint público (sem autenticação). O frontend consulta para saber qual gateway apresentar como opção principal.

Response (200 OK):

{
  "default_payment_gateway": "stripe"
}

Ambos Gateways Disponíveis

A resposta indica apenas a preferência do admin. Asaas e Stripe continuam igualmente disponíveis. O frontend pode usar essa informação para destacar o gateway preferido ou decidir qual fluxo exibir primeiro.


PUT /api/v1/admin/config/payment-gateway — Definir Gateway Padrão

Headers:

Authorization: Bearer <admin_token>
Content-Type: application/json

Request:

{
  "gateway": "stripe"
}
Campo Tipo Valores Descrição
gateway string "asaas" | "stripe" Gateway que o frontend deve usar como padrão

Response (200 OK):

{
  "message": "Default payment gateway updated",
  "default_payment_gateway": "stripe",
  "updated_by": "admin_user",
  "updated_at": "2026-03-02T12:00:00Z"
}

Erros:

Status Quando
401 Token ausente ou inválido
403 Usuário não é admin
422 Valor de gateway inválido (não é "asaas" nem "stripe")

Exemplo Flutter (Admin)

// Alterar gateway padrão para Stripe
final resp = await http.put(
  Uri.parse('$baseUrl/api/v1/admin/config/payment-gateway'),
  headers: {'Authorization': 'Bearer $adminToken', 'Content-Type': 'application/json'},
  body: jsonEncode({'gateway': 'stripe'}),
);

Gestao de Clientes (Admin)

Endpoints para o admin gerenciar clientes diretamente — verificar email, suspender/reativar conta, estender ou ativar assinaturas manualmente.

Prefixo: /api/v1/admin/customers e /api/v1/admin/subscriptions

Metodo Endpoint Descricao
POST /admin/customers/{id}/verify-email Verificar email (bypass do codigo)
POST /admin/customers/{id}/toggle-active Suspender/reativar conta
POST /admin/subscriptions/{id}/extend Estender prazo da assinatura
POST /admin/subscriptions/{customer_id}/activate Criar assinatura manual (cortesia)

POST /admin/customers/{id}/verify-email — Verificar Email

Marca o email do customer como verificado sem precisar do codigo de verificacao.

POST /api/v1/admin/customers/507f1f77bcf86cd799439011/verify-email
Authorization: Bearer <admin_token>
Content-Type: application/json

{
  "verify": true
}
Campo Tipo Default Descricao
verify bool true true = verificar, false = desverificar

Response (200 OK):

{
  "docs": [{
    "_id": "507f1f77bcf86cd799439011",
    "username": "joao_silva",
    "email": "joao@example.com",
    "email_verified": true,
    "email_verified_at": "2026-03-26T20:00:00Z"
  }],
  "info": {
    "message": "Email verified by admin",
    "customer_id": "507f1f77bcf86cd799439011",
    "email_verified": true,
    "admin": "root"
  }
}

Efeitos colaterais:

  • Limpa email_verification_code_hash, email_verification_expires_at e email_verification_attempts
  • Registra audit log ADMIN.CUSTOMER.VERIFY_EMAIL

POST /admin/customers/{id}/toggle-active — Suspender/Reativar

Suspende ou reativa a conta de um customer. Suspender bloqueia o acesso a videos.

POST /api/v1/admin/customers/507f1f77bcf86cd799439011/toggle-active
Authorization: Bearer <admin_token>
Content-Type: application/json

{
  "active": false,
  "reason": "Chargeback recorrente — conta suspensa"
}
Campo Tipo Obrigatorio Descricao
active bool Sim true = reativar, false = suspender
reason string Sim Motivo (5-500 chars, audit trail)

Comportamento:

Acao Efeito no Customer Efeito na Subscription
active: false current_subscription_id = null status = 'suspended'
active: true Restaura current_subscription_id status = 'active' (se existir sub suspensa)

Response (200 OK):

{
  "docs": [{
    "_id": "507f1f77bcf86cd799439011",
    "username": "joao_silva",
    "current_subscription_id": null
  }],
  "info": {
    "message": "Customer suspended by admin",
    "customer_id": "507f1f77bcf86cd799439011",
    "active": false,
    "reason": "Chargeback recorrente — conta suspensa",
    "admin": "root"
  }
}

POST /admin/subscriptions/{id}/extend — Estender Prazo

Estende a data de expiracao de uma assinatura. Aceita dias relativos ou data absoluta.

POST /api/v1/admin/subscriptions/507f1f77bcf86cd799439011/extend
Authorization: Bearer <admin_token>
Content-Type: application/json

{
  "days": 30,
  "reason": "Cortesia por problema tecnico"
}
Campo Tipo Obrigatorio Descricao
days int Um dos dois Dias para estender (1-365, relativo ao expires_at atual)
expires_at string (ISO 8601) Um dos dois Nova data absoluta de expiracao
reason string Sim Motivo (5-500 chars, audit trail)

Reativacao automatica

Se a subscription estiver com status expired ou suspended, o extend reativa automaticamente para active e restaura o current_subscription_id do customer.

Response (200 OK):

{
  "docs": [{
    "_id": "507f1f77bcf86cd799439011",
    "status": "active",
    "expires_at": "2026-04-25T20:00:00Z",
    "customer_id": "507f1f77bcf86cd799439012"
  }],
  "info": {
    "message": "Subscription extended by admin",
    "subscription_id": "507f1f77bcf86cd799439011",
    "new_expires_at": "2026-04-25T20:00:00Z",
    "reason": "Cortesia por problema tecnico",
    "admin": "root"
  }
}

POST /admin/subscriptions/{customer_id}/activate — Ativar Manualmente

Cria uma nova subscription sem passar pelo gateway de pagamento. Uso: cortesia, suporte, migracoes.

POST /api/v1/admin/subscriptions/507f1f77bcf86cd799439011/activate
Authorization: Bearer <admin_token>
Content-Type: application/json

{
  "plan_id": "plan-basic",
  "days": 30,
  "reason": "Cortesia suporte — migracao de plataforma"
}
Campo Tipo Default Obrigatorio Descricao
plan_id string - Sim Slug do plano (ex: "plan-basic")
days int 30 Nao Duracao em dias (1-365)
reason string - Sim Motivo (5-500 chars, audit trail)

Comportamento:

  1. Valida que o customer e o plan existem
  2. Cancela subscription ativa anterior (se existir)
  3. Cria nova subscription com status='active'
  4. Vincula ao customer via current_subscription_id
  5. Registra audit log ADMIN.SUBSCRIPTION.ACTIVATE

Response (200 OK):

{
  "docs": [{
    "_id": "682f1a99bcf86cd799439099",
    "customer_id": "507f1f77bcf86cd799439011",
    "plan_id": "plan-basic",
    "status": "active",
    "gateway": "asaas",
    "payment_method": "pix",
    "started_at": "2026-03-26T20:00:00Z",
    "expires_at": "2026-04-25T20:00:00Z"
  }],
  "info": {
    "message": "Subscription activated manually by admin",
    "subscription_id": "682f1a99bcf86cd799439099",
    "customer_id": "507f1f77bcf86cd799439011",
    "plan_id": "plan-basic",
    "expires_at": "2026-04-25T20:00:00Z",
    "reason": "Cortesia suporte — migracao de plataforma",
    "admin": "root"
  }
}

GET /admin/subscriptions/insights — Análise interpretada (admin)

Camada de análise interpretada sobre os dados do dashboard. Usar para cards de saúde, badges, alertas priorizados e recomendações de ação. Para tabelas e drill-down de números brutos, usar /admin/subscriptions/dashboard.

Request

GET /api/v1/admin/subscriptions/insights
Authorization: Bearer <admin_token>

Response (200 OK)

{
  "health": {
    "score": 0.89,
    "label": "good",
    "active_healthy": 1514,
    "active_at_risk_pix_stuck": 134
  },
  "conversion": {
    "rate": 0.58,
    "label": "warning",
    "customers_without_subscription": 1271,
    "customers_signup_incomplete": 1437,
    "customers_email_unverified": 1233
  },
  "financial_health": {
    "label": "healthy",
    "mrr_cents": 3279520,
    "arr_cents": 39354240,
    "refund_rate": 0.0093,
    "chargeback_rate": 0,
    "failure_rate": 0.0712,
    "ticket_uniformity": "all_basic"
  },
  "gateway_distribution": {
    "dominant": "asaas",
    "stripe_share": 0.13,
    "asaas_share": 0.87
  },
  "recurrence_distribution": {
    "label": "high_one_time_risk",
    "one_time_share": 0.67,
    "one_time_count": 1314,
    "recurring_count": 649
  },
  "top_alerts": [
    {
      "code": "PIX_STUCK",
      "severity": "critical",
      "title": "134 PIX pendentes ha mais de 24h",
      "description": "96% do pending PIX esta estagnado. Webhook PIX pode estar falhando.",
      "metric": 134,
      "potential_recovery_cents": 266660,
      "action_hint": "Investigar DEBT-037 (webhook PIX)"
    }
  ],
  "opportunities": [
    {
      "code": "MIGRATE_PIX_TO_CARD",
      "title": "Migrar 1314 PIX one-time para cartao recorrente",
      "rationale": "PIX nao renova automaticamente.",
      "potential_mrr_increase_cents": 0,
      "potential_arr_increase_cents": 31394640
    }
  ],
  "data_quality": {
    "label": "good",
    "current_orphan_active": 0,
    "current_unknown_gateway_active": 0
  },
  "generated_at": "2026-04-26T20:30:00Z"
}

Labels e regras de severidade

health.label — saúde geral

Label Score
good ≥ 0.85
warning 0.70–0.84
critical < 0.70

Score = (active − pending_pix_over_24h) / (active + pending). Mede quantas das subscriptions "ativas+pendentes" não estão estagnadas.

conversion.label

Label Rate
good ≥ 0.80
warning 0.50–0.79
critical < 0.50

financial_health.label

Label Regra
healthy chargeback_count == 0 AND refund_rate < 2%
attention chargeback_count <= 5 AND refund_rate < 5%
unhealthy qualquer outro caso

recurrence_distribution.label

Label Regra
mostly_recurring one_time_share < 0.20
balanced 0.20 ≤ one_time_share ≤ 0.50
high_one_time_risk one_time_share > 0.50

data_quality.label — pós-DEBT-051 cleanup

Label Regra
good 0 órfãs ativas + 0 unknown_gateway ativos
attention até 10 docs anômalos ativos
unhealthy > 10 docs anômalos ativos

Top alerts — códigos suportados

code Quando aparece Severidade calculada
PIX_STUCK pending_pix_over_24h > 0 critical se ≥50% do pending; high se ≥20%; medium caso contrário
INCOMPLETE_SIGNUP customers_signup_incomplete > 0 high se ≥30% da base; medium se ≥10%; low
PIX_CHURN_RISK one_time_share > 0.50 high se ≥0.70; medium caso contrário
LOW_CONVERSION customers_without_subscription > 0 high se ≥40% da base; medium se ≥20%; low
CHARGEBACK_DETECTED chargeback_count > 0 sempre critical

Lista ordenada por severidade DESC. Máximo 5 alerts retornados. Front-end pode exibir todos ou filtrar por severidade.

Opportunities — códigos suportados

code Cálculo do impacto
RECOVER_PIX_STUCK potential_mrr_increase_cents = pending_pix_over_24h × ticket_médio
MIGRATE_PIX_TO_CARD potential_arr_increase_cents = one_time_count × ticket_médio × 12
COMPLETE_SIGNUP_CAMPAIGN sem estimativa direta — listada quando signup_incomplete > 0

Ticket médio = mrr_cents / active_count (ou média de plans.amount se MRR=0).

Errors

  • 401 — Token ausente/inválido
  • 403 — Usuário não é admin

Performance

  • Mesmas 12 aggregations do dashboard (paralelas via asyncio.gather)
  • Camada de interpretação é puro Python pós-processamento — overhead < 5ms
  • Sem cache — tipico < 500ms

Quando usar /insights vs /dashboard

Use case Endpoint
Cards de KPI com label de saúde /insights
Alertas priorizados /insights
Recomendações de ação com impacto $ /insights
Tabelas drill-down de números /dashboard
Gráficos com breakdown completo /dashboard
BI/data export /dashboard

Exemplo Flutter/Dart

class InsightsTopAlert {
  final String code;
  final String severity; // 'critical' | 'high' | 'medium' | 'low'
  final String title;
  final int metric;
  final int potentialRecoveryCents;
  final String actionHint;

  InsightsTopAlert.fromJson(Map<String, dynamic> j)
      : code = j['code'],
        severity = j['severity'],
        title = j['title'],
        metric = j['metric'],
        potentialRecoveryCents = j['potential_recovery_cents'] ?? 0,
        actionHint = j['action_hint'] ?? '';

  Color get color => switch (severity) {
        'critical' => Colors.red,
        'high' => Colors.orange,
        'medium' => Colors.amber,
        _ => Colors.blue,
      };
}

Future<Map<String, dynamic>> fetchInsights(
  String baseUrl,
  String adminToken,
) async {
  final resp = await http.get(
    Uri.parse('$baseUrl/api/v1/admin/subscriptions/insights'),
    headers: {'Authorization': 'Bearer $adminToken'},
  );
  if (resp.statusCode != 200) throw Exception('Failed: ${resp.statusCode}');
  return jsonDecode(resp.body);
}

GET /admin/subscriptions/dashboard — Dashboard de assinaturas (admin)

Visao 360° de assinaturas e clientes para tomada de decisao do admin. Nao quebra o legado GET /subscriptions/stats.

Request

GET /api/v1/admin/subscriptions/dashboard
Authorization: Bearer <admin_token>

Response (200 OK)

{
  "totals": {
    "subscriptions": 1959,
    "customers": 3051,
    "customers_with_subscription": 1779,
    "customers_without_subscription": 1272,
    "customers_email_unverified": 412,
    "customers_signup_incomplete": 587
  },
  "by_status": {
    "active": 1674,
    "pending": 152,
    "suspended": 3,
    "cancelled": 123,
    "expired": 7
  },
  "by_gateway": {
    "stripe": {
      "total": 845,
      "active": 720,
      "pending": 60,
      "suspended": 1,
      "cancelled": 60,
      "expired": 4,
      "mrr_cents": 2152800
    },
    "asaas": {
      "total": 1114,
      "active": 954,
      "pending": 92,
      "suspended": 2,
      "cancelled": 63,
      "expired": 3,
      "mrr_cents": 2851860
    }
  },
  "by_payment_method": {
    "credit_card": 1502,
    "pix": 380,
    "boleto": 77
  },
  "by_recurrence": {
    "recurring": 1579,
    "one_time": 380
  },
  "by_status_x_gateway": {
    "stripe": { "active": 720, "pending": 60, "suspended": 1, "cancelled": 60, "expired": 4 },
    "asaas":  { "active": 954, "pending": 92, "suspended": 2, "cancelled": 63, "expired": 3 }
  },
  "financial": {
    "mrr_cents": 5004660,
    "arr_cents": 60055920,
    "average_subscription_value_cents": 2989,
    "total_refunded_cents": 145000,
    "chargeback_count": 12,
    "with_payment_failures": 38
  },
  "alerts": {
    "subscriptions_with_chargeback": 12,
    "suspended_with_3plus_failures": 3,
    "pending_pix_over_24h": 47
  },
  "generated_at": "2026-04-26T19:30:00Z"
}

Definicao dos campos

totals — visao macro

Campo Significado
subscriptions Total de docs em subscriptions
customers Total de users.user_type='customer'
customers_with_subscription Clientes com pelo menos uma subscription (calculado: customers − without)
customers_without_subscription Clientes que nunca tiveram assinatura (via $lookup — preciso, ignora subs órfãs)
customers_email_unverified Clientes com email_verified ≠ true
customers_signup_incomplete Clientes com email_verified=false OU tax_id vazio OU phone vazio (OR logico)

by_status — distribuicao de subscriptions por estado de pagamento

Status Significado
active Pagando regularmente
pending Aguardando pagamento (PIX nao confirmado, cartao em validacao)
suspended Suspensa por falhas (≥3) ou chargeback
cancelled Cancelada (admin ou cliente)
expired PIX/anual expirou sem renovacao

by_gateway — split Stripe vs Asaas

Cada gateway tem total, contagem por status e mrr_cents (apenas active de planos MONTH).

by_payment_method — distribuicao de metodos

Metodo Comportamento
credit_card Cobrança recorrente automatica
pix One-time (precisa renovar manual)
boleto Recorrente (Asaas)

by_recurrence — agrupa por modelo de cobranca

Tipo Composicao
recurring credit_card + boleto (renova automaticamente)
one_time pix (sem renovacao automatica — risco de churn passivo)

by_status_x_gateway — cross-tab para identificar onde concentra cada estado.

financial — KPIs em centavos

Campo Calculo
mrr_cents Soma de plan.amount para subscriptions active em planos mensais
arr_cents mrr_cents × 12
average_subscription_value_cents mrr / count(active)
total_refunded_cents Soma de subscriptions.total_refunded
chargeback_count Count de has_chargeback=true
with_payment_failures Count de payment_failures > 0

alerts — sinais operacionais que exigem atencao

Alerta Quando dispara Acao sugerida
subscriptions_with_chargeback has_chargeback=true Suspender conta + revisar pagamento
suspended_with_3plus_failures status=suspended AND payment_failures >= 3 Contato manual de recuperacao
pending_pix_over_24h status=pending AND payment_method=pix AND started_at < now − 24h Lembrete ou cancelar para liberar capacidade
subscriptions_with_unknown_gateway gateway NOT IN ('stripe', 'asaas') — inclui null, vazio, legados (pagseguro) Migrar gateway via script ou cancelar — ver DEBT-051
orphaned_subscriptions customer_id sem doc correspondente em users (cliente deletado ou admin) Cancelar — KPIs active/pending podem estar inflados — ver DEBT-051

Casos de uso

Caso Como interpretar
Quanto da base nao converte? totals.customers_without_subscription / totals.customers
Onde concentra inadimplencia? by_gateway.<gw>.suspended + by_gateway.<gw>.cancelled por gateway
Risco de churn passivo PIX by_recurrence.one_time + alerts.pending_pix_over_24h
Saude do funil customers_signup_incomplete alto = friccao no signup
Receita projetada financial.arr_cents / 100

Errors

  • 401 — Token ausente/invalido
  • 403 — Usuario nao e admin

Performance

  • 10 aggregations rodam em paralelo via asyncio.gather
  • Tipico < 500ms para bases ate ~50k subscriptions
  • Sem paginacao — todas metricas em uma resposta

Notas para o frontend

Gateways inesperados em by_gateway

O contrato declara stripe e asaas no by_gateway e by_status_x_gateway. Na pratica, o backend pode encontrar documentos legados com gateway=null ou valores antigos (ex.: pagseguro). Esses nao sao retornados no dashboard atual — a soma de by_status_x_gateway pode ser menor que by_status. Diferenca = subscriptions com gateway anomalo.

Acao no frontend: comparar sum(by_status.values()) com sum(by_status_x_gateway[gw].values()). Se divergir, exibir aviso ao admin.

Valores possiveis de payment_method

Schema declara credit_card | pix | boleto, mas em producao apenas credit_card e pix aparecem. boleto pode ser zero por longos periodos — tratar como sempre presente no contrato (nao quebrar UI).

Risco de churn passivo PIX

Se by_recurrence.one_time / totals.subscriptions > 0.4, a base esta exposta a churn passivo (PIX nao renova automatico). Cruzar com alerts.pending_pix_over_24h para identificar volume estagnado.

Exemplo Flutter/Dart

import 'dart:convert';
import 'package:http/http.dart' as http;

class SubscriptionsDashboard {
  final Map<String, dynamic> totals;
  final Map<String, dynamic> byStatus;
  final Map<String, dynamic> byGateway;
  final Map<String, dynamic> byPaymentMethod;
  final Map<String, dynamic> byRecurrence;
  final Map<String, dynamic> financial;
  final Map<String, dynamic> alerts;
  final DateTime generatedAt;

  SubscriptionsDashboard.fromJson(Map<String, dynamic> json)
      : totals = json['totals'],
        byStatus = json['by_status'],
        byGateway = Map<String, dynamic>.from(json['by_gateway']),
        byPaymentMethod = json['by_payment_method'],
        byRecurrence = json['by_recurrence'],
        financial = json['financial'],
        alerts = json['alerts'],
        generatedAt = DateTime.parse(json['generated_at']);

  // KPIs derivados
  double get conversionRate =>
      totals['customers_with_subscription'] / totals['customers'];

  double get oneTimeShare =>
      byRecurrence['one_time'] / totals['subscriptions'];

  bool get hasGatewayAnomalies {
    final statusTotal = (byStatus as Map<String, dynamic>)
        .values
        .fold<int>(0, (a, b) => a + (b as int));
    final crossTotal = byGateway.values
        .map((g) => g as Map<String, dynamic>)
        .fold<int>(0, (a, g) => a + (g['total'] as int));
    return statusTotal != crossTotal;
  }

  int get unaccountedSubscriptions {
    if (!hasGatewayAnomalies) return 0;
    final statusTotal = (byStatus as Map<String, dynamic>)
        .values
        .fold<int>(0, (a, b) => a + (b as int));
    final crossTotal = byGateway.values
        .map((g) => g as Map<String, dynamic>)
        .fold<int>(0, (a, g) => a + (g['total'] as int));
    return statusTotal - crossTotal;
  }
}

Future<SubscriptionsDashboard> fetchSubscriptionsDashboard(
  String baseUrl,
  String adminToken,
) async {
  final resp = await http.get(
    Uri.parse('$baseUrl/api/v1/admin/subscriptions/dashboard'),
    headers: {'Authorization': 'Bearer $adminToken'},
  );
  if (resp.statusCode != 200) {
    throw Exception('Failed to load dashboard: ${resp.statusCode}');
  }
  return SubscriptionsDashboard.fromJson(jsonDecode(resp.body));
}

Polling vs cache

  • O endpoint nao tem cache no backend — cada request recalcula.
  • Recomendado polling a cada 30-60 segundos em telas de admin (cuidado: cada request faz 10 aggregations).
  • Para tela de overview (KPIs) usar este endpoint. Para drill-down por cliente, usar /admin/customers/export ou /subscriptions paginado.

GET /admin/customers/export — Exportar clientes segmentados

Exporta clientes filtrados por status de assinatura para campanhas de retencao, recuperacao de inadimplentes e analytics de churn.

Autenticacao: admin via header Authorization: Bearer <token> ou query ?token=<jwt> (para download direto pelo browser).

Request

GET /api/v1/admin/customers/export?segment=without_subscription&format=xlsx
Authorization: Bearer <admin_token>

Query Params

Param Tipo Obrigatorio Default Valores
segment string active, pending, suspended, cancelled, expired, without_subscription, all
format string xlsx xlsx (default, formatado), csv (UTF-8 BOM), json (aninhado)
gateway string stripe, asaas (filtra pela assinatura mais recente)
start string Data inicial (YYYY-MM-DD) — aplica em signup_date
end string Data final (YYYY-MM-DD) — aplica em signup_date
token string JWT alternativo ao header (uso por browser download)

Definicao dos segmentos

Segment Regra
active Cliente com alguma subscription status = active (ADR garante 1 por vez)
pending Cliente com alguma subscription status = pending
suspended Cliente com alguma subscription status = suspended
cancelled Cliente cuja subscription mais recente (sort por created_at DESC) tem status = cancelled
expired Cliente cuja subscription mais recente tem status = expired
without_subscription Cliente que nunca teve assinatura (sem doc em subscriptions)
all Todos os clientes (user_type = 'customer')

Colunas do export

Coluna Tipo Origem
customer_id string (OID) users._id
full_name string users.full_name
email string users.email
tax_id string (CPF) users.tax_id
phone string users.phone
signup_date datetime (UTC) users.created_at
subscription_status string ou vazio subscriptions.status (mais recente)
subscription_gateway string ou vazio subscriptions.gatewayapenas stripe ou asaas
plan_name string ou vazio plans.name (lookup por subscriptions.plan_id)
last_payment_date datetime (UTC) ou vazio subscriptions.last_payment_at
total_revenue_cents int (centavos) LTV aproximado (ver fórmula abaixo) — exibido no Excel como R$ #,##0.00
chargeback_detected bool subscriptions.has_chargeback

total_revenue_cents — fórmula: Para cada subscription do customer, soma plan.amount × ciclos, onde ciclos = ceil((last_payment_at − started_at).days / 30) + 1. Aproximação para LTV — não substitui relatório financeiro do gateway.

Formatos (ADR-059)

Formato Comportamento Limite
xlsx (default) Workbook formatado: título, metadados, freeze A4, autofilter, zebra, números nativos (R$ #,##0.00, dd/mm/yyyy hh:mm) 50.000 linhas/aba — acima retorna 422
csv UTF-8 BOM () para Excel pt-BR. Datas ISO 8601, valores em reais com 2 casas 50.000 (single-aba neste endpoint)
json Aninhado: {meta: {...}, sheets: {Customers: [rows]}} 50.000

Response

  • 200 OKContent-Disposition: attachment; filename="customers_{segment}_{YYYYMMDD_HHMMSS}.{ext}"
  • 400 — Formato de data inválido ou XLSX acima do limite
  • 401 — Token ausente/inválido
  • 403 — Usuário não é admin

Casos de uso

Caso Request
Marketing — campanha de conversão (free → paid) ?segment=without_subscription&format=xlsx
Operações — recuperação de inadimplentes ?segment=suspended&gateway=asaas&format=xlsx
Análise de churn por janela ?segment=cancelled&start=2026-01-01&end=2026-03-31&format=xlsx
Dashboard externo (BI) ?segment=all&format=json
Pipe BI (UTF-8 BOM) ?segment=all&format=csv

Exemplo Flutter/Dart

Future<void> downloadCustomersExport({
  required String segment,
  String format = 'xlsx',
  String? gateway,
}) async {
  final params = {
    'segment': segment,
    'format': format,
    'token': adminToken, // download direto via browser/webview
    if (gateway != null) 'gateway': gateway,
  };
  final uri = Uri.parse('$baseUrl/api/v1/admin/customers/export')
      .replace(queryParameters: params);
  // Em desktop/web: abrir no browser para download nativo
  // Em mobile: usar dio ou http com responseType bytes e salvar com path_provider
  await launchUrl(uri);
}

Exemplo Flutter/Dart — Admin Customer Service

import 'dart:convert';
import 'package:http/http.dart' as http;

class AdminCustomerService {
  final String baseUrl;
  final String token;

  AdminCustomerService({required this.baseUrl, required this.token});

  Map<String, String> get _headers => {
    'Authorization': 'Bearer $token',
    'Content-Type': 'application/json',
  };

  /// Verificar email do customer (bypass do codigo)
  Future<Map<String, dynamic>> verifyEmail(String customerId, {bool verify = true}) async {
    final response = await http.post(
      Uri.parse('$baseUrl/api/v1/admin/customers/$customerId/verify-email'),
      headers: _headers,
      body: jsonEncode({'verify': verify}),
    );
    return jsonDecode(response.body);
  }

  /// Suspender ou reativar conta
  Future<Map<String, dynamic>> toggleActive(
    String customerId, {
    required bool active,
    required String reason,
  }) async {
    final response = await http.post(
      Uri.parse('$baseUrl/api/v1/admin/customers/$customerId/toggle-active'),
      headers: _headers,
      body: jsonEncode({'active': active, 'reason': reason}),
    );
    return jsonDecode(response.body);
  }

  /// Estender prazo da assinatura
  Future<Map<String, dynamic>> extendSubscription(
    String subscriptionId, {
    int? days,
    String? expiresAt,
    required String reason,
  }) async {
    final body = <String, dynamic>{'reason': reason};
    if (days != null) body['days'] = days;
    if (expiresAt != null) body['expires_at'] = expiresAt;

    final response = await http.post(
      Uri.parse('$baseUrl/api/v1/admin/subscriptions/$subscriptionId/extend'),
      headers: _headers,
      body: jsonEncode(body),
    );
    return jsonDecode(response.body);
  }

  /// Ativar assinatura manualmente (cortesia)
  Future<Map<String, dynamic>> activateSubscription(
    String customerId, {
    required String planId,
    int days = 30,
    required String reason,
  }) async {
    final response = await http.post(
      Uri.parse('$baseUrl/api/v1/admin/subscriptions/$customerId/activate'),
      headers: _headers,
      body: jsonEncode({
        'plan_id': planId,
        'days': days,
        'reason': reason,
      }),
    );
    return jsonDecode(response.body);
  }
}

// Uso:
// final adminService = AdminCustomerService(
//   baseUrl: 'https://fdplay-api.infraifd.com',
//   token: adminToken,
// );
//
// // Verificar email
// await adminService.verifyEmail('customer_id_here');
//
// // Suspender conta
// await adminService.toggleActive(
//   'customer_id_here',
//   active: false,
//   reason: 'Chargeback recorrente',
// );
//
// // Estender assinatura por 30 dias
// await adminService.extendSubscription(
//   'subscription_id_here',
//   days: 30,
//   reason: 'Cortesia por problema tecnico',
// );
//
// // Ativar assinatura manual (cortesia)
// await adminService.activateSubscription(
//   'customer_id_here',
//   planId: 'plan-basic',
//   days: 60,
//   reason: 'Acordo comercial',
// );

Fluxo: Troubleshooting com Novos Endpoints

flowchart TD
    A[Cliente nao consegue acessar] --> B{Email verificado?}
    B -->|Nao| C["POST /admin/customers/{id}/verify-email"]
    B -->|Sim| D{Tem subscription ativa?}

    D -->|Nao, expirou| E["POST /admin/subscriptions/{id}/extend<br/>days: 30"]
    D -->|Nao, nunca teve| F["POST /admin/subscriptions/{customer_id}/activate<br/>plan_id: plan-basic, days: 30"]
    D -->|Sim, mas suspensa| G["POST /admin/customers/{id}/toggle-active<br/>active: true"]
    D -->|Sim, ativa| H[Verificar current_subscription_id]

Auditoria

Todas as acoes admin sao registradas em webhook_logs com event_type prefixado ADMIN.:

Event Type Acao
ADMIN.CUSTOMER.VERIFY_EMAIL Email verificado/desverificado
ADMIN.CUSTOMER.SUSPEND Conta suspensa
ADMIN.CUSTOMER.REACTIVATE Conta reativada
ADMIN.SUBSCRIPTION.EXTEND Prazo estendido
ADMIN.SUBSCRIPTION.ACTIVATE Assinatura criada manualmente

Consultar via:

GET /api/v1/webhook-logs?query={"event_type":{"$regex":"^ADMIN\\."}}

QR Code — Geração de Imagens

Endpoints para gerar imagens PNG de QR code a partir de texto ou de um arquivo do GridFS.

Autenticação: Bearer token (JWT) — suporta também ?token=<jwt> para uso direto em Image.network ou launchUrl.

GET /qrcode/{text}

Gera QR code a partir de qualquer texto (URL, ID, string arbitrária).

GET /api/v1/qrcode/{text}
Authorization: Bearer <admin_token>

Ou via URL direta:

GET /api/v1/qrcode/{text}?token=<access_token>

Response: StreamingResponse PNG (Content-Disposition: attachment; filename="qrcode_<uuid>.png")

Parâmetros:

Param Tipo Local Descrição
text string path Texto a ser codificado no QR

Exemplo Flutter:

// Usando Image.network com token no query param
Widget buildQrCode(String text, String token) {
  return Image.network(
    '$baseUrl/api/v1/qrcode/${Uri.encodeComponent(text)}?token=$token',
  );
}

// Download para salvar em disco
Future<File> downloadQrCode(String text, String token) async {
  final response = await Dio().get(
    '$baseUrl/api/v1/qrcode/${Uri.encodeComponent(text)}',
    options: Options(
      headers: {'Authorization': 'Bearer $token'},
      responseType: ResponseType.bytes,
    ),
  );
  final dir = await getApplicationDocumentsDirectory();
  final file = File('${dir.path}/qrcode.png');
  await file.writeAsBytes(response.data as List<int>);
  return file;
}

GET /qrcode/{file_id}/{is_public}

Gera QR code para acesso a um arquivo do GridFS.

GET /api/v1/qrcode/{file_id}/{is_public}
Authorization: Bearer <admin_token>

Ou via URL direta:

GET /api/v1/qrcode/{file_id}/{is_public}?token=<access_token>

Parâmetros:

Param Tipo Local Descrição
file_id string path ObjectId do arquivo no GridFS
is_public bool path true = QR aponta para endpoint público (Fernet-encrypted); false = endpoint autenticado

Comportamento por is_public:

is_public URL gerada no QR Quem pode acessar
true /archive-records-public/{fernet_encrypted_id} Qualquer pessoa (sem auth)
false /archive-records/{file_id} Apenas usuários autenticados

Response: StreamingResponse PNG com header Content-Qrcode: <url_gerada> para debug.


Proximos Passos

  1. Exportacao de relatorios - CSV/Excel de assinaturas, pagamentos e reembolsos
  2. Notificacoes em tempo real - WebSocket para admins (nova assinatura, cancelamento, chargeback)
  3. Analise de cohorts - Retention por mes de signup
  4. Previsao de receita - MRR projetado baseado em historico (deduzindo reembolsos)
  5. Automacao de retry - Reprocessar webhooks com falha automaticamente
  6. Dashboard de chargebacks - Visao consolidada de disputas e seus status
  7. Alertas de chargeback - Notificacao automatica via email/Slack quando chargeback detectado