Skip to content

Assinaturas e Faturamento

Última atualização: 2026-04-20

Adapter centralizado: Toda comunicação HTTP com o Mercado Pago está em _shared/payment-gateway.ts. Para trocar de provedor, alterar apenas esse arquivo.

URLs dinâmicas: Links de pagamento e back URLs são gerados via _shared/environment.ts, derivando o domínio base (olp.digital ou staging.olp.digital) automaticamente a partir do SUPABASE_URL.

Canal de notificação: Lembretes e avisos de faturamento são enviados via WhatsApp (Wasender). Ref: docs/architecture/THIRD_PARTY_INTEGRATIONS.md

Contrato de recálculo (R-002, 2026-04-20): As actions upgrade_downgrade_plan, update_valor_personalizado, recalculate_due_date e recalculate_by_students retornam data.faturas_ids: string[] (UUIDs das faturas afetadas) além do contador faturas_atualizadas. Quando o frontend dispara múltiplas actions em sequência sobre o mesmo conjunto de faturas (ex: EditarAssinaturaModal ao mudar valor + dia + alunos), o totalizador exibido ao usuário DEVE acumular IDs em Set<string> para evitar contagem duplicada. Histórico em supabase/functions/admin-assinaturas/__tests__/regressions.md.


1. Planos

Tabela: planos

CampoTipoDescrição
nometextNome do plano
preco_mensalnumericPreço base mensal
preco_anualnumericPreço anual (se aplicável)
valor_por_aluno_extranumericValor por aluno contratado (multiplicado pelo total)
alunos_minimo / alunos_maximointFaixa de elegibilidade
trial_diasintDuração do trial em dias
is_trialboolSe é plano de teste
tipo_cobrancatextmensal ou anual
featuresjsonbFeatures habilitadas

Fórmula de Cálculo

Valor Mensal = Preço Base + (Alunos Contratados × Valor por Aluno Extra)

2. Assinaturas

Tabela: escola_assinaturas

Ciclo Contratual

  • data_inicio → início do contrato
  • meses_contratados → duração total (calculado a partir do range mes_iniciodata_fim_contrato)
  • data_fim_contrato → último dia do mês final do contrato
  • dia_vencimento → dia do mês para faturas (1-28, default 10)
  • renovacao_automatica → se renova ao expirar

Criação de Assinatura (CreateAssinaturaSchema)

A criação de assinatura usa validação Zod com os seguintes campos:

CampoTipoRegra
escola_idUUIDObrigatório
plano_idUUIDObrigatório
status_assinaturaenum'ativa' ou 'trial' (default: 'ativa')
alunos_contratadosintMínimo 1
dia_vencimentoint1-28 (default 10)
mes_iniciostringYYYY-MM, deve ser posterior ao mês atual
data_fim_contratostringYYYY-MM, >= mes_inicio
valor_personalizadonumberOpcional, override do cálculo automático
renovacao_automaticabooleanDefault true

Geração de faturas por range: Ao criar a assinatura, são geradas faturas skeleton (sem link_pagamento) para cada mês do range [mes_inicio, data_fim_contrato]. O cron faturamento-cron completa esses skeletons.

Status

StatusSignificado
trialPeríodo de teste (validado por trial_ate)
ativaAssinatura paga ativa
suspensaPagamento atrasado — acesso bloqueado
canceladaCancelamento solicitado
encerradaContrato finalizado

Gateway MercadoPago

CampoUso
gateway_customer_idID do cliente no MercadoPago
gateway_subscription_idID da assinatura (se recorrente)

3. Faturas

Tabela: escola_faturas

Numeração Sequencial

Trigger gerar_numero_fatura gera automaticamente: OLP-2026-0001, OLP-2026-0002, etc.

Status de Pagamento

StatusDescrição
pendenteGerada, aguardando pagamento
pagoPagamento confirmado
atrasadoVencimento ultrapassado
canceladoFatura cancelada

Integração MercadoPago

CampoDescrição
gateway_preference_idPreference ID do Checkout Pro
link_pagamentoURL init_point para pagamento (uso interno do painel)
gateway_payment_idID do pagamento confirmado
pix_qrcode / pix_copia_colaDados PIX
boleto_pdf_url / codigo_barrasDados do boleto
lembrete_d5_enviado_emTimestamp do envio do lembrete D-5 (evita reenvio)

Fluxo de Pagamento

1. Admin/cron gera link → Edge Function cria Preference no MercadoPago
2. preference_id salvo em gateway_preference_id
3. init_point salvo em link_pagamento (uso exclusivo do painel)
4. WhatsApp enviado SEM link — apenas informativo direcionando ao painel
5. Usuário acessa painel → "Pagamentos" → botão "Efetuar Pagamento" → MercadoPago
6. Webhook recebe notificação → valida pagamento → atualiza fatura

Nota: O pipeline anterior de URL curta (olp.digital/pagar/{token}) foi descontinuado em 2026-04-16. Mensagens WhatsApp não contêm links de pagamento — o link_pagamento é consumido apenas pelo CheckoutLinkDialog dentro do painel autenticado.


4. Geração Automática de Faturas

Job: gerar-faturas-mensal

  • Edge Function: faturamento-cron (action gerar_faturas)
  • Schedule: 0 6 28-31 * * (dias 28-31 de cada mês, 03:00 BRT / 06:00 UTC)
  • Lógica: Roda nos dias 28-31 mas só executa se amanhã.getDate() === 1 (último dia do mês)
  • Paradigma: O cron completa faturas existentes (skeleton → com link MP + WhatsApp + notificação). Se não existir skeleton, cria nova fatura (fallback).
  • Guard: Uma fatura é considerada completa quando link_pagamento IS NOT NULL. Skeletons (criados pelo create_assinatura) são elegíveis para processamento.
  • Regra: Ciclo anual encerra em dezembro — não gera faturas além desse mês
  • Alerta: Se existem assinaturas ativas mas nenhuma fatura foi processada, dispara alerta faturamento.alerta_faturas_sem_link via ntfy

Regra de Ouro: "Sempre Mês Seguinte"

A primeira fatura de novos planos ou alterações é gerada para o primeiro dia do mês subsequente, evitando cobranças imediatas.

Recálculo Automático

Ao alterar alunos_contratados ou dia_vencimento, faturas pendentes futuras são recalculadas automaticamente.


5. Lembrete D-5

Job: lembrete-d5-diario

  • Edge Function: faturamento-cron (action lembrete_d5)
  • Schedule: 0 9 * * * (diariamente, 06:00 BRT / 09:00 UTC)
  • Lógica: Busca faturas pendentes com vencimento em 5 dias que ainda não receberam lembrete (lembrete_d5_enviado_em IS NULL)
  • Canal: Mensagem enviada via WhatsApp (Wasender) para o contato da escola
  • Controle: Após envio bem-sucedido, marca lembrete_d5_enviado_em = now() para evitar reenvios

Comportamento

CenárioAção
Fatura pendente, vence em 5 dias, sem lembreteEnvia WhatsApp + marca timestamp
Fatura pendente, vence em 5 dias, já lembradaIgnora (idempotente)
Fatura paga ou canceladaIgnora

6. Trial

Verificação: subscription-helper.ts

typescript
const resultado = await verificarAssinaturaEscola(supabase, escolaId, 'sistema');
if (!resultado.valida) {
  // resultado.code: 'TRIAL_EXPIRADO' | 'SEM_ASSINATURA'
}

Comportamento

CenárioResultado
Assinatura ativa ou trial válidovalida: true
Trial expiradovalida: false, code: 'TRIAL_EXPIRADO'
Sem assinaturavalida: false, code: 'SEM_ASSINATURA'
Erro técnicovalida: true (fail-open)

Onde é Verificado

ContextoEdge Function
Login sistemasend-otp, verify-otp
Portal alunoportal-escola (lookup)

7. Ciclo Contratual Multi-Ano

Action: get_ciclo_contratual (admin-faturas)

Retorna dados completos do ciclo contratual de uma escola, com filtro por ano.

Parâmetros: escola_id (obrigatório), ano (opcional — default: ano corrente)

Retorno:

CampoDescrição
faturasLista de faturas do ano filtrado
metricasCálculos do ano: total_faturas, pagas, pendentes, valor_total, valor_pago, valor_pendente, taxa_adimplencia
metricas_geraisCálculos de todo o contrato (sem filtro de ano)
assinaturaDados da assinatura ativa
planoDados do plano
anos_disponiveisLista de anos com faturas

Nota: No frontend, metricas_gerais é independente do filtro de ano — ao trocar de ano, apenas a seção superior recarrega.


8. Administração

Edge Functions

FunçãoAções Principais
admin-assinaturasCRUD de assinaturas, recálculo de faturas
admin-faturasListagem, get_ciclo_contratual, geração manual de links, marcação de pago
admin-planosCRUD de planos
admin-escola-dadoscreate_assinatura (validação Zod CreateAssinaturaSchema) — criação na tela de detalhes
escola-pagamentosVisão da escola: faturas, links de pagamento (usado por gestão/diretor)
mercadopago-preferenceGerar link de pagamento (Checkout Pro)
mercadopago-webhookReceber notificações de pagamento
faturamento-cronGeração mensal de faturas + lembrete D-5

Hooks Frontend

HookUso
useAdminAssinaturasGestão de assinaturas (admin)
useAdminFaturasGestão de faturas (admin)
useAdminPlanosGestão de planos (admin)
useMercadoPagoGeração de links e polling (admin)
useEscolaPagamentosFaturas e pagamentos (visão escola — gestão/diretor)

Edição de Assinatura Vigente

A tela de detalhes da escola (aba Planos & Assinaturas) permite edição completa via EditarAssinaturaModal: plano, alunos, valor mensal personalizado, dia de vencimento, mês final do contrato e renovação automática. O mes_inicio é imutável.

Regra de recálculo de faturas

Status da faturaComportamento
pagoNunca tocada, independentemente de qualquer flag.
pendente (mês corrente)Intocada por padrão. Admin pode optar via checkbox "Aplicar também à fatura de [mês]".
pendente (próximo mês em diante)Sempre recalculada quando há mudança em plano, alunos, valor ou dia de vencimento.
canceladaNunca tocada.

Parâmetro incluir_mes_corrente

As actions abaixo aceitam o opt-in incluir_mes_corrente: boolean (default false):

ActionEfeito quando true
update_valor_personalizadoRecalcula também a fatura pendente do mês corrente.
upgrade_downgrade_planIdem (valor + plano da fatura).
recalculate_by_studentsIdem (valor recalculado pela fórmula).
recalculate_due_dateReposiciona o vencimento_em da fatura do mês corrente.

O log de cada action inclui incluiu_mes_corrente: bool + faturas_atualizadas: N em detalhes.

Toast consolidado

O modal suprime toasts intermediários das actions (flag silent no hook) e exibe um único toast ao final com N alterações aplicadas · M faturas recalculadas.


9. Faturas Migradas

Faturas importadas de sistemas legados são marcadas com migrada = true. Não possuem gateway_preference_id e servem apenas como histórico financeiro.


9.1 Edição Manual de Fatura — Regras de Validação (R-004)

A action update da Edge Function admin-faturas permite editar campos financeiros de uma fatura pendente (não toca em status_pagamento, metodo_pagamento ou pago_em). Para garantir integridade, o backend aplica validação Zod antes de qualquer UPDATE e a UI espelha as mesmas regras como feedback imediato.

CampoRegraErro 400
valornúmero, > 0, ≤ R$ 1.000.000"O valor deve ser maior que zero"
descontonúmero, ≥ 0, ≤ valor (após merge)"O desconto não pode ser negativo" / "...maior que o valor da fatura"
taxasnúmero, ≥ 0, ≤ R$ 1.000.000"As taxas não podem ser negativas"
vencimento_emstring YYYY-MM-DD válida"Data de vencimento inválida (use AAAA-MM-DD)"

Comportamento:

  • Payload inválido → 400 { success: false, message }. Nenhum UPDATE é executado (Fail-Close).
  • Frontend desabilita "Salvar" enquanto houver erro derivado e exibe mensagem inline (aria-invalid + texto destrutivo) por campo.
  • A regra cruzada desconto ≤ valor usa o estado atual da fatura quando um dos dois campos não é enviado — evita burlar enviando só o desconto.
  • CORS preservado em todas as respostas. Stack traces nunca aparecem (AUDIT §3 — sanitização).

10. Período de Trial

SSOT da duração

A duração do trial vive em planos.trial_dias (default 30, configurável por plano com is_trial=true). O helper _shared/subscription-helper.ts não define duração — apenas valida o vencimento já gravado em escola_assinaturas.trial_ate.

Política de UI: componentes NUNCA devem hardcodar "30 dias" como rótulo de trial. Devem (a) ler planos.trial_dias quando o número for crítico, ou (b) omitir a duração e usar apenas o rótulo (Trial). Mostrar dias_restantes como countdown só faz sentido quando o contexto é o estado atual de uma escola específica (ex: badge na listagem) — nunca em fluxos de criação de novos vínculos (ex: modal "Gerar Link de Cadastro"), onde o número é semanticamente irrelevante.

Regra de corte (canônica)

Comparação por dia, em horário local do servidor (BRT em produção):

hoje      = startOfDay(now())          // 00:00:00 BRT
trialAte  = startOfDay(trial_ate)      // 00:00:00 BRT
expirado  = trialAte < hoje            // estritamente menor

Consequência intencional: no dia exato do vencimento (trial_ate === hoje) a escola ainda tem acesso (válido até o fim do dia). Apenas no dia seguinte (trial_ate < hoje) o gate TRIAL_EXPIRADO dispara. Isso evita expirar usuários no meio de uma sessão ativa.

Pontos de bloqueio (gates)

A função verificarAssinaturaEscola(supabase, escolaId, contexto) é chamada em todas as portas de entrada autenticada:

Edge FunctionMomentoContexto
auth/send-otpAntes de enviar OTPsistema
auth/verify-otpAntes de emitir JWTsistema
auth/verify-passwordAntes de emitir JWT (E2E)sistema
auth/select-roleAntes de re-assinar JWT com novo papelsistema
me (via resolve-bloqueios)A cada hidratação de sessãosistema
portal-login-alunoAntes de emitir cookie do Muralportal

Quando bloqueado, /me retorna bloqueios: [{ code: 'TRIAL_EXPIRADO' | 'SEM_ASSINATURA', message }] e o frontend renderiza <AccessBlockedScreen />.

Isolamento do papel escola_trial

Detalhes completos em docs/security/RLS_POLICIES.md. Resumo:

  • Permissões CRUD: zero (hardcoded em _shared/permissions.ts).
  • RLS: apenas 3 SELECT-only (escolas_select_trial, assinaturas_select_trial, olimpiadas_escola_read_active).
  • Edge functions acessíveis: eventos-calendario-trial (read-only) e /me.
  • Frontend: <DashboardTrial /> direto, sem sidebar.

Conversão Trial → Pago

Action admin-assinaturas.convert_trial_to_paid:

  • Troca o papel escola_trialescola automaticamente.
  • Cria nova assinatura com is_trial=false e trial_ate=null.
  • Aciona auto-ativação se escolas.status = 'em_analise' (ver §11 abaixo).

11. Auto-Ativação de Escola

Ao ativar uma assinatura paga (status_assinatura = 'ativa'), se a escola estiver com status = 'em_analise', seu status é automaticamente alterado para ativa.

Regra conservadora: Só ativa escolas em em_analise. Escolas já ativa, suspensa ou encerrada não são alteradas.

Pontos de ativação (3 caminhos):

Edge FunctionActionContexto
admin-escola-dadoscreate_assinaturaTela de detalhes da escola
admin-assinaturascreateTela de assinaturas
admin-assinaturasconvert_trial_to_paidConverter trial para pago

11.1 Régua de Cobrança Automática (Faturas Vencidas)

A partir do ciclo de produção 2026-05, a plataforma transiciona faturas pendentevencido automaticamente e envia uma régua de 3 toques.

Estados

EstadoCritérioQuem altera
pendentePadrão ao ser criadagerar_faturas / criação manual
vencidovencimento_em < hoje BRT E ainda pendenteCRON marcar_vencidas (diário 04:30 BRT)
pagaWebhook MP payment.approvedmercadopago-webhook

A coluna marcada_vencida_em registra o instante exato da transição automática.

Régua

GatilhoDias após vencimentoTemplate SMS/WhatsApp
D+11cobranca_d1 (lembrete amigável)
D+77cobranca_d7 (aviso firme)
D+1515cobranca_d15 (bloqueio iminente)

Disparada pelo CRON regua_cobranca (diário 09:00 BRT). Atrasos intermediários (2..6, 8..14, >15) são ignorados.

Idempotência

Cada envio é registrado em fatura_cobrancas com origem, regua_dia, canal, status, template_slug, gateway_response e enviado_por.

Idempotência garantida por índice único parcial:

uq_fatura_cobrancas_regua_auto
ON (fatura_id, regua_dia)
WHERE origem='regua_auto' AND status IN ('enviado','entregue','lido')

Re-execução do CRON no mesmo dia é segura: envios duplicados retornam status='noop' sem custo.

Envio Manual (Admin › Financeiro)

Edge function admin-financeiro expõe duas actions:

  • enviar_cobranca_manual — síncrono, skipDelay=true, log faturamento.cobranca_envio_manual.
  • enviar_cobranca_bulk — pós-resposta via EdgeRuntime.waitUntil, respeita rate-limit Wasender (6s), log faturamento.cobranca_envio_bulk.

Templates disponíveis: cobranca_d1, cobranca_d7, cobranca_d15, cobranca_manual (amistoso, sem dia específico).

Histórico (UI Admin)

admin-financeiro.list_cobrancas_fatura(fatura_id) retorna o histórico sem expor PII (destinatario e mensagem_preview ficam restritos ao backend — política anti-leak).


12. Referências

  • CRON Jobs — Detalhes do faturamento-cron, lembrete-d5, marcar_vencidas e régua de cobrança
  • Edge Functions — Catálogo completo
  • Audit Log — Logs de operações financeiras
  • Integrações — Wasender (WhatsApp) + MercadoPago + helper enviarCobranca