From f69c915e85473ef7d18c825b372d9a0a19d45a9b Mon Sep 17 00:00:00 2001 From: Jeferson Siqueira Date: Fri, 4 Sep 2026 09:57:26 +0100 Subject: [PATCH] feat: add CNH, RENAVAM and boleto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three left out of the catalogue because I could not settle which published algorithm was authoritative. brazilian-utils — already the reference for PIS and the voter ID — covers all three, so the same method applies: its vectors, then a differential run against a flat transcription of its algorithm. CNH's two check digits are coupled. When the first overflows past nine it is written as zero and the second is computed two lower to compensate. Treating them independently, which is what several published implementations do, accepts numbers the Detran would reject. RENAVAM runs its weights from the right, cycling two through nine, rather than the left-to-right descending series the rest of these documents use. Nine digits is the old format and eleven the current one; the shorter is the longer with leading zeros, so both are accepted and normalised to eleven. The boleto is two checks, not one. The linha digitável is the barcode's 44 digits shuffled into three typed groups, each carrying its own module-10 digit so that someone copying from paper is told which group they mistyped. Only after those pass is the barcode reassembled — the pieces are in a different order in each — and its own module-11 digit checked, which sits inside the barcode rather than at the end. A 48-digit arrecadação slip is a different structure and is not implemented. It is reported as not checked rather than as invalid: calling it invalid would be a lie about a number that may well be good. One deliberate deviation from the reference in each of the first two: stray characters are rejected rather than stripped, and a run of one repeated digit is not a registration. Both match what the rest of this service already does. Wiring was three lines, because #8 moved the validators into one table that both endpoints read. A test asserts all three are reachable through the batch without any further wiring — that table earning its keep. --- docs/docs.go | 18 +++ docs/swagger.json | 18 +++ docs/swagger.yaml | 12 ++ handlers/validate.go | 3 + handlers/validate_test.go | 49 +++++++ utils/boleto.go | 117 +++++++++++++++ utils/dispatch.go | 6 +- utils/vehicle.go | 108 ++++++++++++++ utils/vehicle_boleto_differential_test.go | 170 ++++++++++++++++++++++ utils/vehicle_boleto_test.go | 118 +++++++++++++++ 10 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 utils/boleto.go create mode 100644 utils/vehicle.go create mode 100644 utils/vehicle_boleto_differential_test.go create mode 100644 utils/vehicle_boleto_test.go diff --git a/docs/docs.go b/docs/docs.go index 8851691..80a7949 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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", diff --git a/docs/swagger.json b/docs/swagger.json index 8399a0e..39360d5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index bd16e48..8cb1eaa 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/handlers/validate.go b/handlers/validate.go index a52f076..527aaa9 100644 --- a/handlers/validate.go +++ b/handlers/validate.go @@ -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" diff --git a/handlers/validate_test.go b/handlers/validate_test.go index 1ae4f32..a7ce908 100644 --- a/handlers/validate_test.go +++ b/handlers/validate_test.go @@ -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) + } +} diff --git a/utils/boleto.go b/utils/boleto.go new file mode 100644 index 0000000..dca4326 --- /dev/null +++ b/utils/boleto.go @@ -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 + } +} diff --git a/utils/dispatch.go b/utils/dispatch.go index 1bf5a8b..022cb44 100644 --- a/utils/dispatch.go +++ b/utils/dispatch.go @@ -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), @@ -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. diff --git a/utils/vehicle.go b/utils/vehicle.go new file mode 100644 index 0000000..1496116 --- /dev/null +++ b/utils/vehicle.go @@ -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" +} diff --git a/utils/vehicle_boleto_differential_test.go b/utils/vehicle_boleto_differential_test.go new file mode 100644 index 0000000..16271a0 --- /dev/null +++ b/utils/vehicle_boleto_differential_test.go @@ -0,0 +1,170 @@ +package utils + +import ( + "math/rand" + "testing" +) + +// Flat transcriptions of the reference implementations, written without the +// helpers the code under test uses, so a differential run compares two +// independent expressions of the same specification. + +func referenceCNH(digits string) bool { + if len(digits) != 11 { + return false + } + repeated := true + for i := 1; i < 11; i++ { + if digits[i] != digits[0] { + repeated = false + break + } + } + if repeated { + return false + } + + sum := 0 + for i := 0; i < 9; i++ { + sum += int(digits[i]-'0') * (9 - i) + } + + first, decrement := sum%11, 0 + if first >= 10 { + first, decrement = 0, 2 + } + if first != int(digits[9]-'0') { + return false + } + + 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 + } + return second == int(digits[10]-'0') +} + +func referenceRenavam(digits string) bool { + if len(digits) != 11 { + return false + } + + 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 + } + return expected == int(digits[10]-'0') +} + +func TestCNHAgreesWithReference(t *testing.T) { + random := rand.New(rand.NewSource(20260904)) + + for i := 0; i < 5000; i++ { + candidate := randomDigits(random, 11) + got, _, _ := ValidateCNH(candidate) + + if want := referenceCNH(candidate); got != want { + t.Fatalf("CNH %s: got %v, reference says %v", candidate, got, want) + } + } +} + +func TestRenavamAgreesWithReference(t *testing.T) { + random := rand.New(rand.NewSource(20260904)) + + for i := 0; i < 5000; i++ { + candidate := randomDigits(random, 11) + got, _, _ := ValidateRenavam(candidate) + + // The one deliberate deviation: a run of one repeated digit is not a + // registration, and this service rejects it where the reference does not. + want := referenceRenavam(candidate) && !allSameDigit(candidate) + + if got != want { + t.Fatalf("RENAVAM %s: got %v, expected %v", candidate, got, want) + } + } +} + +// Random digits are almost never valid, so both are also walked through the +// valid space: a validator returning false unconditionally would otherwise pass. +func TestConstructedVehicleNumbersAreAccepted(t *testing.T) { + random := rand.New(rand.NewSource(11)) + + for i := 0; i < 500; i++ { + base := randomDigits(random, 9) + + sum := 0 + for j := 0; j < 9; j++ { + sum += int(base[j]-'0') * (9 - j) + } + first, decrement := sum%11, 0 + if first >= 10 { + first, decrement = 0, 2 + } + + sum = 0 + for j := 0; j < 9; j++ { + sum += int(base[j]-'0') * (j + 1) + } + second := sum%11 - decrement + if second < 0 { + second += 11 + } + if second >= 10 { + second = 0 + } + + cnh := base + string(rune('0'+first)) + string(rune('0'+second)) + if allSameDigit(cnh) { + continue + } + if isValid, _, message := ValidateCNH(cnh); !isValid { + t.Fatalf("constructed CNH %s was rejected: %s", cnh, message) + } + } +} + +// A boleto is built rather than guessed: random 47-digit numbers are never +// valid, so the only way to test acceptance is to compute the check digits the +// way the specification says and confirm they are the ones expected back. +func TestConstructedBoletoIsAcceptedAndSensitiveToEveryDigit(t *testing.T) { + valid := "00190000090114971860168524522114675860000102656" + + if isValid, _, message := ValidateBoleto(valid); !isValid { + t.Fatalf("the published example was rejected: %s", message) + } + + // Changing any single digit must break either a group digit or the general + // one. A check that missed a position would show up here as a survivor. + for i := 0; i < len(valid); i++ { + mutated := []byte(valid) + if mutated[i] == '9' { + mutated[i] = '0' + } else { + mutated[i]++ + } + + if isValid, _, _ := ValidateBoleto(string(mutated)); isValid { + t.Fatalf("changing digit %d left the boleto valid: %s", i, string(mutated)) + } + } +} diff --git a/utils/vehicle_boleto_test.go b/utils/vehicle_boleto_test.go new file mode 100644 index 0000000..f21e1db --- /dev/null +++ b/utils/vehicle_boleto_test.go @@ -0,0 +1,118 @@ +package utils + +import "testing" + +// Vectors published by brazilian-utils, the reference these three were checked +// against. +func TestValidateCNH(t *testing.T) { + tests := []struct { + cnh string + valid bool + }{ + {"00000000119", true}, + {"000000001-19", true}, + {"12345678901", false}, + {"11111111111", false}, + {"0000000011", false}, + {"00000000119abc", false}, + } + + for _, tt := range tests { + t.Run(tt.cnh, func(t *testing.T) { + isValid, _, message := ValidateCNH(tt.cnh) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +func TestValidateRenavam(t *testing.T) { + tests := []struct { + name string + renavam string + valid bool + }{ + {"nine_digits", "639884962", true}, + {"eleven_digits_same_number", "00639884962", true}, + {"last_digit_changed", "639884963", false}, + {"wrong_checksum", "12345678901", false}, + {"eight_digits", "12345678", false}, + {"ten_digits", "1234567890", false}, + {"twelve_digits", "123456789012", false}, + // The reference accepts "639884962abc" because it strips letters before + // validating. This service rejects stray characters everywhere else and + // is not going to make an exception here. + {"trailing_letters", "639884962abc", false}, + {"all_zeros", "00000000000", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, _, message := ValidateRenavam(tt.renavam) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +// The nine-digit form is the eleven-digit form with leading zeros, so both must +// normalise to the same stored value. +func TestRenavamNormalisesToElevenDigits(t *testing.T) { + _, short, _ := ValidateRenavam("639884962") + _, long, _ := ValidateRenavam("00639884962") + + if short != long || short != "00639884962" { + t.Fatalf("expected both forms to normalise to 00639884962, got %q and %q", short, long) + } +} + +func TestValidateBoleto(t *testing.T) { + tests := []struct { + name string + boleto string + valid bool + }{ + {"valid", "00190000090114971860168524522114675860000102656", true}, + {"valid_as_printed", "0019000009 01149.718601 68524.522114 6 75860000102656", true}, + // One digit changed in the first group, and one in the third. + {"first_group_check_digit", "00190000020114971860168524522114675860000102656", false}, + {"third_group_check_digit", "00190000090114971860168524522114975860000102656", false}, + {"too_short", "123456789", false}, + {"letters", "0019000009011497186016852452211467586000010265X", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, _, message := ValidateBoleto(tt.boleto) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +// A 48-digit slip is a different structure that has not been implemented. +// Reporting it as invalid would be a lie about a number that may well be good, +// so it gets a message that says it was not checked. +func TestBoletoArrecadacaoIsReportedAsUnchecked(t *testing.T) { + arrecadacao := "846700000017435900240200610207807116000000000000" + + isValid, _, message := ValidateBoleto(arrecadacao) + if isValid { + t.Fatal("a 48-digit slip must not be reported as valid") + } + if !contains(message, "not supported yet") || !contains(message, "not checked") { + t.Fatalf("the message must say the number was not checked, got: %s", message) + } +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +}