Permanência & Adimplência de Clientes (Admin)¶
Tabela que responde, por cliente, há quantos meses ele está cadastrado e em quantos desses meses ele pagou — com filtro por status de assinatura.
Duas rotas, o mesmo cálculo por trás: uma devolve JSON para a tela, a outra devolve planilha. Tela e planilha nunca divergem.
Prefixo: /api/v1/admin/customers
Autenticação: JWT + user_type='admin'
Endpoints¶
| Método | Endpoint | Devolve |
|---|---|---|
| GET | /admin/customers/tenure |
JSON paginado — é daqui que a tela lê |
| GET | /admin/customers/export |
Planilha xlsx / csv / json |
Autenticação¶
Header Authorization: Bearer <jwt> ou query ?token=<jwt>.
O ?token= existe para download direto pelo navegador (um <a download> não
consegue mandar header).
curl -H "Authorization: Bearer $JWT" \
"https://fdplay-api.infraifd.com/api/v1/admin/customers/tenure?status=active&status=pending&sort=payment_rate_pct&order=asc&page=0&limit=50"
1. GET /admin/customers/tenure¶
Query Params¶
| Param | Tipo | Default | Descrição |
|---|---|---|---|
status |
enum repetível | — | ?status=active&status=pending. Filtra pelo status efetivo (ver §3.2). |
segment |
enum única | all |
Filtro alternativo, aplicado no banco. Combina com status por E lógico. Usando só status, deixe segment de fora. |
gateway |
stripe | asaas |
— | |
start / end |
YYYY-MM-DD |
— | Data de cadastro, não de pagamento. |
search |
string |
— | Nome, e-mail, CPF/CNPJ ou telefone. Parcial, case-insensitive. |
sort |
enum | signup_date |
months_paid, months_registered, payment_rate_pct, signup_date, full_name |
order |
asc | desc |
desc |
|
page |
int ≥ 0 |
0 |
Base 0. |
limit |
int 1–500 |
50 |
|
token |
string |
— | JWT alternativo ao header. |
Valores de status / segment: active, pending, suspended, cancelled,
expired, without_subscription (+ all, só em segment).
Response (200 OK)¶
{
"meta": {
"generated_at": "2026-07-28T20:48:51.402000+00:00",
"coverage_start_month": "2026-03",
"coverage_note": "Meses pagos são confirmados pelo log de pagamentos a partir de 03/2026. Antes disso — e para clientes sem nenhum pagamento identificado no log — o valor é estimado pela janela da assinatura (início → último pagamento). A coluna \"origem\" indica caso a caso.",
"status_options": [
{ "value": "active", "label": "Ativo", "count": 3521 },
{ "value": "pending", "label": "Pendente", "count": 239 }
],
"source_options": [
{ "value": "confirmed", "label": "Confirmado", "count": 4330 },
{ "value": "estimated", "label": "Estimado", "count": 294 },
{ "value": "mixed", "label": "Parcialmente estimado", "count": 1 },
{ "value": "none", "label": "Sem pagamento", "count": 4684 }
],
"export_url": "/api/v1/admin/customers/export?segment=all&format=xlsx"
},
"summary": {
"total_customers": 9309,
"customers_with_payment": 4625,
"customers_never_paid": 4684,
"by_status": { "active": 3521, "pending": 239, "without_subscription": 4157 },
"by_paid_months_source": { "confirmed": 4330, "estimated": 294, "mixed": 1, "none": 4684 },
"avg_months_registered": 2.9,
"avg_months_paid": 0.8,
"avg_payment_rate_pct": 29.0,
"total_revenue_cents": 17659260
},
"page": 0,
"limit": 50,
"total": 9309,
"total_pages": 187,
"items": [
{
"customer_id": "696d2dec82496bec14679b63",
"full_name": "Ana Paula de Aragão Alves",
"email": "anapaula@example.com",
"tax_id": "",
"phone": "",
"signup_date": "2025-06-29T14:59:07.628000+00:00",
"subscription_status": "active",
"subscription_status_effective": "active",
"subscription_status_label": "Ativo",
"subscription_gateway": "stripe",
"plan_name": "Plano Mensal Básico",
"last_payment_date": "2026-03-09T20:00:58.853000+00:00",
"months_registered": 14,
"months_paid": 6,
"months_unpaid": 8,
"payment_rate_pct": 42.9,
"months_paid_confirmed": 0,
"months_paid_estimated": 6,
"months_paid_source": "estimated",
"months_paid_source_label": "Estimado",
"total_revenue_cents": 23880,
"chargeback_detected": false
}
]
}
Schema navegável: Swagger em /api/v1/docs → CustomersTenureResponse.
2. GET /admin/customers/export¶
Mesmos filtros do /tenure (menos sort, order, page, limit), mais
format=xlsx|csv|json (default xlsx). Limite de ~50k linhas por aba.
Colunas: Customer ID · Nome · Email · CPF/CNPJ · Telefone · Cadastro · Status (armazenado) · Status (efetivo) · Gateway · Plano · Último Pagamento · Meses cadastrado · Meses pagos · Meses pagos (origem) · Adimplência · Receita Total · Chargeback.
3. Os três pontos que exigem decisão de UI¶
3.1 months_paid_source — não exiba estimativa como fato¶
Nem todo months_paid é um número observado
O log de pagamentos da API só existe a partir de meta.coverage_start_month
(hoje 2026-03), mas há cliente cadastrado desde 2025-06. Fora dessa janela o
backend estima pelo período da assinatura, assumindo cobrança
ininterrupta.
months_paid_source |
months_paid_source_label |
Significado | Como exibir |
|---|---|---|---|
confirmed |
Confirmado | tudo tem lastro no log | número puro |
estimated |
Estimado | tudo é estimativa | número + asterisco/badge |
mixed |
Parcialmente estimado | parte confirmada, parte estimada | número + asterisco/badge |
none |
Sem pagamento | nunca pagou | — ou 0 |
Os contadores vêm abertos e são disjuntos:
Dá para montar o tooltip "6 meses — 4 confirmados, 2 estimados" sem conta no front.
Escala real hoje
4330 confirmed · 294 estimated · 1 mixed · 4684 none.
O asterisco aparece em ~295 linhas, não na tabela toda. Não desenhe a tela em
torno da exceção — mas ela precisa estar visível.
Use meta.coverage_note como rodapé da tabela, literal. É uma frase PT-BR
pronta e se atualiza sozinha conforme a cobertura do log avança; reescrita à mão,
desatualiza.
3.2 Status: exiba o efetivo, não o armazenado¶
subscription_status— o que está gravado na assinatura.subscription_status_effective— o que o controle de acesso de fato aplica:activevencido viraexpired; com falhas de pagamento acumuladas virasuspended.subscription_status_label— o efetivo já em PT-BR.
Renderize subscription_status_label. O filtro status também opera sobre o
efetivo, então tabela e filtro batem.
Note
Em produção os dois divergem em ~10 clientes — exatamente os casos em que o cliente leva 403 no app mas o painel diria "Ativo".
3.3 Meses e adimplência¶
| Campo | Semântica |
|---|---|
months_registered |
Meses de calendário desde o cadastro, contando o mês atual. Quem se cadastrou ontem tem 1, nunca 0. |
months_paid |
Meses distintos com pagamento. Nunca maior que months_registered. |
months_unpaid |
A diferença, já calculada. |
payment_rate_pct |
months_paid / months_registered × 100, uma casa decimal. Já vem em escala 0–100 — não multiplique de novo. |
4. meta — não hardcode vocabulário¶
status_options e source_options trazem valor + rótulo PT-BR + contagem no
recorte atual. Popule os selects e a legenda a partir deles: se o backend ganhar
um status novo, o filtro aparece sozinho.
export_url é a mesma consulta já montada como planilha — o botão "Exportar" é só
abrir essa URL concatenando &token=<jwt>.
5. Integração Flutter (dio)¶
class TenureCustomer {
final String customerId, fullName, email, statusLabel, sourceLabel, source;
final int monthsRegistered, monthsPaid, monthsUnpaid;
final int monthsPaidConfirmed, monthsPaidEstimated;
final double paymentRatePct;
TenureCustomer.fromJson(Map<String, dynamic> j)
: customerId = j['customer_id'] ?? '',
fullName = j['full_name'] ?? '',
email = j['email'] ?? '',
statusLabel = j['subscription_status_label'] ?? '',
sourceLabel = j['months_paid_source_label'] ?? '',
source = j['months_paid_source'] ?? 'none',
monthsRegistered = j['months_registered'] ?? 0,
monthsPaid = j['months_paid'] ?? 0,
monthsUnpaid = j['months_unpaid'] ?? 0,
monthsPaidConfirmed = j['months_paid_confirmed'] ?? 0,
monthsPaidEstimated = j['months_paid_estimated'] ?? 0,
paymentRatePct = (j['payment_rate_pct'] ?? 0).toDouble();
/// Sinaliza na UI quando o número não é 100% observado.
bool get isEstimated => source == 'estimated' || source == 'mixed';
}
status vai como lista repetida. O dio já usa ListFormat.multi por padrão (gera
?status=a&status=b); explícito abaixo só para documentar a intenção e sobreviver
a um BaseOptions que tenha mudado isso globalmente:
final res = await dio.get(
'/api/v1/admin/customers/tenure',
queryParameters: {
'status': ['active', 'pending'], // vira ?status=active&status=pending
'search': termo,
'sort': 'payment_rate_pct',
'order': 'asc',
'page': page,
'limit': 50,
},
options: Options(
headers: {'Authorization': 'Bearer $jwt'},
listFormat: ListFormat.multi,
),
);
Célula com a procedência:
Widget monthsPaidCell(TenureCustomer c) {
if (c.source == 'none') return const Text('—');
return Tooltip(
message: c.isEstimated
? '${c.monthsPaid} meses — ${c.monthsPaidConfirmed} confirmados, '
'${c.monthsPaidEstimated} estimados'
: '${c.monthsPaid} meses confirmados',
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text('${c.monthsPaid}'),
if (c.isEstimated) const Text(' *', style: TextStyle(color: Colors.amber)),
]),
);
}
6. Checklist / pegadinhas¶
- [ ]
pageé base 0.total_pagesjá vem pronto. - [ ]
statusmúltiplo depende deListFormat.multi(padrão do dio) — confira se oBaseOptionsdo projeto não sobrescreveu. - [ ]
summaryé do recorte filtrado, não da base inteira — os KPIs mudam ao filtrar, é intencional. - [ ] A resposta é montada sobre a base inteira a cada request (~3s em produção,
9.3k clientes). Trate como tela de relatório: mostre loading e use debounce
no
search. - [ ]
total_revenue_centsestá em centavos e é aproximado. - [ ] Datas em ISO-8601 UTC — converta para o fuso do usuário na exibição.
- [ ] Botão "Exportar" =
meta.export_url+&token=<jwt>. - [ ] Rodapé da tabela =
meta.coverage_note, literal.
7. Limitação conhecida¶
Sem RBAC por perfil
O endpoint não passa pelo RBAC por perfil (users_type.view) — usa o mesmo
portão do export, que é user_type == 'admin'. Na prática qualquer admin vê
a tabela, independente do perfil (Financial Manager, Global Viewer etc.).
Se for preciso restringir, é mudança no backend — não resolva escondendo no
front.
Ver também: Admin Dashboard · Dashboard Financeiro