Skip to content
This repository was archived by the owner on Oct 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ const docTemplate = `{
"name": "placa",
"in": "query"
},
{
"type": "string",
"description": "Driving licence number to be validated",
"name": "cnh",
"in": "query"
},
{
"type": "string",
"description": "Vehicle registration number, 9 or 11 digits",
"name": "renavam",
"in": "query"
},
{
"type": "string",
"description": "Bank slip linha digitável, 47 digits",
"name": "boleto",
"in": "query"
},
{
"type": "string",
"description": "PIX key in any of its five forms",
Expand Down
18 changes: 18 additions & 0 deletions docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@
"name": "placa",
"in": "query"
},
{
"type": "string",
"description": "Driving licence number to be validated",
"name": "cnh",
"in": "query"
},
{
"type": "string",
"description": "Vehicle registration number, 9 or 11 digits",
"name": "renavam",
"in": "query"
},
{
"type": "string",
"description": "Bank slip linha digitável, 47 digits",
"name": "boleto",
"in": "query"
},
{
"type": "string",
"description": "PIX key in any of its five forms",
Expand Down
12 changes: 12 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ paths:
in: query
name: placa
type: string
- description: Driving licence number to be validated
in: query
name: cnh
type: string
- description: Vehicle registration number, 9 or 11 digits
in: query
name: renavam
type: string
- description: Bank slip linha digitável, 47 digits
in: query
name: boleto
type: string
- description: PIX key in any of its five forms
in: query
name: pix
Expand Down
3 changes: 3 additions & 0 deletions handlers/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import (
// @Param pis query string false "PIS/PASEP/NIT/NIS to be validated"
// @Param titulo query string false "Voter registration number to be validated"
// @Param placa query string false "Vehicle plate, old or Mercosul pattern"
// @Param cnh query string false "Driving licence number to be validated"
// @Param renavam query string false "Vehicle registration number, 9 or 11 digits"
// @Param boleto query string false "Bank slip linha digitável, 47 digits"
// @Param pix query string false "PIX key in any of its five forms"
// @Param ie query string false "State registration; requires uf"
// @Param uf query string false "Two-letter state code qualifying ie"
Expand Down
49 changes: 49 additions & 0 deletions handlers/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,52 @@ func TestValidateHandlerInscricaoEstadual(t *testing.T) {
})
}
}

func TestValidateHandlerVehicleAndBoleto(t *testing.T) {
tests := []struct {
name string
query string
wantStatus int
wantValid bool
}{
{"cnh", "cnh=00000000119", http.StatusOK, true},
{"cnh_invalid", "cnh=12345678901", http.StatusUnprocessableEntity, false},
{"renavam_nine", "renavam=639884962", http.StatusOK, true},
{"renavam_eleven", "renavam=00639884962", http.StatusOK, true},
{"boleto", "boleto=00190000090114971860168524522114675860000102656", http.StatusOK, true},
{"boleto_arrecadacao_is_not_checked", "boleto=846700000017435900240200610207807116000000000000", http.StatusUnprocessableEntity, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
ValidateHandler(recorder, httptest.NewRequest(http.MethodGet, "/validate?"+tt.query, nil))

if recorder.Code != tt.wantStatus {
t.Fatalf("expected status %d, got %d: %s", tt.wantStatus, recorder.Code, recorder.Body.String())
}

var response models.ValidationResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("could not decode the response: %v", err)
}
if response.IsValid != tt.wantValid {
t.Fatalf("expected is_valid=%v, got %v (%s)", tt.wantValid, response.IsValid, response.Message)
}
})
}
}

// The batch endpoint reads the same table, so the three new documents must be
// reachable there without another line of wiring.
func TestBatchReachesTheNewDocuments(t *testing.T) {
recorder, response := postBatch(t, `{"items":[
{"key":"cnh","value":"00000000119"},
{"key":"renavam","value":"639884962"},
{"key":"boleto","value":"00190000090114971860168524522114675860000102656"}
]}`)

if recorder.Code != http.StatusOK || response.Summary.Valid != 3 {
t.Fatalf("expected all three to validate through the batch: %+v", response)
}
}
117 changes: 117 additions & 0 deletions utils/boleto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package utils

import "regexp"

var boletoChars = regexp.MustCompile(`^[\d.\-\s]*$`)

// A linha digitável is not the barcode. It is the barcode's 44 digits shuffled
// into three groups that each carry their own module-10 check digit, so a
// person typing it from paper is told which group they got wrong rather than
// simply that the whole number is bad.
//
// Validating it therefore means two separate things: check each group, then put
// the barcode back together and check its own digit.

// boletoGroups are the three typed groups: where each starts, where its digits
// end, and where its check digit sits.
var boletoGroups = []struct{ start, end, check int }{
{0, 9, 9},
{10, 20, 20},
{21, 31, 31},
}

// barcodeOrder reassembles the 44-digit barcode out of the typed line. The
// pieces are not in the same order in both, which is the whole reason this
// mapping has to exist.
var barcodeOrder = [][2]int{{0, 4}, {32, 47}, {4, 9}, {10, 20}, {21, 31}}

// barcodeCheckDigitPosition is where the general check digit sits once the
// barcode is reassembled — inside it, not at the end.
const barcodeCheckDigitPosition = 4

// ValidateBoleto checks the 47-digit linha digitável of a bank slip.
func ValidateBoleto(boleto string) (bool, string, string) {
if !boletoChars.MatchString(boleto) {
return false, boleto, "Invalid boleto (unexpected characters)"
}

digits := nonDigitsRegex.ReplaceAllString(boleto, "")

// Utility and tax slips are 48 digits with a different structure entirely.
// Saying so is not the same as calling the number invalid: it has not been
// checked, and reporting it as invalid would be a lie about a slip that may
// well be good.
if len(digits) == 48 {
return false, digits, "Arrecadação slips (48 digits) are not supported yet — this number was not checked"
}

if len(digits) != 47 {
return false, digits, "Invalid boleto (expected 47 digits)"
}

for i, group := range boletoGroups {
expected := mod10Boleto(digits[group.start:group.end])
if expected != int(digits[group.check]-'0') {
return false, digits, "Invalid boleto (check digit of group " + string(rune('1'+i)) + " does not match)"
}
}

barcode := ""
for _, span := range barcodeOrder {
barcode += digits[span[0]:span[1]]
}

withoutCheck := barcode[:barcodeCheckDigitPosition] + barcode[barcodeCheckDigitPosition+1:]
if mod11Boleto(withoutCheck) != int(barcode[barcodeCheckDigitPosition]-'0') {
return false, digits, "Invalid boleto (general check digit does not match)"
}

return true, digits, "Valid boleto"
}

// mod10Boleto is the Luhn-shaped check the typed groups use: alternating
// weights of two and one from the right, with any product above nine reduced by
// nine.
func mod10Boleto(digits string) int {
sum := 0

for i := len(digits) - 1; i >= 0; i-- {
weight := 2
if (len(digits)-1-i)&1 == 1 {
weight = 1
}

product := int(digits[i]-'0') * weight
if product > 9 {
product -= 9
}
sum += product
}

if remainder := sum % 10; remainder > 0 {
return 10 - remainder
}
return 0
}

// mod11Boleto is the barcode's own check: weights cycling two through nine from
// the right, where a remainder of 0 or 1 yields 1 rather than the 0 most
// Brazilian documents use.
func mod11Boleto(digits string) int {
sum, weight := 0, 2

for i := len(digits) - 1; i >= 0; i-- {
sum += int(digits[i]-'0') * weight
if weight < 9 {
weight++
} else {
weight = 2
}
}

if remainder := sum % 11; remainder == 0 || remainder == 1 {
return 1
} else {
return 11 - remainder
}
}
6 changes: 5 additions & 1 deletion utils/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ var validators = map[string]validatorFunc{
return Outcome{Sanitized: sanitized, Valid: valid, Message: message}
},
"pis": plain(ValidatePIS),
"cnh": plain(ValidateCNH),
"renavam": plain(ValidateRenavam),
"boleto": plain(ValidateBoleto),
"titulo": plain(ValidateTituloEleitor),
"placa": plain(ValidatePlate),
"pix": plain(ValidatePixKey),
Expand All @@ -57,7 +60,8 @@ var validators = map[string]validatorFunc{
// is random in Go and the endpoint's behaviour must not be.
var ValidationKeys = []string{
"email", "cpf", "cnpj", "documento", "ie", "pis", "titulo",
"placa", "pix", "name", "telephone", "plastic", "rg", "cep",
"cnh", "renavam", "placa", "boleto", "pix",
"name", "telephone", "plastic", "rg", "cep",
}

// SupportedKeys reports what can be validated, for error messages.
Expand Down
108 changes: 108 additions & 0 deletions utils/vehicle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package utils

import "regexp"

var (
cnhChars = regexp.MustCompile(`^[\d.\-\s]*$`)
renavamChars = regexp.MustCompile(`^[\d.\-\s]*$`)
)

// ValidateCNH checks a driving licence number.
//
// The two check digits are unusual in being coupled: when the first overflows
// past nine it is written as zero, and the second is then computed two lower to
// compensate. Treating them independently — which is what most of the wrong
// implementations of this do — accepts numbers the Detran would reject.
func ValidateCNH(cnh string) (bool, string, string) {
if !cnhChars.MatchString(cnh) {
return false, cnh, "Invalid CNH format (unexpected characters)"
}

digits := nonDigitsRegex.ReplaceAllString(cnh, "")
if len(digits) != 11 {
return false, digits, "Invalid CNH format (incorrect length)"
}
if allSameDigit(digits) {
return false, digits, "Invalid CNH format"
}

first, sum := 0, 0
for i := 0; i < 9; i++ {
sum += int(digits[i]-'0') * (9 - i)
}

first = sum % 11
decrement := 0
if first >= 10 {
first, decrement = 0, 2
}
if first != int(digits[9]-'0') {
return false, digits, "Invalid CNH format"
}

sum = 0
for i := 0; i < 9; i++ {
sum += int(digits[i]-'0') * (i + 1)
}

second := sum%11 - decrement
if second < 0 {
second += 11
}
if second >= 10 {
second = 0
}
if second != int(digits[10]-'0') {
return false, digits, "Invalid CNH format"
}

return true, digits, "Valid CNH format"
}

// ValidateRenavam checks a vehicle registration number.
//
// Nine digits was the old format and eleven is the current one; the shorter is
// the longer with leading zeros, so both are accepted and normalised to eleven.
// The weights run from the right and cycle two through nine, unlike the
// left-to-right descending series most Brazilian documents use.
func ValidateRenavam(renavam string) (bool, string, string) {
if !renavamChars.MatchString(renavam) {
return false, renavam, "Invalid RENAVAM format (unexpected characters)"
}

digits := nonDigitsRegex.ReplaceAllString(renavam, "")
if len(digits) != 9 && len(digits) != 11 {
return false, digits, "Invalid RENAVAM format (incorrect length)"
}

for len(digits) < 11 {
digits = "0" + digits
}

// Not in the reference this was checked against, but consistent with every
// other document here: a run of one repeated digit is not a registration.
if allSameDigit(digits) {
return false, digits, "Invalid RENAVAM format"
}

sum, weight := 0, 2
for i := 9; i >= 0; i-- {
sum += int(digits[i]-'0') * weight
if weight >= 9 {
weight = 2
} else {
weight++
}
}

expected := 11 - sum%11
if expected >= 10 {
expected = 0
}

if expected != int(digits[10]-'0') {
return false, digits, "Invalid RENAVAM format"
}

return true, digits, "Valid RENAVAM format"
}
Loading
Loading