diff --git a/docs/docs.go b/docs/docs.go index f17c46e..5f2d0e7 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -71,6 +71,18 @@ const docTemplate = `{ "name": "pix", "in": "query" }, + { + "type": "string", + "description": "State registration; requires uf", + "name": "ie", + "in": "query" + }, + { + "type": "string", + "description": "Two-letter state code qualifying ie", + "name": "uf", + "in": "query" + }, { "type": "string", "description": "Name to be validated", diff --git a/docs/swagger.json b/docs/swagger.json index 685e963..59335bc 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -60,6 +60,18 @@ "name": "pix", "in": "query" }, + { + "type": "string", + "description": "State registration; requires uf", + "name": "ie", + "in": "query" + }, + { + "type": "string", + "description": "Two-letter state code qualifying ie", + "name": "uf", + "in": "query" + }, { "type": "string", "description": "Name to be validated", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 15fa0a5..6713f57 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -67,6 +67,14 @@ paths: in: query name: pix type: string + - description: State registration; requires uf + in: query + name: ie + type: string + - description: Two-letter state code qualifying ie + in: query + name: uf + type: string - description: Name to be validated in: query name: name diff --git a/handlers/validate.go b/handlers/validate.go index 685c7ad..50c2b0e 100644 --- a/handlers/validate.go +++ b/handlers/validate.go @@ -23,6 +23,8 @@ import ( // @Param titulo query string false "Voter registration number to be validated" // @Param placa query string false "Vehicle plate, old or Mercosul pattern" // @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" // @Param name query string false "Name to be validated" // @Param telephone query string false "Phone number to be validated" // @Param phone query string false "Phone number to be validated" @@ -63,6 +65,9 @@ func ValidateHandler(w http.ResponseWriter, r *http.Request) { } else if documento := r.URL.Query().Get("documento"); documento != "" { isValid, sanitizedValue, message := utils.ValidateDocument(documento) response = createResponse("documento", documento, sanitizedValue, isValid, message, start, false) + } else if ie := r.URL.Query().Get("ie"); ie != "" { + isValid, sanitizedValue, message := utils.ValidateInscricaoEstadual(ie, r.URL.Query().Get("uf")) + response = createResponse("ie", ie, sanitizedValue, isValid, message, start, false) } else if pis := r.URL.Query().Get("pis"); pis != "" { isValid, sanitizedValue, message := utils.ValidatePIS(pis) response = createResponse("pis", pis, sanitizedValue, isValid, message, start, false) @@ -111,7 +116,7 @@ func validationCount(r *http.Request) int { if strings.TrimSpace(r.URL.Query().Get("email")) != "" { count++ } - for _, key := range []string{"cnpj", "documento", "pis", "titulo", "placa", "pix"} { + for _, key := range []string{"cnpj", "documento", "pis", "titulo", "placa", "pix", "ie"} { if strings.TrimSpace(r.URL.Query().Get(key)) != "" { count++ } diff --git a/handlers/validate_test.go b/handlers/validate_test.go index cd38adb..1ae4f32 100644 --- a/handlers/validate_test.go +++ b/handlers/validate_test.go @@ -177,3 +177,41 @@ func TestValidateHandlerNewDocuments(t *testing.T) { }) } } + +func TestValidateHandlerInscricaoEstadual(t *testing.T) { + tests := []struct { + name string + query string + wantStatus int + wantValid bool + }{ + {"valid_with_state", "ie=0100482300112&uf=AC", http.StatusOK, true}, + {"valid_elsewhere", "ie=0100482300112&uf=SP", http.StatusUnprocessableEntity, false}, + {"missing_state", "ie=0100482300112", http.StatusUnprocessableEntity, false}, + {"unknown_state", "ie=0100482300112&uf=XX", http.StatusUnprocessableEntity, false}, + {"exempt", "ie=ISENTO&uf=SP", http.StatusOK, true}, + // uf qualifies ie rather than being a validation of its own, so it must + // not push the request over the one-parameter limit. + {"uf_is_not_a_second_validation", "ie=0100482300112&uf=AC", http.StatusOK, true}, + {"ie_with_cpf", "ie=0100482300112&uf=AC&cpf=529.982.247-25", http.StatusBadRequest, 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) + } + }) + } +} diff --git a/utils/ie_arithmetic.go b/utils/ie_arithmetic.go new file mode 100644 index 0000000..ae07924 --- /dev/null +++ b/utils/ie_arithmetic.go @@ -0,0 +1,98 @@ +package utils + +import "strings" + +// The arithmetic every state registration is built from. +// +// Twenty-seven states, twenty-seven roteiros de crítica — but almost all of +// them are the same three steps with different constants: take a prefix of the +// digits, multiply by descending weights, and fold the remainder into a check +// digit. Keeping those steps here is what lets most states be four lines +// instead of forty. + +// descendingWeights produces the weight series the SINTEGRA documents print +// above the digits: counting down from start, and wrapping back to 9 after 2. +// +// descendingWeights(4, 11) is 4 3 2 9 8 7 6 5 4 3 2 — the series Acre's roteiro +// shows, and the reason the wrap exists at all. +func descendingWeights(start, size int) []int { + weights := make([]int, 0, size) + + for len(weights) < size { + weights = append(weights, start) + if start == 2 { + start = 10 + } + start-- + } + + return weights +} + +// weightedTotal multiplies each digit by its weight and sums, stopping at +// whichever of the two runs out first. +func weightedTotal(digits string, weights []int) int { + total := 0 + + for i := 0; i < len(weights) && i < len(digits); i++ { + total += int(digits[i]-'0') * weights[i] + } + + return total +} + +// mod11Check is the rule most states share: eleven minus the remainder, with +// remainders of 0 and 1 both yielding zero because the alternative needs two +// characters. +func mod11Check(total int) int { + if remainder := total % 11; remainder >= 2 { + return 11 - remainder + } + return 0 +} + +// mod10Check is the same idea in base ten, used by Bahia for the registrations +// whose leading digit puts them in the module-10 group. +func mod10Check(total int) int { + if remainder := total % 10; remainder != 0 { + return 10 - remainder + } + return 0 +} + +// mod11TimesTen covers the states whose roteiro multiplies the sum by ten +// before taking the remainder — Alagoas and Rio Grande do Norte — where a +// remainder of 10 yields zero. +func mod11TimesTen(total int) int { + if remainder := total * 10 % 11; remainder != 10 { + return remainder + } + return 0 +} + +// hasPrefix reports whether the registration starts with any of the state +// codes given. An empty list means the state does not fix its opening digits. +func hasPrefix(ie string, prefixes []string) bool { + if len(prefixes) == 0 { + return true + } + + for _, prefix := range prefixes { + if strings.HasPrefix(ie, prefix) { + return true + } + } + + return false +} + +// digitsOnly reports whether every character is a digit, which every state +// requires except São Paulo, whose rural registrations begin with a P. +func digitsOnly(ie string) bool { + for i := 0; i < len(ie); i++ { + if ie[i] < '0' || ie[i] > '9' { + return false + } + } + return len(ie) > 0 +} diff --git a/utils/ie_states.go b/utils/ie_states.go new file mode 100644 index 0000000..eda82f0 --- /dev/null +++ b/utils/ie_states.go @@ -0,0 +1,362 @@ +package utils + +import "strconv" + +// One entry per state. The common shape — a fixed length, an optional state +// prefix, one trailing check digit over descending weights — is expressed as +// data; the states that genuinely do something else get a function. + +type ieValidator func(ie string) bool + +// trailingMod11 builds the shape most states use: `length` digits, optionally +// opening with one of `prefixes`, ending in a single mod-11 check digit taken +// over weights descending from `weightStart`. +func trailingMod11(length, weightStart int, prefixes ...string) ieValidator { + return func(ie string) bool { + if len(ie) != length || !hasPrefix(ie, prefixes) { + return false + } + + base := ie[:length-1] + total := weightedTotal(base, descendingWeights(weightStart, length-1)) + return int(ie[length-1]-'0') == mod11Check(total) + } +} + +// twoTrailingMod11 builds the thirteen-digit shape used by Acre and the Federal +// District: two check digits, the second computed over the first. +func twoTrailingMod11(prefixes ...string) ieValidator { + return func(ie string) bool { + if len(ie) != 13 || !hasPrefix(ie, prefixes) { + return false + } + + base := ie[:11] + first := mod11Check(weightedTotal(base, descendingWeights(4, 11))) + second := mod11Check(weightedTotal(base+strconv.Itoa(first), descendingWeights(5, 12))) + + return ie == base+strconv.Itoa(first)+strconv.Itoa(second) + } +} + +var ieValidators = map[string]ieValidator{ + // The plain nine-digit, weights nine down to two, mod-11 shape. + "AM": trailingMod11(9, 9), + "CE": trailingMod11(9, 9, "06"), + "ES": trailingMod11(9, 9), + "MA": trailingMod11(9, 9, "12"), + "MS": trailingMod11(9, 9, "28"), + "PA": trailingMod11(9, 9, "15"), + "PB": trailingMod11(9, 9), + "PI": trailingMod11(9, 9), + "SC": trailingMod11(9, 9), + "SE": trailingMod11(9, 9), + + // Same idea, other lengths. + "MT": trailingMod11(11, 3), + "RS": trailingMod11(10, 2), + + "AC": twoTrailingMod11("01"), + "DF": twoTrailingMod11(), + + "AL": validateAlagoas, + "AP": validateAmapa, + "BA": validateBahia, + "GO": validateGoias, + "MG": validateMinasGerais, + "PE": validatePernambuco, + "PR": validateParana, + "RJ": validateRioDeJaneiro, + "RN": validateRioGrandeDoNorte, + "RO": validateRondonia, + "RR": validateRoraima, + "SP": validateSaoPaulo, + "TO": validateTocantins, +} + +// Alagoas multiplies the weighted sum by ten before the remainder, and a +// remainder of ten means zero. +func validateAlagoas(ie string) bool { + if len(ie) != 9 || !hasPrefix(ie, []string{"24"}) { + return false + } + + total := weightedTotal(ie[:8], descendingWeights(9, 8)) + return int(ie[8]-'0') == mod11TimesTen(total) +} + +// Amapá adds a constant to the sum, and forces the digit for two registration +// ranges, according to where the number falls in the state's own sequence. +func validateAmapa(ie string) bool { + if len(ie) != 9 || !hasPrefix(ie, []string{"03"}) { + return false + } + + value, err := strconv.Atoi(ie[:8]) + if err != nil { + return false + } + + shift, forced := 0, -1 + switch { + case value >= 3000001 && value <= 3017000: + shift, forced = 5, 0 + case value >= 3017001 && value <= 3019022: + shift, forced = 9, 1 + } + + total := shift + weightedTotal(ie[:8], descendingWeights(9, 8)) + digit := 11 - total%11 + + switch { + case digit == 10: + digit = 0 + case digit == 11: + digit = forced + if forced < 0 { + digit = 0 + } + } + + return int(ie[8]-'0') == digit +} + +// Bahia is the only state where the check digits are stored in the opposite +// order to the one they are computed in: the second digit sits before the +// first. Which module applies depends on the leading digit — the first for +// eight-digit registrations, the second for nine-digit ones. +func validateBahia(ie string) bool { + if len(ie) != 8 && len(ie) != 9 { + return false + } + + baseLength := len(ie) - 2 + base := ie[:baseLength] + + leading := ie[0] + if len(ie) == 9 { + leading = ie[1] + } + + module11 := true + for _, d := range []byte{'0', '1', '2', '3', '4', '5', '8'} { + if leading == d { + module11 = false + break + } + } + + check := mod10Check + if module11 { + check = mod11Check + } + + second := check(weightedTotal(base, descendingWeights(baseLength+1, baseLength))) + first := check(weightedTotal(base+strconv.Itoa(second), descendingWeights(baseLength+2, baseLength+1))) + + return ie == base+strconv.Itoa(first)+strconv.Itoa(second) +} + +// Goiás forces the digit for one historical registration and for a range that +// was issued before the current rule. +func validateGoias(ie string) bool { + if len(ie) != 9 { + return false + } + if !hasPrefix(ie, []string{"10", "11", "15"}) { + leading, err := strconv.Atoi(ie[:2]) + if err != nil || leading < 20 || leading > 29 { + return false + } + } + + base := ie[:8] + if base == "11094402" { + return ie[8] == '0' || ie[8] == '1' + } + + total := weightedTotal(base, descendingWeights(9, 8)) + remainder := total % 11 + + digit := 11 - remainder + switch { + case remainder == 0: + digit = 0 + case remainder == 1: + value, err := strconv.Atoi(base) + if err != nil { + return false + } + digit = 0 + if value >= 10103105 && value <= 10119997 { + digit = 1 + } + } + + return int(ie[8]-'0') == digit +} + +// Minas Gerais inserts a zero after the municipality code, then takes a digit +// sum rather than a weighted sum for the first check digit. +func validateMinasGerais(ie string) bool { + if len(ie) != 13 { + return false + } + + base := ie[:11] + expanded := base[:3] + "0" + base[3:] + + sum := 0 + for i := 0; i < len(expanded); i++ { + product := int(expanded[i]-'0') * (1 + i%2) + sum += product / 10 + sum += product % 10 + } + + first := (10 - sum%10) % 10 + second := mod11Check(weightedTotal(base+strconv.Itoa(first), []int{3, 2, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2})) + + return ie == base+strconv.Itoa(first)+strconv.Itoa(second) +} + +// Pernambuco: seven digits of sequence and two check digits. +func validatePernambuco(ie string) bool { + if len(ie) != 9 { + return false + } + + base := ie[:7] + first := mod11Check(weightedTotal(base, descendingWeights(8, 7))) + second := mod11Check(weightedTotal(base+strconv.Itoa(first), descendingWeights(9, 8))) + + return ie == base+strconv.Itoa(first)+strconv.Itoa(second) +} + +// Paraná: eight digits and two check digits, with weight series that restart +// rather than descend continuously. +func validateParana(ie string) bool { + if len(ie) != 10 { + return false + } + + base := ie[:8] + first := mod11Check(weightedTotal(base, append([]int{3, 2}, descendingWeights(7, 6)...))) + second := mod11Check(weightedTotal(base+strconv.Itoa(first), append([]int{4, 3, 2}, descendingWeights(7, 6)...))) + + return ie == base+strconv.Itoa(first)+strconv.Itoa(second) +} + +// Rio de Janeiro is the shortest: eight digits, and the weight series opens +// with 2 before descending from 7. +func validateRioDeJaneiro(ie string) bool { + if len(ie) != 8 { + return false + } + + base := ie[:7] + return int(ie[7]-'0') == mod11Check(weightedTotal(base, []int{2, 7, 6, 5, 4, 3, 2})) +} + +// Rio Grande do Norte accepts nine or ten digits and, like Alagoas, multiplies +// before taking the remainder. +func validateRioGrandeDoNorte(ie string) bool { + if len(ie) != 9 && len(ie) != 10 || !hasPrefix(ie, []string{"20"}) { + return false + } + + base := ie[:len(ie)-1] + + weights := descendingWeights(9, 8) + if len(ie) == 10 { + weights = append([]int{10}, weights...) + } + + return int(ie[len(ie)-1]-'0') == mod11TimesTen(weightedTotal(base, weights)) +} + +// Rondônia changed format in 2000: the older nine-digit registrations open with +// a three-digit municipality code that is excluded from the calculation, while +// the current fourteen-digit ones dropped it and pad the company number with +// leading zeros instead. The state's own document says the old check digit +// stays valid under the new formula, so both use the same rule. +// +// That rule is not the usual one. Where most states fold a difference of 10 or +// 11 onto zero, Rondônia's roteiro says to *subtract ten* — so a sum divisible +// by eleven yields 1, not 0. +func validateRondonia(ie string) bool { + switch len(ie) { + case 9: + return int(ie[8]-'0') == rondoniaDigit(weightedTotal(ie[3:8], descendingWeights(6, 5))) + case 14: + return int(ie[13]-'0') == rondoniaDigit(weightedTotal(ie[:13], descendingWeights(6, 13))) + default: + return false + } +} + +func rondoniaDigit(total int) int { + if digit := 11 - total%11; digit < 10 { + return digit + } else { + return digit - 10 + } +} + +// Roraima is the only state on module nine, with ascending weights. +func validateRoraima(ie string) bool { + if len(ie) != 9 || !hasPrefix(ie, []string{"24"}) { + return false + } + + total := weightedTotal(ie[:8], []int{1, 2, 3, 4, 5, 6, 7, 8}) + return int(ie[8]-'0') == total%9 +} + +// São Paulo has two formats: twelve digits for industry and commerce, and a +// rural producer number opening with P whose single check digit sits in the +// middle rather than at the end. +func validateSaoPaulo(ie string) bool { + weights := []int{1, 3, 4, 5, 6, 7, 8, 10} + + if len(ie) > 0 && (ie[0] == 'P' || ie[0] == 'p') { + if len(ie) != 13 || !digitsOnly(ie[1:]) { + return false + } + + base := ie[1:9] + digit := weightedTotal(base, weights) % 11 % 10 + return int(ie[9]-'0') == digit + } + + if len(ie) != 12 { + return false + } + + first := weightedTotal(ie[:8], weights) % 11 % 10 + if int(ie[8]-'0') != first { + return false + } + + second := weightedTotal(ie[:11], []int{3, 2, 10, 9, 8, 7, 6, 5, 4, 3, 2}) % 11 % 10 + return int(ie[11]-'0') == second +} + +// Tocantins carried a two-digit type code in the middle of its older +// eleven-digit registrations, which is excluded from the calculation. +func validateTocantins(ie string) bool { + if len(ie) != 9 && len(ie) != 11 { + return false + } + + base := ie[:len(ie)-1] + if len(ie) == 11 { + switch ie[2:4] { + case "01", "02", "03", "99": + default: + return false + } + base = ie[:2] + ie[4:10] + } + + return int(ie[len(ie)-1]-'0') == mod11Check(weightedTotal(base, descendingWeights(9, 8))) +} diff --git a/utils/inscricao_estadual.go b/utils/inscricao_estadual.go new file mode 100644 index 0000000..1408863 --- /dev/null +++ b/utils/inscricao_estadual.go @@ -0,0 +1,68 @@ +package utils + +import ( + "regexp" + "sort" + "strings" +) + +// A state registration is only meaningful alongside the state that issued it: +// the same digits can be valid in one and nonsense in another. The API +// therefore requires both, and says so rather than guessing. + +var ieChars = regexp.MustCompile(`^[\dPp.\-/\s]*$`) + +// SupportedIEStates lists the states this service can check, sorted, for the +// error message when an unknown one arrives. +func SupportedIEStates() []string { + states := make([]string, 0, len(ieValidators)) + for uf := range ieValidators { + states = append(states, uf) + } + sort.Strings(states) + return states +} + +// ValidateInscricaoEstadual checks a state registration against the roteiro de +// crítica published by the issuing state. +// +// The state is a required argument, not an inference. A registration carries no +// marker of where it came from, so a service that guessed would be answering a +// question it was not asked. +func ValidateInscricaoEstadual(ie, uf string) (bool, string, string) { + // "Isento" — exempt — is a legitimate value in every state for a business + // that is registered but has no number assigned. It has to be recognised + // before the character guard, which allows only digits and formatting. + if strings.EqualFold(strings.TrimSpace(ie), "ISENTO") { + return true, "ISENTO", "Valid state registration (exempt)" + } + + if !ieChars.MatchString(ie) { + return false, ie, "Invalid state registration (unexpected characters)" + } + + state := strings.ToUpper(strings.TrimSpace(uf)) + if state == "" { + return false, ie, "Missing state: a registration cannot be checked without the state that issued it" + } + + validate, supported := ieValidators[state] + if !supported { + return false, ie, "Unknown state " + state + " (expected one of " + strings.Join(SupportedIEStates(), ", ") + ")" + } + + // São Paulo's rural registrations open with a P; every other character that + // is not a digit is formatting and comes out. + sanitized := strings.ToUpper(strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || r == 'P' || r == 'p' { + return r + } + return -1 + }, ie)) + + if !validate(sanitized) { + return false, sanitized, "Invalid state registration for " + state + } + + return true, sanitized, "Valid state registration (" + state + ")" +} diff --git a/utils/inscricao_estadual_test.go b/utils/inscricao_estadual_test.go new file mode 100644 index 0000000..8e3dfae --- /dev/null +++ b/utils/inscricao_estadual_test.go @@ -0,0 +1,94 @@ +package utils + +import "testing" + +// Every number here is taken from the "roteiro de crítica" the state itself +// publishes through SINTEGRA — the worked example in the state's own document, +// completed with the check digit that document calculates. +// +// They are the reason this file exists: an algorithm transcribed from a +// specification and never run against the specification's own example is a +// guess with good grammar. +func TestInscricaoEstadualOfficialExamples(t *testing.T) { + official := map[string]string{ + "AC": "0100482300112", + "AL": "240000048", + "AP": "030123459", + "BA": "12345663", + "CE": "060000015", + "GO": "109876547", + "MA": "120000385", + "MT": "00130000019", + "PA": "159999995", + "PB": "060000015", + "PI": "012345679", + "PR": "1234567850", + "RO": "101625213", + "RR": "240061536", + "RS": "2243658792", + "SC": "251040852", + "SE": "271234563", + "SP": "110042490114", + "TO": "29010227836", + } + + for uf, ie := range official { + t.Run(uf, func(t *testing.T) { + isValid, _, message := ValidateInscricaoEstadual(ie, uf) + if !isValid { + t.Fatalf("%s rejected its own published example %s: %s", uf, ie, message) + } + }) + } +} + +// Bahia publishes examples for both of its lengths and both of its modules. +// The nine-digit module-10 case is the one the reference implementation this +// was checked against gets wrong, which is why it is called out separately. +func TestInscricaoEstadualBahiaBothLengths(t *testing.T) { + for _, ie := range []string{"12345663", "100000306"} { + if isValid, _, message := ValidateInscricaoEstadual(ie, "BA"); !isValid { + t.Fatalf("BA rejected its published example %s: %s", ie, message) + } + } +} + +func TestInscricaoEstadualRejections(t *testing.T) { + tests := []struct { + name string + ie string + uf string + }{ + {"wrong_check_digit", "0100482300113", "AC"}, + {"wrong_length", "010048230011", "AC"}, + {"wrong_prefix", "0200482300112", "AC"}, + {"letters", "010048230011X", "AC"}, + {"valid_elsewhere", "0100482300112", "SP"}, + {"unknown_state", "0100482300112", "XX"}, + {"missing_state", "0100482300112", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if isValid, _, message := ValidateInscricaoEstadual(tt.ie, tt.uf); isValid { + t.Fatalf("expected rejection, got valid (%s)", message) + } + }) + } +} + +func TestInscricaoEstadualExempt(t *testing.T) { + // A business can be registered and exempt from holding a number; a + // validator that rejected "ISENTO" would be rejecting a legal answer. + for _, value := range []string{"ISENTO", "isento", " Isento "} { + if isValid, sanitized, _ := ValidateInscricaoEstadual(value, "SP"); !isValid || sanitized != "ISENTO" { + t.Fatalf("expected %q to be accepted as exempt, got valid=%v value=%q", value, isValid, sanitized) + } + } +} + +func TestSupportedStatesCoverTheFederation(t *testing.T) { + if got := len(SupportedIEStates()); got != 27 { + t.Fatalf("expected all 27 states, got %d: %v", got, SupportedIEStates()) + } +}