Covil do Mineiro — Docs

Sistema de Pontos e Recompensas do Sócio — Covil do Mineiro

O que é este documento Um brief de produto + spec técnico para implementar um programa de fidelidade exclusivo para sócios da atlética. Foi escrito para ser aberto em uma nova sessão do Claude Code (ou outra IA) e servir como base de implementação. Ele já está amarrado ao stack e às convenções reais do projeto (Next.js 15 App Router, Supabase com migrations SQL + RLS, webhook de pagamento (Sicoob/PIX), tema dourado data-member, "Área do Sócio").

Status:IMPLEMENTADO — este documento é mantido como registro histórico do design original. O programa de Pepitas está no ar; o funcionamento atual está em banco-de-dados.md (tabelas loyalty_*, funções earn_points/redeem_reward/adjust_points/reverse_purchase_points) e nas telas /socios/pepitas e /admin/pepitas. As decisões em aberto da seção 10 foram resolvidas: moeda = Pepitas, 1 pepita por R$1, 100 pepitas = R$5, bônus de boas-vindas de 100. Foco: exclusivo para sócios (é um benefício de ser sócio, não um programa aberto).


1. Por que fazer isso (objetivo de negócio)

Uma atlética vive de recorrência e pertencimento. O programa de pontos ataca três coisas ao mesmo tempo:

  1. Retenção / renovação de sócio — dar um motivo tangível para renovar o plano (os pontos "moram" na conta de sócio ativo).
  2. Frequência de compra — transformar compras avulsas (loja, ingressos) em hábito, porque cada compra "rende".
  3. Engajamento com eventos — premiar presença, não só consumo.

O norte: ser sócio tem que ser visivelmente mais vantajoso do que não ser. Hoje o sócio já ganha member_price (preço de sócio). Os pontos são a segunda camada de valor.


2. O que aprendemos de programas que funcionam

Referências e o que copiar de cada uma (adaptado à realidade de uma atlética, não de uma multinacional):

ProgramaIdeia que vale copiarPor quê
Starbucks Rewards (Stars)Ganho por compra + desafios/missões temporários ("compre 2 eventos este mês, ganhe bônus")Cria picos de engajamento sem inflar o custo o tempo todo
Sephora Beauty InsiderTiers (níveis) com benefícios crescentesDá aspiração — o sócio quer "subir de nível"
Nubank / cartões premiumPontos não expiram enquanto a assinatura está ativaAmarra o benefício à renovação do sócio (nosso maior objetivo)
Duolingo / gamificaçãoStreak (constância) e progresso visívelBarato de implementar, forte no emocional
Mercado Livre Nível / iFoodNível calculado por atividade recenteMantém o programa "vivo", premia quem está ativo agora
Milhas (LATAM/Smiles)Separar saldo (gasta) de status/nível (qualifica)Evita que resgatar pontos rebaixe o sócio

Princípios de design que vamos seguir (destilados dessas referências):

  • Simplicidade acima de tudo. O sócio tem que entender a regra em uma frase. Ex: "A cada R$1 você ganha 1 pepita. 100 pepitas viram R$5 de desconto ou um brinde."
  • Valor percebido > custo real. Resgatar por produto a preço de custo (não a preço de venda) faz o brinde parecer caro e sair barato para a atlética.
  • Progresso "quase lá" (endowed progress). Mostrar "faltam 40 pepitas para seu próximo resgate" converte mais do que mostrar saldo seco.
  • Evitar inflação. Ponto emitido é passivo financeiro. Teto de acúmulo e de resgate são obrigatórios (seção 5).
  • Nunca calcular saldo "na mão". Saldo é sempre a soma de um livro-razão (ledger) imutável (seção 6). Isso é inegociável — é o que garante que não vai dar bug de pontos duplicados/sumidos.

3. A moeda: Pepitas

Proposta de nome: Pepitas (o mineiro garimpa pepitas de ouro — casa com o mascote e com a marca preto/dourado #ffc200 / dourado premium do sócio #d4a017).

Alternativas caso não goste: Ouro do Covil, Nuggets, Barras, Patas de Ouro.

Símbolo/ícone sugerido: um naco de ouro (lucide não tem "nugget"; usar Gem, Coins ou um SVG próprio dourado).

No resto do documento uso "pepita(s)" como a unidade.


4. Mecânica: como ganhar e como gastar

4.1 Como GANHAR pepitas

AçãoRegra sugeridaQuando credita
Compra na loja1 pepita por R$1 gasto (só o valor efetivamente pago)Quando orders.payment_status = 'paid' (webhook)
Compra de ingresso/evento1 pepita por R$1idem
Presença em evento (check-in)+50 pepitas (fixo)Admin faz check-in / valida presença
Bônus de boas-vindas+100 pepitas ao virar sócio ativoNa ativação da associação
Bônus de renovação/fidelidade+150 pepitas a cada renovaçãoNa renovação
Indicação (referral)+100 pepitas quando o indicado vira sócio paganteApós ativação do indicado
Aniversário+100 pepitas no mês de aniversárioJob mensal / cron
Missões temporáriasvariável (ex.: "2 compras no mês = +80")Fim da missão

Regra de ouro: pepita só credita depois do pagamento confirmado pelo webhook de pagamento (Sicoob/PIX). Nunca no momento do "checkout iniciado". Estorno/refund → remove as pepitas (transação de reversão).

4.2 Como GASTAR pepitas

ResgateComo funciona
Desconto em compraEx.: 100 pepitas = R$5 de desconto no carrinho (aplicado como um "cupom interno")
Produto do catálogoCamiseta, caneca, copo — resgatado por X pepitas, entregue na UNISATC
Ingresso / entrada em eventoTrocar pepitas por ingresso de lote específico
Experiência exclusivaÁrea VIP, brinde de bastidor, sorteios só para quem tem saldo
Upgrade de tier(se adotar tiers) pepitas aceleram a subida — opcional

Recomendo começar só com desconto em compra + catálogo de produtos (é o que dá para automatizar/controlar melhor). O resto entra em fases posteriores.


5. A economia por trás (a parte "muito bem pensada")

Ponto emitido = dívida da atlética com o sócio. Se a economia estiver errada, o programa vira prejuízo. As três alavancas:

5.1 Taxa de acúmulo × valor de resgate

Exemplo trabalhado (recomendado para começar, conservador):

  • Acúmulo: 1 pepita por R$1 pago.
  • Resgate: 100 pepitas = R$5 de benefício → cada pepita vale R$0,05.
  • Logo, o "cashback efetivo" em pepitas ≈ 5% do que o sócio gasta.

Isso só é sustentável se a margem média dos produtos for confortavelmente acima de 5% (quase sempre é, em produto de atlética). Para eventos/ingressos com margem apertada, dá para reduzir o acúmulo (ex.: ingresso rende 0,5 pepita/R$1) ou não pontuar ingresso na fase 1.

5.2 Resgate por produto "a custo" (o truque que faz render)

O sócio percebe o valor de varejo; a atlética paga o valor de custo.

  • Camiseta que vende a R$60, custa R$25 para a atlética.
  • Resgate por 800 pepitas. Para juntar 800 pepitas o sócio gastou ~R$800 na loja.
  • Percepção do sócio: "ganhei uma camiseta de R$60". Custo real da atlética: R$25 sobre R$800 de receita = ~3%.

Isso mantém o programa barato e ainda assim generoso na percepção.

5.3 Breakage (pontos que nunca são resgatados)

Na indústria, 10% a 30% dos pontos emitidos nunca são usados. Isso reduz o custo real do programa. Mas não conte com ele para fechar a conta — trate como margem de segurança, não como premissa.

5.4 Tetos obrigatórios (proteção)

  • Teto de resgate por pedido: desconto de pepitas cobre no máximo X% do valor (ex.: 30%). Nunca deixar zerar um pedido.
  • Teto de acúmulo diário: evita abuso/fraude (ex.: máx. 500 pepitas/dia por conta).
  • Validade: pepitas expiram após 12 meses de inatividade OU quando a associação fica inactive (decisão sua — ver seção 10). Expiração é o que impede o passivo de crescer para sempre.

6. Modelo de dados (schema Supabase proposto)

Segue o padrão do projeto: uma migration nova em supabase/migrations/, RLS habilitado, tipos espelhados em src/types/supabase.ts e domínio em src/types/content.ts.

Decisão de arquitetura central: saldo = soma do ledger. Nunca guardamos um "saldo" editável como fonte da verdade. O saldo é derivado das transações. Podemos cachear o saldo em profiles.points_balance para performance, mas ele é sempre reconciliável somando loyalty_transactions.

6.1 Tabelas

-- ============================================================
-- LEDGER: fonte da verdade. Imutável (nunca UPDATE/DELETE).
-- ============================================================
create table public.loyalty_transactions (
  id            uuid primary key default gen_random_uuid(),
  user_id       uuid not null references auth.users(id) on delete cascade,
  -- earn = ganhou | redeem = gastou | expire = expirou | adjust = ajuste manual admin | reversal = estorno
  type          text not null check (type in ('earn','redeem','expire','adjust','reversal')),
  points        integer not null,          -- positivo p/ earn/adjust+, negativo p/ redeem/expire/reversal
  reason        text not null,             -- 'purchase','event_checkin','welcome','referral','manual', etc.
  order_id      uuid references public.orders(id) on delete set null,
  redemption_id uuid,                       -- FK lógica p/ loyalty_redemptions (evita ciclo de FK)
  balance_after integer not null,           -- saldo do usuário logo após esta transação (auditoria)
  created_by    uuid references auth.users(id) on delete set null, -- admin, se ajuste manual
  created_at    timestamptz not null default (now() at time zone 'utc')
);
create index on public.loyalty_transactions (user_id, created_at desc);

-- ============================================================
-- CATÁLOGO DE RESGATE
-- ============================================================
create table public.loyalty_rewards (
  id             uuid primary key default gen_random_uuid(),
  name           text not null,
  description    text not null default '',
  image_url      text,
  cost_points    integer not null check (cost_points > 0),
  -- product = brinde físico | discount = vira desconto | ticket = ingresso | experience = experiência
  reward_type    text not null check (reward_type in ('product','discount','ticket','experience')),
  discount_value numeric(10,2),             -- se reward_type='discount', quanto em R$
  stock          integer,                    -- null = ilimitado
  active         boolean not null default true,
  publish_status text not null default 'published' check (publish_status in ('draft','published')),
  created_at     timestamptz not null default (now() at time zone 'utc'),
  updated_at     timestamptz not null default (now() at time zone 'utc')
);

-- ============================================================
-- RESGATES (pedidos de troca de pepitas)
-- ============================================================
create table public.loyalty_redemptions (
  id           uuid primary key default gen_random_uuid(),
  user_id      uuid not null references auth.users(id) on delete cascade,
  reward_id    uuid not null references public.loyalty_rewards(id) on delete restrict,
  points_spent integer not null check (points_spent > 0),
  -- pending = resgatou, aguardando entrega | fulfilled = entregue | cancelled = cancelado/estornado
  status       text not null default 'pending' check (status in ('pending','fulfilled','cancelled')),
  code         text not null,               -- código de retirada (mostrar p/ o sócio, admin confere na entrega)
  notes        text,
  created_at   timestamptz not null default (now() at time zone 'utc'),
  fulfilled_at timestamptz
);
create index on public.loyalty_redemptions (user_id, created_at desc);

-- ============================================================
-- CACHE DE SALDO em profiles (derivado, reconciliável)
-- ============================================================
alter table public.profiles add column points_balance integer not null default 0;

6.2 RLS (seguir o padrão das outras tabelas)

  • loyalty_transactions: usuário lê só as próprias (auth.uid() = user_id); nenhum INSERT/UPDATE/DELETE via cliente — só server (service role no webhook / RPC SECURITY DEFINER). Admin lê tudo (is_admin()).
  • loyalty_rewards: leitura pública dos published + active (é vitrine); escrita is_admin().
  • loyalty_redemptions: usuário lê/insere as próprias; admin lê e atualiza status (entrega).
  • profiles.points_balance: já coberto pelo RLS de profiles (self + admin).

6.3 Funções SECURITY DEFINER (a lógica crítica no banco)

Seguindo o padrão de activate_membership() e redeem_coupon() que já existem no projeto:

-- Credita pepitas de forma atômica (calcula balance_after e atualiza o cache).
-- Idempotente por (order_id, reason) para o webhook poder reprocessar sem duplicar.
create function public.earn_points(p_user uuid, p_points int, p_reason text, p_order uuid default null)
  returns void ...

-- Resgata: valida saldo, debita, cria redemption, baixa estoque — tudo numa transação.
create function public.redeem_reward(p_user uuid, p_reward uuid)
  returns uuid ...  -- retorna id do redemption

Idempotência é obrigatória no earn. O webhook de pagamento (Sicoob/PIX) pode chamar mais de uma vez para o mesmo pagamento. earn_points deve checar se já existe transação earn para aquele order_id antes de creditar. (Mesma disciplina que o activate_membership já precisa ter.)


7. Onde plugar no código que já existe

Este programa não é um módulo isolado — ele se encaixa em pontos que já estão prontos:

  1. Crédito automático por comprasrc/app/api/sicoob/webhook/pix/route.ts. Onde hoje ele marca payment_status='paid' e chama activate_membership, adicionar a chamada earn_points(user, total, 'purchase', order_id) somente se o comprador for sócio ativo.
  2. Estorno → mesmo webhook, no branch de refunded/cancelled: transação reversal.
  3. Desconto por pepitas no checkoutsrc/lib/actions/checkout-actions.ts (onde já recalcula preço de sócio + cupom). O desconto de pepitas entra como mais uma linha de dedução, recalculada no servidor (nunca confiar no cliente).
  4. Vitrine + saldo + extrato → nova aba na Área do Sócio (src/app/(public)/socios/ ou uma rota /socios/pepitas). Usar o tema dourado premium (data-member) e os componentes existentes (Card, SectionHeader, member-badge).
  5. Admin do programa → nova seção em /admin (ex.: /admin/pepitas), seguindo o padrão dos managers (src/components/admin/*-manager.tsx): CRUD de recompensas, ledger/extrato por usuário, ajuste manual, e a fila de resgates a entregar. Adicionar 'pepitas' em ROLE_SECTIONS (src/lib/permissions.ts) — sugiro liberar para super_admin, admin, financeiro.
  6. Check-in de evento → botão no admin de eventos que credita event_checkin para uma lista de sócios presentes.

8. Telas (UX)

Para o sócio (Área do Sócio):

  • Card de saldo em destaque (dourado): "Você tem 1.240 pepitas" + barra "faltam 60 para o próximo resgate".
  • Extrato: timeline das transações (ganhou/gastou, data, motivo). Vem direto do ledger.
  • Catálogo de resgate: grid de recompensas com custo em pepitas e botão "Resgatar" (desabilitado se saldo insuficiente).
  • "Como ganhar pepitas": bloco educativo com as regras (seção 4.1) em linguagem simples.
  • Meus resgates: lista com o code de retirada e status (pendente/entregue).

Para o admin:

  • Lista de recompensas (CRUD).
  • Fila de resgates pendentes → botão "marcar como entregue".
  • Busca de sócio → ver extrato + botão "ajuste manual" (com motivo obrigatório, vira transação adjust).
  • Métricas (seção 11).

9. Anti-fraude e integridade (não pular)

  • Pepita credita após payment_status='paid' confirmado pelo webhook (server-side com service role). Nunca no cliente, nunca no "checkout iniciado".
  • earn_points idempotente por order_id (webhook duplicado não duplica pontos).
  • Estorno remove pontos — inclusive se o saldo já tiver sido parcialmente gasto (saldo pode ficar negativo temporariamente; bloquear novos resgates até regularizar).
  • Teto diário de acúmulo por conta.
  • Resgate valida saldo no servidor dentro de redeem_reward (transação atômica) — dois cliques rápidos não podem resgatar duas vezes o mesmo saldo.
  • Ledger é append-only: nunca UPDATE/DELETE em loyalty_transactions. Correção = nova transação adjust/reversal.
  • sócio ativo (role='member' + membership_status='active') ganha e gasta. Sócio que expira: decidir em seção 10.

10. Decisões em aberto (você precisa preencher antes de implementar)

Estas escolhas mudam a implementação — deixe respondido aqui antes de passar para a próxima IA:

  1. Nome da moeda: "Pepitas" (sugerido) ou outro? → __________
  2. Taxa de acúmulo: 1 pepita por R$1? Outro? → __________
  3. Valor de resgate: 100 pepitas = R$5? → __________
  4. Ingressos/eventos pontuam? (margem menor) → __________
  5. Tiers/níveis na fase 1? Recomendo não (adicionar na fase 2). → __________
  6. Expiração: por inatividade (12 meses) e/ou ao ficar sócio inativo? → __________
  7. O que acontece com as pepitas se o sócio não renovar? (congela / expira / zera) → __________
  8. Teto de resgate por pedido: 30% do valor? → __________
  9. Orçamento/verba mensal que a atlética topa "pagar" em recompensas (para calibrar as taxas). → __________

11. Métricas de sucesso (o que medir depois de lançar)

  • % de sócios ativos com saldo > 0 (adesão real ao programa).
  • Pepitas emitidas vs. resgatadas por mês (controle de passivo e de breakage).
  • Ticket médio: sócio no programa vs. sócio fora / não-sócio (o programa está aumentando gasto?).
  • Taxa de renovação de sócio com programa vs. sem (o objetivo #1).
  • Custo real do programa (R$ em recompensas entregues / receita de sócios).

12. Roadmap de implementação (fases)

Fase 1 — MVP (o essencial que já dá valor):

  1. Migration: loyalty_transactions, loyalty_rewards, loyalty_redemptions, profiles.points_balance + RLS + earn_points/redeem_reward.
  2. Crédito automático por compra no webhook (só sócio ativo) + reversão em estorno.
  3. Área do Sócio: card de saldo + extrato + catálogo + resgate + "meus resgates".
  4. Admin /admin/pepitas: CRUD de recompensas + fila de resgates + ajuste manual + extrato por sócio.
  5. Bônus de boas-vindas na ativação de sócio.

Fase 2 — Aprofundar:

  • Desconto por pepitas aplicado no checkout.
  • Tiers/níveis com benefícios.
  • Referral (indicação) e bônus de renovação.
  • Check-in de evento credita pepitas.
  • Expiração automática (job/cron) + e-mail "suas pepitas vão expirar".

Fase 3 — Gamificar:

  • Missões/desafios temporários.
  • Streak de constância.
  • Notificações e e-mails ("você ganhou X pepitas!").
  • Sorteios entre quem tem saldo.

13. Riscos e cuidados finais

  • Prejuízo por economia mal calibrada → começar conservador (5% efetivo, tetos, resgate a custo) e ajustar com dados.
  • Complexidade cedo demais → não implementar tiers/gamificação na fase 1. MVP primeiro.
  • Expectativa não atendida → se prometer resgate, tem que ter estoque e processo de entrega (na UNISATC). Fila de resgate no admin resolve isso.
  • LGPD → o programa cria histórico de consumo do sócio (dado pessoal). Cobrir isso na Política de Privacidade e nos Termos.
  • Passivo contábil → pepitas em circulação são uma dívida. A diretoria financeira precisa ver o total emitido vs. resgatado (métrica da seção 11).

Documento vivo — preencha a seção 10 e ajuste as taxas da seção 5 conforme a realidade financeira da atlética antes de implementar.

On this page