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(tabelasloyalty_*, funçõesearn_points/redeem_reward/adjust_points/reverse_purchase_points) e nas telas/socios/pepitase/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:
- 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).
- Frequência de compra — transformar compras avulsas (loja, ingressos) em hábito, porque cada compra "rende".
- 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):
| Programa | Ideia que vale copiar | Por 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 Insider | Tiers (níveis) com benefícios crescentes | Dá aspiração — o sócio quer "subir de nível" |
| Nubank / cartões premium | Pontos não expiram enquanto a assinatura está ativa | Amarra o benefício à renovação do sócio (nosso maior objetivo) |
| Duolingo / gamificação | Streak (constância) e progresso visível | Barato de implementar, forte no emocional |
| Mercado Livre Nível / iFood | Nível calculado por atividade recente | Manté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ção | Regra sugerida | Quando credita |
|---|---|---|
| Compra na loja | 1 pepita por R$1 gasto (só o valor efetivamente pago) | Quando orders.payment_status = 'paid' (webhook) |
| Compra de ingresso/evento | 1 pepita por R$1 | idem |
| 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 ativo | Na ativação da associação |
| Bônus de renovação/fidelidade | +150 pepitas a cada renovação | Na renovação |
| Indicação (referral) | +100 pepitas quando o indicado vira sócio pagante | Após ativação do indicado |
| Aniversário | +100 pepitas no mês de aniversário | Job mensal / cron |
| Missões temporárias | variá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
| Resgate | Como funciona |
|---|---|
| Desconto em compra | Ex.: 100 pepitas = R$5 de desconto no carrinho (aplicado como um "cupom interno") |
| Produto do catálogo | Camiseta, caneca, copo — resgatado por X pepitas, entregue na UNISATC |
| Ingresso / entrada em evento | Trocar 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_balancepara performance, mas ele é sempre reconciliável somandoloyalty_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 / RPCSECURITY DEFINER). Admin lê tudo (is_admin()).loyalty_rewards: leitura pública dospublished+active(é vitrine); escritais_admin().loyalty_redemptions: usuário lê/insere as próprias; admin lê e atualiza status (entrega).profiles.points_balance: já coberto pelo RLS deprofiles(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 redemptionIdempotência é obrigatória no earn. O webhook de pagamento (Sicoob/PIX) pode chamar mais de uma vez para o mesmo pagamento.
earn_pointsdeve checar se já existe transaçãoearnpara aqueleorder_idantes de creditar. (Mesma disciplina que oactivate_membershipjá 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:
- Crédito automático por compra →
src/app/api/sicoob/webhook/pix/route.ts. Onde hoje ele marcapayment_status='paid'e chamaactivate_membership, adicionar a chamadaearn_points(user, total, 'purchase', order_id)somente se o comprador for sócio ativo. - Estorno → mesmo webhook, no branch de
refunded/cancelled: transaçãoreversal. - Desconto por pepitas no checkout →
src/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). - 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). - 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'emROLE_SECTIONS(src/lib/permissions.ts) — sugiro liberar parasuper_admin,admin,financeiro. - Check-in de evento → botão no admin de eventos que credita
event_checkinpara 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
codede 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 só credita após
payment_status='paid'confirmado pelo webhook (server-side com service role). Nunca no cliente, nunca no "checkout iniciado". earn_pointsidempotente pororder_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/DELETEemloyalty_transactions. Correção = nova transaçãoadjust/reversal. - Só 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:
- Nome da moeda: "Pepitas" (sugerido) ou outro? →
__________ - Taxa de acúmulo: 1 pepita por R$1? Outro? →
__________ - Valor de resgate: 100 pepitas = R$5? →
__________ - Ingressos/eventos pontuam? (margem menor) →
__________ - Tiers/níveis na fase 1? Recomendo não (adicionar na fase 2). →
__________ - Expiração: por inatividade (12 meses) e/ou ao ficar sócio inativo? →
__________ - O que acontece com as pepitas se o sócio não renovar? (congela / expira / zera) →
__________ - Teto de resgate por pedido: 30% do valor? →
__________ - 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):
- Migration:
loyalty_transactions,loyalty_rewards,loyalty_redemptions,profiles.points_balance+ RLS +earn_points/redeem_reward. - Crédito automático por compra no webhook (só sócio ativo) + reversão em estorno.
- Área do Sócio: card de saldo + extrato + catálogo + resgate + "meus resgates".
- Admin
/admin/pepitas: CRUD de recompensas + fila de resgates + ajuste manual + extrato por sócio. - 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.