Skip to content

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/docsCustomersTenureResponse.


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:

months_paid_confirmed + months_paid_estimated == months_paid

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: active vencido vira expired; com falhas de pagamento acumuladas vira suspended.
  • 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_pages já vem pronto.
  • [ ] status múltiplo depende de ListFormat.multi (padrão do dio) — confira se o BaseOptions do 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_cents está 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