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:
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",
"full_name": "New Admin da Silva",
"tax_id": "12345678909",
"phone": "+5511987654321",
"user_type": "admin",
"enable": true,
"language": "pt",
"avatar_id": "507f1f77bcf86cd799439011",
"type_user": "507f1f77bcf86cd799439012"
}]
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
username |
string |
Sim | Único, 3-50 caracteres |
email |
string |
Sim | Email válido |
password |
string |
Sim | Mínimo 8 caracteres |
name |
string |
Sim | Nome de exibição (legado), 3-200 caracteres |
full_name |
string |
Não | Nome completo (paridade com Customer), 3-200 caracteres |
tax_id |
string |
Não | CPF (11 dígitos) ou CNPJ (14 dígitos), apenas números |
phone |
string |
Não | Telefone com código de país (regex ^\+?\d{10,15}$) |
enable |
bool |
Não | Default true. false recusa o login (ADR-0117) |
language |
string |
Não | pt | es | en, default pt |
avatar_id |
ObjectId |
Não | GridFS id. Suba a imagem antes (POST /me/avatar) e mande o id — a API não aceita base64 |
type_user |
ObjectId |
Não | Perfil de acesso (users-type). Um por usuário; perfil inexistente → 422 (ADR-0118) |
enable e type_user não existem em PUT /my-user
/my-user é rota self-service, fora da checagem de permissão. Os dois campos são
ignorados ali — senão qualquer admin escolheria o próprio perfil de acesso. Para
alterá-los, use PUT /api/v1/users, que exige permissão em users.
Conflitos (409, ADR-0111): username, email e tax_id são únicos. Duplicar devolve
409 com code: "DUPLICATE_KEY" e o campo em error.field — use-o para apontar o erro no
formulário. username: "root" é reservado (sintético, ADR-0104) e devolve 409 com
code: "CONFLICT", também com field.
{"error": {"code": "DUPLICATE_KEY", "field": "tax_id",
"message": "Já existe um registro com o valor informado para 'tax_id'."}}
PUT /api/v1/users - Atualizar Admin¶
Atualizar admin existente. Mesmos campos opcionais do POST (tax_id, phone, enable,
language, avatar_id e type_user incluídos), mais _id obrigatório. Renomear para um
username/email já usado devolve o mesmo 409. Campo não enviado mantém o valor atual.
DELETE /api/v1/users - Deletar Admin¶
Basta o _id. Duas formas equivalentes — não envie password nem o documento completo:
DELETE /api/v1/users?_id=<id> # sem corpo
DELETE /api/v1/users -d '[{"_id": "<id>"}]' # corpo só com o _id
Campos extras no corpo são ignorados, então clientes que ainda mandam o documento inteiro
continuam funcionando. _id de customer é desviado para o expurgo com cascade (ADR-0100).
Excluir um usuário também o remove de users_type.users — o _id não fica mais órfão
exibindo users_name: "not found" na listagem de perfis (ADR-0118).
🛡️ Perfil de acesso (type_user)¶
O vínculo usuário ⇄ perfil tem duas pontas: users.type_user (escalar) e
users_type.users (array). Escreva só uma delas — o backend sincroniza a outra
(ADR-0118). Escrever as duas na mesma operação não é necessário e não é suportado.
| Você faz | O backend faz |
|---|---|
PUT /users com type_user: "<id>" |
Entra em users_type.users do perfil e sai de todos os outros |
PUT /users com type_user: null |
Sai de todos os perfis |
PUT /users-type com users: [...] |
Grava type_user em cada um; quem saiu da lista fica sem perfil |
DELETE /users |
Remove o _id de todos os perfis |
Um perfil por usuário. Entrar num perfil sai dos demais — não há seleção múltipla.
Repetições em users são descartadas na gravação.
Referência inexistente → 422. type_user que não aponta para um perfil real, ou users
citando um _id sem documento em users, são recusados com VALIDATION_ERROR e o field
correspondente. Sem isso o id fantasma seria gravado em silêncio (o Mongo não reclama de um
$addToSet que não casa nada) e voltaria na tela como users_name: "not found".
Excluir perfil com usuários → 409 (mesma regra da coluna do kanban, ADR-0098). O corpo
é opcional — DELETE /users-type?_id=<id> basta:
{"error": {"code": "CONFLICT", "field": "users",
"message": "O perfil 'Admin' ainda tem usuários vinculados — mova-os para outro perfil antes de excluir."}}
Para exibir o perfil na listagem de usuários, leia type_user do próprio documento — não
é mais preciso varrer todos os tipos procurando o _id.
🔑 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)¶
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 | |
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¶
409 - Email Já Existe¶
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:
- Token JWT válido -
Authorization: Bearer <token> - user_type='admin' - Verificado automaticamente
Resposta 403 para não-admins:
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' |
| Pagar.me | /api/v1/admin/pagarme/subscriptions |
Subscriptions com gateway='pagarme' |
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/, /admin/asaas/ e /admin/pagarme/ sao recomendadas para operacoes que interagem com a API do gateway (cancelar, estornar, consultar pagamentos).
Listagem generica GET /api/v1/subscriptions (ADR-0133)¶
- Busca por assinante:
full_nameeusernamevem do$lookupemuserse aceitam filtro naquery, inclusive$regexe dentro de$or. O total (qty_total_docs) conta o mesmo recorte: gatewayreflete o documento:stripe,asaas,pagarme,pagseguro(legado, 1 registro) ounullquando o documento nao tem o campo (41 cancelamentos antigos). Ate 14/09/2026 esses 41 saiam como"asaas", e por isso{"gateway":{"$nin":["asaas","stripe"]}}parecia ignorado — o operador funcionava e casava exatamente os documentos sem o campo.- Documento fora do contrato nao derruba a pagina: e omitido da lista (e registrado no
log com o
_id). A pagina pode vir com menos itens queqty_docs_page; o total segue contando o documento.
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'.
Mesmos query parameters (query, current_page, qty_docs_page, sort).
GET /admin/stripe/subscriptions/stats¶
Estatisticas agregadas de subscriptions Stripe.
Response (200 OK):
{
"gateway": "stripe",
"total_subscriptions": 270,
"active": 230,
"suspended": 15,
"cancelled": 20,
"pending": 5,
"expired": 0,
"paying": 230,
"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:
- Valida que
gateway == 'stripe'(rejeita outros gateways com 422) - Chama
StripeClient.cancel_subscription(stripe_subscription_id, at_period_end=False) - Atualiza
status='cancelled'no MongoDB - Limpa
current_subscription_iddo 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:
- Busca
stripe_customer_iddo customer - Lista os 10 charges mais recentes via
Charge.list() - Seleciona o primeiro charge com
status='succeeded'erefunded=false - Usa o
payment_intentdesse 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'.
Mesmos query parameters (query, current_page, qty_docs_page, sort).
GET /admin/asaas/subscriptions/stats¶
Estatisticas agregadas de subscriptions Asaas.
Response (200 OK):
{
"gateway": "asaas",
"total_subscriptions": 150,
"active": 120,
"suspended": 10,
"cancelled": 15,
"pending": 5,
"expired": 0,
"paying": 120,
"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:
- Valida que
gateway == 'asaas'(rejeita outros gateways com 422) - Chama
AsaasClient.cancel_subscription(asaas_subscription_id) - Atualiza
status='cancelled'no MongoDB - Limpa
current_subscription_iddo 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
}
]
}
Cache de 10 min e teto por minuto (ADR-0171)
Esta rota e GET /subscriptions/{id}/payments (Asaas) leem por cache de 10 min por
assinatura: pagamento novo pode levar ate 10 min para aparecer. Consultas que vao a Asaas
tem teto global de 30/min; acima, 429 TOO_MANY_REQUESTS com Retry-After
(segundos). O bloqueio da propria Asaas tambem sai 429 com Retry-After — antes saia
403 FORBIDDEN. A chave e a mesma das cobrancas: nao iterar sobre centenas de
assinaturas; para o razao de pagamentos use /admin/finance/payments/entries.
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:
- Busca
asaas_subscription_idda subscription - Lista os 10 pagamentos mais recentes via
AsaasClient.list_subscription_payments() - Seleciona o primeiro pagamento com
status='RECEIVED'ou'CONFIRMED'e semrefundedValue - Usa o
iddesse 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": []
}
Admin Pagar.me¶
Prefixo: /api/v1/admin/pagarme — mesmas telas do menu Asaas, mesmo contrato de resposta (docs + info).
| Metodo | Endpoint | Descricao |
|---|---|---|
| GET | /admin/pagarme/subscriptions |
Listar subscriptions Pagar.me |
| GET | /admin/pagarme/subscriptions/stats |
Estatisticas (status, pagantes, MRR em centavos) |
| POST | /admin/pagarme/subscriptions/{id}/cancel |
Cancelar agora no Pagar.me e na base |
| GET | /admin/pagarme/subscriptions/{id}/payments |
Cobrancas reais (faturas da assinatura ou QR do PIX) |
| POST | /admin/pagarme/subscriptions/{id}/refund |
Estornar (charge_id?, value? em reais, reason) |
| GET | /admin/pagarme/subscriptions/{id}/refunds |
Estornos e chargebacks |
- Permissao: vale a do menu Asaas — quem ve/opera o painel da Asaas ve/opera o do Pagar.me (sem migrar perfil). Customer e admin sem perfil levam 401/403.
payments: cada item trazid(ch_xxx),status(paid,failed,refunded...),billing_type,valueerefunded_valueem reais.refund: semcharge_idestorna a ultima cobranca paga; com ele, so aceita cobranca desta assinatura (a conta e compartilhada com outra integracao) — senao422.total_refundede somado pelo webhookcharge.refunded(nao pela rota), entao aparece segundos depois.cancel: se o Pagar.me falhar, o cancelamento local vale e a assinatura ganhagateway_cancel_failed=true+gateway_cancel_error.stats: PIX do Pagar.me (pedido avulso) nao entra emmonthly_recurring_revenue.- Estorno sem a assinatura em maos:
POST /admin/refunds/by-chargecomgateway: "pagarme"echarge_id(ch_xxx);404se a cobranca nao e de nenhuma assinatura/pedido nosso. Ver Pagar.me — Integração.
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.
Cancelamento genérico — interrompe recorrência em ambos os gateways
POST /subscriptions/{id}/cancel despacha por gateway e interrompe a
recorrência na fonte tanto para Stripe (cancel imediato) quanto para
Asaas (DELETE da subscription). Marca cancelled no Mongo e limpa
current_subscription_id. Acesso admin (o usuário root é
superusuário e sempre pode). {id} = _id da subscription no Mongo,
não o id do gateway.
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:
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
🔎 Cliente 360 — Triagem Cross-Gateway¶
Endpoint único de triagem de suporte: dado um e-mail ou CPF/CNPJ, agrega numa só resposta tudo o que se sabe do cliente em MongoDB + Stripe + Asaas + Pagar.me. Resolve em uma chamada o que antes exigia consultar cada sistema na mão: contas duplicadas, cobranças avulsas (Payment Link) e faturamento cruzado entre gateways.
GET /api/v1/admin/customers/360¶
Auth: Admin (Bearer). Leitura apenas — não altera nada.
Query params¶
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
query |
string | ✅ | E-mail ou CPF/CNPJ do cliente (mín. 3 caracteres). Detecção automática: contém @ → e-mail; senão → dígitos do documento. |
Response 200¶
{
"query": "cliente@email.com",
"duplicate_accounts": true, // true quando há mais de uma conta (mesmo e-mail/CPF)
"accounts": [ // contas no MongoDB (users) + suas assinaturas
{
"id": "69e22d3e...",
"email": "cliente@email.com",
"full_name": "Fulana de Tal",
"tax_id": "04553347148",
"user_type": "customer",
"email_verified": true,
"current_subscription_id": "69e22d74...",
"stripe_customer_id": null,
"asaas_customer_id": "cus_000171629761",
"created_at": "2026-04-17T12:53:18.566000Z",
"signup_campaign": {"utm_source": "GRS", "utm_campaign": "turma14"}, // UTM do cadastro (ADR-0165); null = sem link. Posição nunca vem aqui (LGPD)
"subscriptions": [
{
"id": "69e22d74...",
"gateway": "asaas",
"status": "active",
"payment_method": "pix",
"expires_at": null,
"payment_failures": 0,
"asaas_subscription_id": "sub_rnyvqtcwdm8dcmjo",
"stripe_subscription_id": null
}
]
}
],
"stripe_charges": [ // TODAS as cobranças Stripe (inclui avulsas sem assinatura)
{ "gateway": "stripe", "id": "ch_...", "date": "2025-10-23",
"value_brl": 19.9, "status": "succeeded", "method": "card",
"refunded": false, "subscription": null }
],
"pagarme_charges": [], // cobranças Pagar.me dos clientes que ESTA API criou (nunca busca por CPF/e-mail: conta compartilhada)
"asaas_charges": [ // TODAS as cobranças Asaas
{ "gateway": "asaas", "id": "pay_...", "date": "2026-05-17",
"value_brl": 19.9, "status": "RECEIVED", "method": "PIX",
"refunded": false, "subscription": "sub_rnyvqtcwdm8dcmjo" }
],
"summary": {
"accounts": 1,
"active_subscriptions": 1,
"total_paid_brl": 45.3, // pago: Stripe succeeded + Asaas RECEIVED/CONFIRMED
"total_refunded_brl": 0.0,
"net_refundable_brl": 45.3 // pago - estornado (estimativa para triagem)
},
"warnings": [] // ex.: "Falha ao consultar a Stripe" (degrada parcial)
}
Notas para o front-end¶
duplicate_accounts: true→ renderizar alerta de conta duplicada; mais de um item emaccounts.- Valores sempre em reais (
value_brl), já normalizados (Stripe converte de centavos). - Cobrança avulsa = item em
stripe_charges/asaas_charges/pagarme_chargescomsubscription: null(típico de Payment Link). - Resiliência: se um gateway falhar, o endpoint retorna os dados parciais + mensagem em
warnings(não dá 5xx por causa de um gateway). - Erros:
403(não-admin),404(nenhum cadastro nem cobrança encontrado),422(query< 3 caracteres).
Exemplo¶
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://fdplay-api.infraifd.com/api/v1/admin/customers/360?query=04553347148"
📱 Aparelhos, perfis, avatares e configurações do app (ADR-0175..0181)¶
Rotas para a ficha do cliente e a configuração das melhorias do app. Todas exigem admin
(assert_admin); os formatos de resposta são os das rotas do cliente.
Liberar as rotas novas no RBAC (obrigatório no deploy)
O RBAC é fail-closed e compara o nome do handler com as listas de users_type
(Perfil de acesso): handler que não está em nenhum perfil responde
401 para todo admin que não seja root. Depois do deploy, rodar uma vez:
FDPLAY_ENV=production PYTHONPATH=. uv run python scripts/setup_rotas_melhorias_do_app.py # relatório
FDPLAY_ENV=production PYTHONPATH=. uv run python scripts/setup_rotas_melhorias_do_app.py --apply # grava
O script é idempotente e só acrescenta: cada rota nova entra nos perfis que já têm a
"irmã" (aparelhos/perfis do cliente = customer_get; desconectar = customer_put;
limites e e-mail semanal = set_pix_billing_mode; avatares = video_post/video_put/video_delete;
legendas = video_put). Também aquece os índices das coleções novas.
Aparelhos do cliente¶
| Método | Rota | Descrição |
|---|---|---|
GET |
/api/v1/admin/customers/{customer_id}/devices |
{max_devices, connected, devices[]} — mesmo formato de GET /customers/me/devices, com current sempre false. max_devices é null enquanto a cobrança do limite estiver desligada. 404 se o cliente não existe |
DELETE |
/api/v1/admin/customers/{customer_id}/devices/{device_id} |
204. Desconecta na hora (auditado). 404 se o aparelho não existe, já saiu ou é de outra conta |
Perfis do cliente (somente leitura)¶
GET /api/v1/admin/customers/{customer_id}/profiles — os perfis com a contagem de uso. Perfil
é da pessoa: o admin só lê. Conta que nunca abriu os perfis devolve [] (não cria o padrão).
[
{"id": "6720a1b2c3d4e5f607182930", "name": "Ana", "avatar_id": "507f1f77bcf86cd799439011",
"avatar_preset_id": null, "color": "#368B99", "is_kids": false, "language": "pt",
"is_default": true, "watch_history_count": 42, "my_list_count": 7}
]
Galeria de avatares¶
Avatares prontos que o cliente escolhe nos perfis (GET /avatars é público —
Perfil).
| Método | Rota | Descrição |
|---|---|---|
GET |
/api/v1/admin/avatars |
Galeria inteira, inclusive desativados. [{id, url, color, order, enabled}] |
POST |
/api/v1/admin/avatars |
201. Multipart: file (JPEG/PNG/WebP, até 5 MB), color (#RRGGBB, obrigatório), order (0–1000, padrão 0) |
PUT |
/api/v1/admin/avatars/{avatar_id} |
JSON com color, order, enabled (só os enviados). Desativar tira da galeria sem mudar quem já usa |
DELETE |
/api/v1/admin/avatars/{avatar_id} |
204. Só se nenhum perfil usa; senão 409 (desative em vez de excluir) |
Configurações¶
| Método | Rota | Corpo | Observações |
|---|---|---|---|
GET |
/api/v1/config/default-max-devices |
— | Público. {default_max_devices, enabled} |
PUT |
/api/v1/admin/config/default-max-devices |
{default_max_devices?: 1–20, enabled?: bool} |
Um ou os dois (nenhum = 400). default_max_devices vale para contas cujo plano não define max_devices (cascata: plano › este › 3). enabled liga a cobrança do limite — nasce desligada; ligar só com o app que manda device_id e trata o 409. Vale para novas entradas |
GET |
/api/v1/config/max-profiles |
— | Público. {max_profiles} |
PUT |
/api/v1/admin/config/max-profiles |
{max_profiles: 1–10} |
Padrão 5. Conta que já passou do novo teto mantém os perfis que tem (vale só para criar) |
GET |
/api/v1/config/weekly-email |
— | Público. {enabled} |
PUT |
/api/v1/admin/config/weekly-email |
{enabled: bool} |
Nasce desligado. Ligado, manda milhares de e-mails reais no domingo 04:00 (Notificações) |
Legendas¶
POST /api/v1/videos/{id}/subtitles e DELETE /api/v1/videos/{id}/subtitles/{lang} (admin) — ver
Streaming.
🔎 Trilha de auditoria¶
ADR-0149 / ADR-0150 · contrato completo: Trilha de auditoria
Toda ação administrativa que altera estado deixa registro: quem, o quê, quando, de
onde e com qual resultado. Append-only — não há PUT nem DELETE, para perfil nenhum.
| Método | Rota | Descrição |
|---|---|---|
| GET | /admin/audit-logs |
Consultar, com filtros |
| GET | /admin/audit-logs/{_id} |
Um registro |
| GET | /admin/audit-logs/verificar |
Conferir o encadeamento por hash |
| GET | /admin/audit-logs/export |
Exportar (xlsx | csv | json), mesmos filtros |
Filtros: start, end, actor_id, action, resource, resource_id,
outcome (success | denied | error), current_page, qty_docs_page.
# tudo que aconteceu com um cliente — responde "por que foi suspenso?"
curl -G "$B/admin/audit-logs" -H "Authorization: Bearer $TOKEN" \
--data-urlencode "resource_id=6aad368bcffa97ed34b9655f"
# tentativas recusadas no período
curl -G "$B/admin/audit-logs" -H "Authorization: Bearer $TOKEN" \
--data-urlencode "outcome=denied" --data-urlencode "start=2026-09-20"
⚠️ Exige o perfil Auditor — não Admin, nunca Global Viewer. Liberar:
scripts/setup_auditoria.py --apply. A própria leitura é registrada (audit.read).
⚠️ Ações administrativas saíram de /webhook-logs. O que aparecia ali com
signature='admin-action' está na trilha desde 20/09; os ~222 registros antigos
permanecem como histórico.
📝 Logs de Webhooks¶
GET /api/v1/webhook-logs - Listar Logs¶
Buscar logs de webhooks recebidos dos gateways. Não é trilha de auditoria — é o que o gateway informa, não o que alguém faz no painel (ver a seção acima).
Headers:
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:
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:
-
Verificar
current_subscription_iddo customer: -
Verificar status da subscription:
-
Verificar logs de webhooks:
Solução:
Se current_subscription_id estiver null mas subscription estiver ativa:
Webhook Não Processado (processed=false)¶
Diagnóstico:
Causas Comuns:
- Subscription não encontrada:
Solução: Verificar se subscription foi criada antes do webhook. Se não, criar manualmente.
- Assinatura inválida:
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:
- Upload da imagem via
POST /api/v1/archive-records - Atualizar admin com o
file_idretornado
PUT /api/v1/users
Content-Type: application/json
Authorization: Bearer <admin_token>
[{
"_id": "<admin_id>",
"avatar_id": "507f1f77bcf86cd799439012"
}]
Buscar Avatar¶
Ou sem header (para Image.network, <img>, etc.):
Para miniaturas, peça a imagem já reduzida e em WebP — ?w=400 derruba uma página de 10
cards de ~12,5 MB para menos de 300 KB. Ver Entrega de Imagens.
⚙️ 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):
Todos os Gateways Disponíveis
A resposta ("asaas", "stripe" ou "pagarme") indica apenas a preferência do admin. Os três 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:
Request:
| Campo | Tipo | Valores | Descrição |
|---|---|---|---|
gateway |
string |
"asaas" | "stripe" | "pagarme" |
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 |
| 409 | gateway: "pagarme" em ambiente sem as chaves do Pagar.me, ou com a trava ligada (allow_as_default = false no credentials.toml, o padrão) |
| 422 | Valor de gateway inválido (não é "asaas", "stripe" nem "pagarme") |
Só selecione pagarme com os apps atualizados
O app do cliente (payment_config.dart) só conhece stripe e asaas: um valor que ele não reconhece cai em Stripe. Selecionar pagarme antes de o app tokenizar o cartão (GET /pagarme/config + card_token) manda os clientes para a Stripe sem aviso. Por isso a API recusa a seleção (409) até alguém ligar allow_as_default = true no credentials.toml do ambiente.
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'}),
);
🧭 Controle de Versão do Cliente¶
Permite ao usuário root definir a versão compatível dos apps cliente. Cada front (customer e admin) chama o GET na inicialização, compara com ${PackageInfo.version}+${PackageInfo.buildNumber} local e — se diferente — exibe um dialog não-fechável solicitando atualização.
Decisão: igualdade exata (réplica do padrão
version_compatible_view). Sem grace period, sem distinção de plataforma. Atualizar a string força update simultâneo de Android+iOS+Web do app afetado. Ver ADR-060.
GET /api/v1/config/client-version — Consultar Versão Compatível¶
Endpoint público (sem autenticação). Query param app obrigatório.
Query params:
| Campo | Tipo | Valores | Descrição |
|---|---|---|---|
app |
string |
"customer" | "admin" |
Qual front está consultando |
Response (200 OK):
Quando o admin nunca definiu a versão, version_compatible_view retorna null — front interpreta como "sem gate" e não bloqueia.
PUT /api/v1/admin/config/client-version — Definir Versão (root-only)¶
Headers:
Request:
| Campo | Tipo | Descrição |
|---|---|---|
app |
string |
"customer" ou "admin" |
version |
string |
Versão compatível no formato {version}+{buildNumber} (ex.: 1.0.0+22) |
Response (200 OK):
{
"message": "Client version updated",
"app": "customer",
"version_compatible_view": "1.0.0+22",
"updated_by": "root",
"updated_at": "2026-05-16T12:00:00+00:00"
}
Erros:
| Status | Quando |
|---|---|
| 400 | Campo version vazio |
| 401 | Token ausente ou inválido |
| 403 | Usuário não é root (admin comum não pode alterar) |
| 422 | app fora de customer/admin |
Exemplo Flutter (Front Cliente — check na inicialização)¶
final pkg = await PackageInfo.fromPlatform();
final local = '${pkg.version}+${pkg.buildNumber}';
final resp = await http.get(
Uri.parse('$baseUrl/api/v1/config/client-version?app=customer'),
);
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final remote = data['version_compatible_view'] as String?;
if (remote != null && remote != local) {
// dialog não-fechável: "Você está usando uma versão desatualizada..."
}
Exemplo Flutter (Admin — atualizar versão como root)¶
final resp = await http.put(
Uri.parse('$baseUrl/api/v1/admin/config/client-version'),
headers: {'Authorization': 'Bearer $rootToken', 'Content-Type': 'application/json'},
body: jsonEncode({'app': 'customer', 'version': '1.0.1+23'}),
);
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) |
| GET | /customers?utm_source= |
Listagem filtrada pela origem do cadastro (detalhe) |
| GET | /admin/customers/signup-origins |
Ranking de cadastros por origem (detalhe) |
Origem do cadastro no painel¶
Cada parceiro recebe um link https://fdplay.com.br/?utm_source=X; o app grava a campanha no
cadastro (ADR-0165) e o painel filtra e ranqueia por ela (ADR-0166).
Onde a campanha aparece: signup_campaign em GET /customers, GET /customers/me e em
accounts[] do GET /admin/customers/360. null = cadastro sem link (inclusive todos os
anteriores a 06/10/2026). A posicao (signup_location) nao deve ser exibida por cliente
no painel (LGPD); o 360 nem a devolve.
Filtro na listagem¶
| Param | Regra |
|---|---|
utm_source, utm_medium, utm_campaign |
Igualdade exata, sem diferenciar maiusculas (grs acha GRS; GR nao acha). Combinam entre si e com query por E logico; o query nao sobrescreve estes filtros |
O export aceita o mesmo utm_source e traz as colunas de origem (export).
GET /admin/customers/signup-origins — Ranking por origem¶
GET /api/v1/admin/customers/signup-origins?start=2026-10-01&end=2026-10-31
Authorization: Bearer <admin_token>
| Param | Tipo | Obrigatorio | Regra |
|---|---|---|---|
start |
YYYY-MM-DD |
❌ | Dia de Brasilia, sobre a data do cadastro |
end |
YYYY-MM-DD |
❌ | Dia de Brasilia, inclusivo. Sem start/end = base inteira |
Response 200
{
"applied_filters": {"start": "2026-10-01", "end": "2026-10-31"},
"total_signups": 179,
"origins": [
{"utm_source": "GRS", "signups": 42, "with_active_subscription": 30},
{"utm_source": "instagram", "signups": 17, "with_active_subscription": 9},
{"utm_source": null, "signups": 120, "with_active_subscription": 80}
]
}
- Ordenado por
signupsdecrescente;utm_source: null(sem link) sempre por ultimo. GRS,grseGrssao uma linha, exibida com a grafia mais frequente.with_active_subscription: customers do grupo com assinatura de status efetivoactiveagora (o mesmo do controle de acesso). Inclui acesso por ingresso e promo — nao e sinonimo de pagante.
| Status | Quando |
|---|---|
| 401/403 | Sem token / nao e admin |
| 422 | Data fora de YYYY-MM-DD ou start depois de end |
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_ateemail_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:
- Valida que o customer e o plan existem
- Cancela subscription ativa anterior (se existir)
- Cria nova subscription com
status='active' - Vincula ao customer via
current_subscription_id - 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¶
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.0196,
"chargeback_rate": 0,
"failure_rate": 0.0712,
"ticket_uniformity": "all_basic"
},
"gateway_distribution": {
"dominant": "asaas",
"stripe_share": 0.13,
"asaas_share": 0.87,
"pagarme_share": 0.0
},
"recurrence_distribution": {
"label": "mostly_recurring",
"one_time_share": 0.0179,
"one_time_count": 128,
"recurring_count": 7031
},
"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.refund_rate = total_refunded_cents ÷ gross_paid_cents, as duas
pontas do mesmo razão e do mesmo período.
⚠️ Mudou em 21/09 (ADR-0158). A razão anterior dividia o estorno acumulado desde sempre pelo MRR mensal — estoque sobre fluxo. Media "quantos meses de MRR já foram estornados" e crescia para sempre, então nenhum limiar abaixo descrevia comportamento de cliente. Se o painel recalculava por conta própria, remova o contorno: a API devolve 1,96% e a conta antiga devolvia 9,5%.
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 (sem contrato recorrente — ADR-0141) |
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 (one_time_count = sem contrato recorrente) |
COMPLETE_SIGNUP_CAMPAIGN |
sem estimativa direta — listada quando signup_incomplete > 0 |
Ticket médio =
mrr_cents / active_count(ou média deplans.amountse 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¶
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": 7031,
"one_time": 128
},
"by_pix_modality": {
"automatic": { "paying": 254, "mrr_cents": 505460 },
"monthly_manual": { "paying": 636, "mrr_cents": 1265640 },
"yearly": { "paying": 52, "mrr_cents": 87928 },
"one_time_last_30_days": { "customers": 0, "payments": 0, "unidentified_payments": 1 },
"one_time_all_time": { "customers": 0, "payments": 0, "unidentified_payments": 61 }
},
"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": 432760,
"refund_count": 121,
"gross_paid_cents": 22123618,
"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, Asaas e Pagar.me (pagarme aparece com total, status, paying e mrr_cents como os demais)
Cada gateway tem total, contagem por status gravado, paying (pagantes, ADR-0135) e mrr_cents dos pagantes.
by_payment_method — distribuicao de metodos
| Metodo | Comportamento |
|---|---|
credit_card |
Cobrança recorrente automatica |
pix |
Depende da modalidade — ver by_pix_modality |
boleto |
Recorrente (Asaas) |
by_recurrence — tem contrato que renova no gateway? (ADR-0141, todos os status)
| Tipo | Composicao |
|---|---|
recurring |
Com asaas_subscription_id, asaas_pix_auth_id ou stripe_subscription_id — cartão e PIX automático, mensal manual e anual |
one_time |
Sem contrato: PIX Stripe, acesso por ingresso, Payment Link, legado |
Mudou em 16/09/2026
Antes, one_time = todo PIX (4.877) e recurring = cartão + boleto. PIX Automático e contrato PIX mensal/anual renovam na Asaas: eram contados errado.
by_pix_modality — PIX por modalidade, com a regra da Asaas (ADR-0141)
| Campo | Quem conta |
|---|---|
automatic |
Pagante PIX com autorização do PIX Automático: a Asaas debita cada ciclo |
monthly_manual |
Pagante PIX com contrato mensal sem autorização: QR todo mês, pago à mão |
yearly |
Pagante PIX com contrato anual sem autorização |
one_time_last_30_days / one_time_all_time |
Avulso: PIX pago sem contrato na Asaas por cliente sem nenhuma assinatura recorrente |
payingemrr_centsusam a regra de pagante (ADR-0135); o ciclo vem do contrato espelhado da Asaas.automatic + monthly_manual + yearly= pagantes PIX de/admin/finance/overview(by_payment_method.pix.count).- Avulso não é pagante (pagante exige contrato), por isso fica à parte.
customers= clientes distintos;payments= cobranças deles. unidentified_payments= PIX pago sem contrato cujo pagador não é cliente aqui (ex.: PIX direto na chave): não dá para confirmar se tem assinatura, então não entra emcustomers.- A 1ª cobrança do PIX Automático chega sem
subscriptionna Asaas (DEBT-065): o cliente tem assinatura, então não é avulso. - Janela de 30 dias = hoje e os 29 dias anteriores, dia de Brasília (ADR-0137), pela data do pagamento.
by_status_x_gateway — cross-tab para identificar onde concentra cada estado.
financial — KPIs em centavos
| Campo | Calculo |
|---|---|
mrr_cents |
MRR dos pagantes (ADR-0135): mensal = plan.amount, anual = pago / 12. Pagante = acesso efetivo e, na Asaas, pagamento que cobre hoje |
arr_cents |
mrr_cents × 12 |
average_subscription_value_cents |
mrr / paying_subscriptions |
paying_subscriptions |
Pagantes; by_status.active segue sendo o status gravado |
total_refunded_cents |
Estornado desde sempre, lido do razão de pagamentos (ADR-0132/0158) — a mesma fonte de /admin/finance/payments.totals.refunded_cents |
refund_count |
Quantos estornos compõem o total |
gross_paid_cents |
Pago desde sempre; é o denominador de financial_health.refund_rate |
chargeback_count |
Count de has_chargeback=true |
with_payment_failures |
Count de payment_failures > 0 |
⚠️ Mudou em 21/09 (ADR-0158).
total_refunded_centssomavasubscriptions.total_refunded, campo desnormalizado que só conhece o estorno que o webhook amarrou a uma assinatura viva — perdia o avulso, o de ingresso e o da assinatura já apagada. Media 129.134 em 48 assinaturas contra 432.760 em 121 estornos no razão.refund_countegross_paid_centsexistem para o número ser conferível: foi a ausência deles que deixou a divergência indevassável.
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 |
| Quanto do PIX é débito automático? | by_pix_modality.automatic.paying ÷ soma das 3 modalidades |
| Quem ainda paga QR à mão | by_pix_modality.monthly_manual.paying (candidatos a migrar para o PIX Automático) |
| Base sem renovação | 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, asaas e pagarme 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
by_recurrence.one_time conta só o que não tem contrato. Para o PIX, o risco real é o by_pix_modality.monthly_manual: renova na Asaas, mas depende de o cliente pagar o QR todo mês.
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> byPixModality;
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'],
byPixModality = json['by_pix_modality'],
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/exportou/subscriptionspaginado.
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.
Para montar tela, use a contraparte JSON
GET /admin/customers/tenure devolve as mesmas linhas paginadas em JSON,
com KPIs agregados e a procedência de cada número — veja
Permanência & Adimplência.
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 |
❌ | all |
active, pending, suspended, cancelled, expired, without_subscription, all |
status |
string repetível |
❌ | — | Mesmos valores de segment (sem all). Filtra pelo status efetivo da assinatura mais recente. Combina com segment por E lógico. Ex.: ?status=active&status=pending |
search |
string |
❌ | — | Nome, e-mail, CPF/CNPJ ou telefone (parcial, case-insensitive) |
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 |
utm_source |
string |
❌ | — | Origem do cadastro, igualdade exata sem diferenciar maiusculas (ADR-0166) |
token |
string |
❌ | — | JWT alternativo ao header (uso por browser download) |
segmentvsstatus:segmenté seleção única, aplicada como$matchno banco, e usa o status armazenado.statusé multi-seleção e usa o status efetivo (o mesmo que o controle de acesso aplica). Para filtro de tela, prefirastatus.
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 |
Origem (utm_source) |
string ou vazio |
users.signup_campaign.utm_source — vazio = cadastro sem link (ADR-0166) |
Mídia (utm_medium) |
string ou vazio |
users.signup_campaign.utm_medium |
Campanha (utm_campaign) |
string ou vazio |
users.signup_campaign.utm_campaign |
Status (armazenado) |
string ou vazio |
subscriptions.status (mais recente) |
Status (efetivo) |
string |
Status que o controle de acesso aplica: active vencido → expired, falhas de pagamento acumuladas → suspended |
subscription_gateway |
string ou vazio |
subscriptions.gateway — apenas 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 |
Meses cadastrado |
int |
Meses de calendário desde created_at, contando o mês atual (mín. 1) |
Meses pagos |
int |
Meses distintos com pagamento (ver nota de procedência) |
Meses pagos (origem) |
string |
Confirmado | Estimado | Parcialmente estimado | Sem pagamento |
Adimplência |
percent |
Meses pagos / Meses cadastrado, em % |
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, somaplan.amount × ciclos, ondeciclos = ceil((last_payment_at − started_at).days / 30) + 1. Aproximação para LTV — não substitui relatório financeiro do gateway.
Meses pagos nem sempre é um número observado
É confirmado pelo log de pagamentos a partir do primeiro evento registrado
(hoje 2026-03); antes disso é estimado pela janela da assinatura. A coluna
Meses pagos (origem) diz qual é o caso, linha a linha. Detalhe completo em
Permanência & Adimplência.
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 OK —
Content-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:
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).
Ou via URL direta:
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.
Ou via URL direta:
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¶
- Exportacao de relatorios - CSV/Excel de assinaturas, pagamentos e reembolsos
- Notificacoes em tempo real - WebSocket para admins (nova assinatura, cancelamento, chargeback)
- Analise de cohorts - Retention por mes de signup
- Previsao de receita - MRR projetado baseado em historico (deduzindo reembolsos)
- Automacao de retry - Reprocessar webhooks com falha automaticamente
- Dashboard de chargebacks - Visao consolidada de disputas e seus status
- Alertas de chargeback - Notificacao automatica via email/Slack quando chargeback detectado