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.digitaloustaging.olp.digital) automaticamente a partir doSUPABASE_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_dateerecalculate_by_studentsretornamdata.faturas_ids: string[](UUIDs das faturas afetadas) além do contadorfaturas_atualizadas. Quando o frontend dispara múltiplas actions em sequência sobre o mesmo conjunto de faturas (ex:EditarAssinaturaModalao mudar valor + dia + alunos), o totalizador exibido ao usuário DEVE acumular IDs emSet<string>para evitar contagem duplicada. Histórico emsupabase/functions/admin-assinaturas/__tests__/regressions.md.
1. Planos
Tabela: planos
| Campo | Tipo | Descrição |
|---|---|---|
nome | text | Nome do plano |
preco_mensal | numeric | Preço base mensal |
preco_anual | numeric | Preço anual (se aplicável) |
valor_por_aluno_extra | numeric | Valor por aluno contratado (multiplicado pelo total) |
alunos_minimo / alunos_maximo | int | Faixa de elegibilidade |
trial_dias | int | Duração do trial em dias |
is_trial | bool | Se é plano de teste |
tipo_cobranca | text | mensal ou anual |
features | jsonb | Features 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 contratomeses_contratados→ duração total (calculado a partir do rangemes_inicio→data_fim_contrato)data_fim_contrato→ último dia do mês final do contratodia_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:
| Campo | Tipo | Regra |
|---|---|---|
escola_id | UUID | Obrigatório |
plano_id | UUID | Obrigatório |
status_assinatura | enum | 'ativa' ou 'trial' (default: 'ativa') |
alunos_contratados | int | Mínimo 1 |
dia_vencimento | int | 1-28 (default 10) |
mes_inicio | string | YYYY-MM, deve ser posterior ao mês atual |
data_fim_contrato | string | YYYY-MM, >= mes_inicio |
valor_personalizado | number | Opcional, override do cálculo automático |
renovacao_automatica | boolean | Default 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
| Status | Significado |
|---|---|
trial | Período de teste (validado por trial_ate) |
ativa | Assinatura paga ativa |
suspensa | Pagamento atrasado — acesso bloqueado |
cancelada | Cancelamento solicitado |
encerrada | Contrato finalizado |
Gateway MercadoPago
| Campo | Uso |
|---|---|
gateway_customer_id | ID do cliente no MercadoPago |
gateway_subscription_id | ID 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
| Status | Descrição |
|---|---|
pendente | Gerada, aguardando pagamento |
pago | Pagamento confirmado |
atrasado | Vencimento ultrapassado |
cancelado | Fatura cancelada |
Integração MercadoPago
| Campo | Descrição |
|---|---|
gateway_preference_id | Preference ID do Checkout Pro |
link_pagamento | URL init_point para pagamento (uso interno do painel) |
gateway_payment_id | ID do pagamento confirmado |
pix_qrcode / pix_copia_cola | Dados PIX |
boleto_pdf_url / codigo_barras | Dados do boleto |
lembrete_d5_enviado_em | Timestamp 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 faturaNota: 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 — olink_pagamentoé consumido apenas peloCheckoutLinkDialogdentro do painel autenticado.
4. Geração Automática de Faturas
Job: gerar-faturas-mensal
- Edge Function:
faturamento-cron(actiongerar_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 pelocreate_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_linkvia 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(actionlembrete_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ário | Ação |
|---|---|
| Fatura pendente, vence em 5 dias, sem lembrete | Envia WhatsApp + marca timestamp |
| Fatura pendente, vence em 5 dias, já lembrada | Ignora (idempotente) |
| Fatura paga ou cancelada | Ignora |
6. Trial
Verificação: subscription-helper.ts
const resultado = await verificarAssinaturaEscola(supabase, escolaId, 'sistema');
if (!resultado.valida) {
// resultado.code: 'TRIAL_EXPIRADO' | 'SEM_ASSINATURA'
}Comportamento
| Cenário | Resultado |
|---|---|
Assinatura ativa ou trial válido | valida: true |
| Trial expirado | valida: false, code: 'TRIAL_EXPIRADO' |
| Sem assinatura | valida: false, code: 'SEM_ASSINATURA' |
| Erro técnico | valida: true (fail-open) |
Onde é Verificado
| Contexto | Edge Function |
|---|---|
| Login sistema | send-otp, verify-otp |
| Portal aluno | portal-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:
| Campo | Descrição |
|---|---|
faturas | Lista de faturas do ano filtrado |
metricas | Cálculos do ano: total_faturas, pagas, pendentes, valor_total, valor_pago, valor_pendente, taxa_adimplencia |
metricas_gerais | Cálculos de todo o contrato (sem filtro de ano) |
assinatura | Dados da assinatura ativa |
plano | Dados do plano |
anos_disponiveis | Lista 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ção | Ações Principais |
|---|---|
admin-assinaturas | CRUD de assinaturas, recálculo de faturas |
admin-faturas | Listagem, get_ciclo_contratual, geração manual de links, marcação de pago |
admin-planos | CRUD de planos |
admin-escola-dados | create_assinatura (validação Zod CreateAssinaturaSchema) — criação na tela de detalhes |
escola-pagamentos | Visão da escola: faturas, links de pagamento (usado por gestão/diretor) |
mercadopago-preference | Gerar link de pagamento (Checkout Pro) |
mercadopago-webhook | Receber notificações de pagamento |
faturamento-cron | Geração mensal de faturas + lembrete D-5 |
Hooks Frontend
| Hook | Uso |
|---|---|
useAdminAssinaturas | Gestão de assinaturas (admin) |
useAdminFaturas | Gestão de faturas (admin) |
useAdminPlanos | Gestão de planos (admin) |
useMercadoPago | Geração de links e polling (admin) |
useEscolaPagamentos | Faturas 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 fatura | Comportamento |
|---|---|
pago | Nunca 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. |
cancelada | Nunca tocada. |
Parâmetro incluir_mes_corrente
As actions abaixo aceitam o opt-in incluir_mes_corrente: boolean (default false):
| Action | Efeito quando true |
|---|---|
update_valor_personalizado | Recalcula também a fatura pendente do mês corrente. |
upgrade_downgrade_plan | Idem (valor + plano da fatura). |
recalculate_by_students | Idem (valor recalculado pela fórmula). |
recalculate_due_date | Reposiciona 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.
| Campo | Regra | Erro 400 |
|---|---|---|
valor | número, > 0, ≤ R$ 1.000.000 | "O valor deve ser maior que zero" |
desconto | número, ≥ 0, ≤ valor (após merge) | "O desconto não pode ser negativo" / "...maior que o valor da fatura" |
taxas | número, ≥ 0, ≤ R$ 1.000.000 | "As taxas não podem ser negativas" |
vencimento_em | string YYYY-MM-DD válida | "Data de vencimento inválida (use AAAA-MM-DD)" |
Comportamento:
- Payload inválido →
400 { success: false, message }. NenhumUPDATEé executado (Fail-Close). - Frontend desabilita "Salvar" enquanto houver erro derivado e exibe mensagem inline (
aria-invalid+ texto destrutivo) por campo. - A regra cruzada
desconto ≤ valorusa 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_diasquando o número for crítico, ou (b) omitir a duração e usar apenas o rótulo(Trial). Mostrardias_restantescomo 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 menorConsequê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 Function | Momento | Contexto |
|---|---|---|
auth/send-otp | Antes de enviar OTP | sistema |
auth/verify-otp | Antes de emitir JWT | sistema |
auth/verify-password | Antes de emitir JWT (E2E) | sistema |
auth/select-role | Antes de re-assinar JWT com novo papel | sistema |
me (via resolve-bloqueios) | A cada hidratação de sessão | sistema |
portal-login-aluno | Antes de emitir cookie do Mural | portal |
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_trial→escolaautomaticamente. - Cria nova assinatura com
is_trial=falseetrial_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 Function | Action | Contexto |
|---|---|---|
admin-escola-dados | create_assinatura | Tela de detalhes da escola |
admin-assinaturas | create | Tela de assinaturas |
admin-assinaturas | convert_trial_to_paid | Converter 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 pendente→vencido automaticamente e envia uma régua de 3 toques.
Estados
| Estado | Critério | Quem altera |
|---|---|---|
pendente | Padrão ao ser criada | gerar_faturas / criação manual |
vencido | vencimento_em < hoje BRT E ainda pendente | CRON marcar_vencidas (diário 04:30 BRT) |
paga | Webhook MP payment.approved | mercadopago-webhook |
A coluna marcada_vencida_em registra o instante exato da transição automática.
Régua
| Gatilho | Dias após vencimento | Template SMS/WhatsApp |
|---|---|---|
| D+1 | 1 | cobranca_d1 (lembrete amigável) |
| D+7 | 7 | cobranca_d7 (aviso firme) |
| D+15 | 15 | cobranca_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, logfaturamento.cobranca_envio_manual.enviar_cobranca_bulk— pós-resposta viaEdgeRuntime.waitUntil, respeita rate-limit Wasender (6s), logfaturamento.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