Validar CPF em Ruby on Rails: mod-11 e RSpec do zero
Validar CPF em Ruby on Rails: mod-11 e RSpec do zero
O CPF tem 11 dígitos e dois dígitos verificadores calculados via módulo 11. Sem entender essa mecânica, qualquer implementação de validação é frágil. Este artigo parte do algoritmo e chega em produção: validator ActiveRecord, testes RSpec completos e coluna criptografada com LGPD em mente.
O algoritmo mod-11 define o que é um CPF válido
Estrutura do CPF: 9 dígitos base + 2 dígitos verificadores
Um CPF formatado segue o padrão XXX.XXX.XXX-DD, onde os 9 primeiros dígitos são o número base e DD são os dois dígitos verificadores. A Receita Federal define essa estrutura na Instrução Normativa RFB 1.548/2015.
Cálculo do primeiro dígito verificador (pesos 10..2)
Multiplique cada um dos 9 primeiros dígitos pelos pesos de 10 a 2, em ordem. Some os produtos. Calcule resto = soma % 11. Se o resto for menor que 2, o primeiro dígito verificador é 0. Caso contrário, é 11 - resto.
| Posição | d1 | d2 | d3 | d4 | d5 | d6 | d7 | d8 | d9 |
|---|---|---|---|---|---|---|---|---|---|
| Peso | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 |
Cálculo do segundo dígito verificador (pesos 11..2)
O segundo dígito usa os 9 dígitos base mais o primeiro verificador, com pesos de 11 a 2. A mesma regra do resto se aplica: resto menor que 2 resulta em 0, caso contrário 11 - resto.
Regra de rejeição: CPFs com todos os dígitos iguais
000.000.000-00, 111.111.111-11 e variantes passam no cálculo mod-11 matematicamente, mas são rejeitados pela Receita Federal como inválidos. Seu validador precisa checar isso antes de calcular os dígitos verificadores.
Implementação em Ruby puro antes de tocar no Rails
Método valid_cpf?(cpf) sem dependências externas
def valid_cpf?(cpf)
digits = cpf.to_s.gsub(/\D/, '')
return false unless digits.length == 11
return false if digits.chars.uniq.length == 1
calc_digit = lambda do |slice, weight_start|
sum = slice.chars.each_with_index.sum do |d, i|
d.to_i * (weight_start - i)
end
remainder = sum % 11
remainder < 2 ? 0 : 11 - remainder
end
first = calc_digit.call(digits[0..8], 10)
second = calc_digit.call(digits[0..9], 11)
digits[9].to_i == first && digits[10].to_i == second
endNormalização da entrada: strip, remover pontuação, garantir 11 chars
A linha digits = cpf.to_s.gsub(/\D/, '') cobre todos os casos: nil vira string vazia, pontuação é removida, espaços também. to_s evita NoMethodError sem precisar tratar nil separadamente.
Retorno explícito de true/false sem exceptions no caminho feliz
O método retorna false para entradas inválidas, nunca levanta exceção. Exceptions ficam reservadas para erros inesperados de sistema. Validação de formato é fluxo normal, não excepcional.
Cobertura com RSpec: casos que derrubam implementações ingênuas
CPFs inválidos: dígito errado, sequência repetida, tamanho errado
RSpec.describe '#valid_cpf?' do
shared_examples 'rejeita cpf invalido' do |cpf|
it "rejeita #{cpf.inspect}" do
expect(valid_cpf?(cpf)).to be false
end
end
context 'entradas validas' do
it 'aceita CPF com pontuacao' do
expect(valid_cpf?('529.982.247-25')).to be true
end
it 'aceita CPF sem pontuacao' do
expect(valid_cpf?('52998224725')).to be true
end
end
context 'sequencias repetidas' do
%w[00000000000 11111111111 99999999999].each do |cpf|
include_examples 'rejeita cpf invalido', cpf
end
end
context 'digito verificador errado' do
include_examples 'rejeita cpf invalido', '529.982.247-26'
end
context 'tamanho errado' do
include_examples 'rejeita cpf invalido', '5299822472'
include_examples 'rejeita cpf invalido', '529982247250'
end
context 'entradas sujas' do
include_examples 'rejeita cpf invalido', nil
include_examples 'rejeita cpf invalido', ''
include_examples 'rejeita cpf invalido', ' '
include_examples 'rejeita cpf invalido', '123.456.789-09'
end
endEstrutura de shared_examples para reutilizar em validadores futuros
O shared_examples 'rejeita cpf invalido' pode ser incluído no spec do validador de CNPJ quando você implementar validação de CNPJ. A estrutura mod-11 é a mesma, só os pesos mudam.
Integrar o validador como ActiveModel::EachValidator
Criar app/validators/cpf_validator.rb
class CpfValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if valid_cpf?(value)
record.errors.add(attribute, :invalid_cpf)
end
private
def valid_cpf?(cpf)
digits = cpf.to_s.gsub(/\D/, '')
return false unless digits.length == 11
return false if digits.chars.uniq.length == 1
calc_digit = lambda do |slice, weight_start|
sum = slice.chars.each_with_index.sum { |d, i| d.to_i * (weight_start - i) }
remainder = sum % 11
remainder < 2 ? 0 : 11 - remainder
end
first = calc_digit.call(digits[0..8], 10)
second = calc_digit.call(digits[0..9], 11)
digits[9].to_i == first && digits[10].to_i == second
end
endUsar validates :cpf, cpf: true no model
class User < ApplicationRecord
validates :cpf, presence: true, cpf: true
endRails infere CpfValidator a partir do símbolo :cpf. Não precisa de require explícito se o arquivo estiver em app/validators/.
Mensagem de erro localizada via config/locales/pt-BR.yml
pt-BR:
errors:
messages:
invalid_cpf: "não é um CPF válido"
activerecord:
attributes:
user:
cpf: "CPF"Testar o validator no nível de model com RSpec + FactoryBot
Factory com CPF válido gerado sinteticamente
FactoryBot.define do
factory :user do
name { Faker::Name.name }
email { Faker::Internet.email }
cpf { CpfFactory.generate }
end
endCpfFactory.generate é implementado na seção de geração sintética abaixo. Não dependa de dados externos ou serviços em factories.
Teste de persistência: create(:user, cpf: cpf_invalido) deve falhar
RSpec.describe User, type: :model do
it 'é invalido com CPF incorreto' do
user = build(:user, cpf: '111.111.111-11')
expect(user).not_to be_valid
expect(user.errors[:cpf]).to include('não é um CPF válido')
end
it 'nao persiste com CPF invalido' do
expect { create(:user, cpf: '000.000.000-00') }.to raise_error(ActiveRecord::RecordInvalid)
end
it 'persiste com CPF valido' do
expect { create(:user) }.not_to raise_error
end
endSanitização e normalização no before_validation
before_validation :normalize_cpf remove a máscara antes de validar
class User < ApplicationRecord
before_validation :normalize_cpf
validates :cpf, presence: true, cpf: true
private
def normalize_cpf
self.cpf = cpf.to_s.gsub(/\D/, '') if cpf.present?
end
endArmazenar apenas os 11 dígitos no banco (sem pontuação)
Armazenar 52998224725 em vez de 529.982.247-25 simplifica consultas SQL, índices e comparações. A formatação é responsabilidade da camada de apresentação.
Apresentar formatado na view com helper cpf_format
module ApplicationHelper
def cpf_format(raw)
return '' if raw.blank?
digits = raw.gsub(/\D/, '')
return raw unless digits.length == 11
"#{digits[0..2]}.#{digits[3..5]}.#{digits[6..8]}-#{digits[9..10]}"
end
endNa view: <%= cpf_format(user.cpf) %>.
Unicidade e índice no banco de dados
Migration com add_index :users, :cpf, unique: true
class AddUniqueIndexToUsersCpf < ActiveRecord::Migration[7.2]
def change
add_index :users, :cpf, unique: true
end
endSem esse índice, validates :cpf, uniqueness: true faz um SELECT antes de cada INSERT, o que é lento e sujeito a race conditions em produção.
validates :cpf, uniqueness: true e race conditions
AVISO:validates :cpf, uniqueness: trueprotege contra duplicatas no nível da aplicação, mas não no banco. Com concorrência, dois processos podem passar pela validação simultaneamente antes de qualquer um persistir. O índiceUNIQUEno banco é a única garantia real. TrateActiveRecord::RecordNotUniqueno controller.
validates :cpf, uniqueness: { scope: :tenant_id } em apps multi-tenant
Em SaaS multi-tenant, o mesmo CPF pode existir em tenants diferentes. O scope garante unicidade dentro de um tenant. O índice correspondente fica em add_index :users, [:cpf, :tenant_id], unique: true.
Considerações LGPD ao armazenar CPF
CPF é dado pessoal sob LGPD Art. 5º, I
O CPF identifica uma pessoa natural de forma direta. Isso o classifica como dado pessoal na Lei 13.709/2018 (LGPD), Art. 5º, I. O tratamento exige base legal explícita: Art. 7º, V (execução de contrato) ou Art. 7º, IX (legítimo interesse, desde que documentado).
Criptografar em repouso: attr_encrypted ou coluna cifrada no PostgreSQL 16
# Gemfile
gem 'attr_encrypted', '~> 4.0'
# model
class User < ApplicationRecord
attr_encrypted :cpf,
key: Rails.application.credentials.cpf_encryption_key,
encode: true,
encode_iv: true
endPostgreSQL 16 também suporta colunas bytea com criptografia via pgcrypto. A escolha depende de onde você prefere gerenciar chaves.
Nunca logar CPF em texto plano
# config/application.rb
config.filter_parameters += [:cpf, :cpf_confirmacao]Isso garante que o CPF apareça como [FILTERED] nos logs do Rails. Revise também qualquer gem de APM (Datadog, New Relic, Sentry) que capture parâmetros de request.
Gerar CPFs sintéticos para o ambiente de testes
Por que não usar CPFs reais em fixtures
Usar CPFs reais em seeds, factories ou commits de teste viola o princípio de minimização de dados da LGPD Art. 6º, III. Além disso, repositórios públicos com dados pessoais reais expõem a empresa a notificações da ANPD.
Implementar CpfFactory.generate em Ruby
module CpfFactory
def self.generate
base = Array.new(9) { rand(0..9) }
calc = lambda do |digits, weight_start|
sum = digits.each_with_index.sum { |d, i| d * (weight_start - i) }
remainder = sum % 11
remainder < 2 ? 0 : 11 - remainder
end
first = calc.call(base, 10)
second = calc.call(base + [first], 11)
(base + [first, second]).join
end
endAlternativa via API REST do FakeForge
Para gerar lotes maiores sem escrever código, use o endpoint da API do FakeForge:
curl "https://fakeforge.com.br/api/generate?type=cpf&quantity=50&format=json"A resposta inclui 50 CPFs válidos prontos para seeds ou fixtures. O gerador de CPF na interface web cobre casos de uso manuais durante o desenvolvimento.
DICA: Se você precisar de CPFs correlacionados com nomes, endereços e e-mails para um seed mais realista, o tipo pessoa retorna todos esses campos em um único objeto coerente. Confira o gerador de pessoa para ver a estrutura de saída.Benchmark: validação em lote sem degradar a requisição
Custo de valid_cpf? é O(1) -- seguro em callbacks
O algoritmo faz exatamente 20 multiplicações e 2 divisões, independente da entrada. A validação fica abaixo de 1 microssegundo por chamada em hardware moderno. Usar em before_validation ou validate não introduz latência mensurável em requisições web normais.
Quando mover validação para job: importação CSV com 10k+ registros
Se você importa planilhas de clientes com dezenas de milhares de linhas, valide fora da requisição HTTP. Processe em um ActiveJob, retorne um relatório de erros assíncrono e evite timeouts do Puma.
Padrão com Dry::Validation para pipelines mais complexos
require 'dry-validation'
class UserContract < Dry::Validation::Contract
params do
required(:cpf).filled(:string)
end
rule(:cpf) do
key.failure('não é um CPF válido') unless valid_cpf?(values[:cpf])
end
endDry::Validation separa contratos de dados dos models ActiveRecord, o que facilita reuso em workers, importadores e APIs externas que não passam pelo fluxo Rails MVC.
Resumo
- Implemente
valid_cpf?em Ruby puro sem gems: normalize comgsub(/\D/, ''), rejeite sequências repetidas e calcule os dois dígitos verificadores via mod-11 com os pesos corretos. - Crie
app/validators/cpf_validator.rbherdando deActiveModel::EachValidator, usevalidates :cpf, cpf: trueno model e adicionebefore_validation :normalize_cpfpara remover pontuação antes de validar. - Coloque
add_index :users, :cpf, unique: truena migration e trateActiveRecord::RecordNotUniqueno controller. A validação do Rails não previne race conditions, o índice previne. - Configure
config.filter_parameters += [:cpf]e documente a base legal (LGPD Art. 7º, V ou IX) antes de ir para produção. Considere criptografia em repouso comattr_encryptedoupgcrypto. - Use
CpfFactory.generateou a API do FakeForge para fixtures sintéticas. Nunca use CPFs reais em código de teste -- isso viola LGPD Art. 6º, III e expõe o repositório. - O próximo passo natural é aplicar a mesma estrutura para validar CNPJ: o algoritmo mod-11 é idêntico, com dois ciclos de pesos em vez de um.
Perguntas frequentes
Como capturar e comunicar erro quando um CPF duplicado é enviado?+
Use `rescue ActiveRecord::RecordNotUnique` no controller. O índice `UNIQUE` levanta essa exceção antes do Rails acabar a transação. Retorne HTTP 422 com mensagem clara: `{ error: "CPF já cadastrado" }`. Isso protege contra race conditions onde dois requests simultâneos passam pela validação Rails mas só um consegue persistir.
Tenho 100k usuários sem validação. Como adiciono validação de CPF sem quebrar?+
Crie uma migration que executa `UPDATE users SET cpf = TRIM(cpf)` e remove duplicatas mantendo o registro mais antigo. Depois adicione o validador com `validates :cpf, cpf: true, allow_blank: true`. Rode em background job para não travar. Monitore erros, corrija CPFs malformados manualmente, depois mude para `allow_blank: false`.
Na API, devo sempre normalizar CPF ou deixar o cliente fazer?+
Normalize no servidor via `before_validation`. Clientes sempre enviam formatos sujos: pontos, hífens, espaços. Tratá-los no model garante consistência independente da origem (API, form web, import CSV). Armazene normalizado no banco. Na view/resposta JSON, formate com helper: `cpf_format(user.cpf)`.
Posso usar o mesmo CpfValidator em User, Customer e Supplier?+
Sim. O validador é agnóstico ao model — só testa o formato do CPF. Se cada model tem `validates :cpf, cpf: true`, reutiliza `CpfValidator` automaticamente. Para lógica mais específica (ex: scoped por tenant), deixe no model com `before_validation`. O validator fica simples, focado no algoritmo mod-11.
Validar CPF contra o servidor da Receita Federal depois de passar na validação local?+
Não faça rotineiramente — a Receita Federal não expõe API pública de validação. CPFs válidos localmente (mod-11 + rejeição de sequências repetidas) são suficientes para testes. Para onboarding real com compliance, integre com serviços third-party como Serpro ou Brunnsys, mas fora do ciclo de requisição HTTP. Cache o resultado por 24h.