Repository: go-playground/validator
Pull request #1009 (2022-10-04 11:08:39)

Introduce Luhn Checksum Validation
## Fixes Or Enhances
I noticed that the credit card check has been added recently in #924. I added a more general `luhn_checksum` validation that validates not only credit card numbers (strings with length `[12-19]`) but strings/numbers that have a Luhn checksum in general.

**Make sure that you've checked the boxes below before you submit PR:**
- [x] Tests exist or have been written that cover this particular change.

@go-playground/validator-maintainers

coveralls: 
[![Coverage Status](https://coveralls.io/builds/52990102/badge)](https://coveralls.io/builds/52990102)

Coverage increased (+0.04%) to 74.22% when pulling **40289842de835f65819cb9d9c18f6b7a4bb1ec4a on hf-kklein:luhn_checksum** into **c7e0172e0fd176bdc521afb5186818a7db6b77ac on go-playground:master**.


Patch:
diff --git a/README.md b/README.md
index 9c631d6..acdf773 100644
--- a/README.md
+++ b/README.md
@@ -180,6 +180,7 @@ Baked-in Validations
 | jwt | JSON Web Token (JWT) |
 | latitude | Latitude |
 | longitude | Longitude |
+| luhn_checksum | Luhn Algorithm Checksum (for strings and (u)int) |
 | postcode_iso3166_alpha2 | Postcode |
 | postcode_iso3166_alpha2_field | Postcode |
 | rgb | RGB String |
diff --git a/baked_in.go b/baked_in.go
index 028bc84..48d71b9 100644
--- a/baked_in.go
+++ b/baked_in.go
@@ -223,6 +223,7 @@ var (
 		"semver":                        isSemverFormat,
 		"dns_rfc1035_label":             isDnsRFC1035LabelFormat,
 		"credit_card":                   isCreditCard,
+		"luhn_checksum":                 hasLuhnChecksum,
 		"mongodb":                       isMongoDB,
 		"cron":                          isCron,
 	}
@@ -2681,6 +2682,29 @@ func isDnsRFC1035LabelFormat(fl FieldLevel) bool {
 	return dnsRegexRFC1035Label.MatchString(val)
 }
 
+// digitsHaveLuhnChecksum returns true if and only if the last element of the given digits slice is the Luhn checksum of the previous elements
+func digitsHaveLuhnChecksum(digits []string) bool {
+	size := len(digits)
+	sum := 0
+	for i, digit := range digits {
+		value, err := strconv.Atoi(digit)
+		if err != nil {
+			return false
+		}
+		if size%2 == 0 && i%2 == 0 || size%2 == 1 && i%2 == 1 {
+			v := value * 2
+			if v >= 10 {
+				sum += 1 + (v % 10)
+			} else {
+				sum += v
+			}
+		} else {
+			sum += value
+		}
+	}
+	return (sum % 10) == 0
+}
+
 // isMongoDB is the validation function for validating if the current field's value is valid mongoDB objectID
 func isMongoDB(fl FieldLevel) bool {
 	val := fl.Field().String()
@@ -2705,24 +2729,29 @@ func isCreditCard(fl FieldLevel) bool {
 		return false
 	}
 
-	sum := 0
-	for i, digit := range ccDigits {
-		value, err := strconv.Atoi(digit)
-		if err != nil {
-			return false
-		}
-		if size%2 == 0 && i%2 == 0 || size%2 == 1 && i%2 == 1 {
-			v := value * 2
-			if v >= 10 {
-				sum += 1 + (v % 10)
-			} else {
-				sum += v
-			}
-		} else {
-			sum += value
-		}
+	return digitsHaveLuhnChecksum(ccDigits)
+}
+
+// hasLuhnChecksum is the validation for validating if the current field's value has a valid Luhn checksum
+func hasLuhnChecksum(fl FieldLevel) bool {
+	field := fl.Field()
+	var str string // convert to a string which will then be split into single digits; easier and more readable than shifting/extracting single digits from a number
+	switch field.Kind() {
+	case reflect.String:
+		str = field.String()
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		str = strconv.FormatInt(field.Int(), 10)
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		str = strconv.FormatUint(field.Uint(), 10)
+	default:
+		panic(fmt.Sprintf("Bad field type %T", field.Interface()))
 	}
-	return (sum % 10) == 0
+	size := len(str)
+	if size < 2 { // there has to be at least one digit that carries a meaning + the checksum
+		return false
+	}
+	digits := strings.Split(str, "")
+	return digitsHaveLuhnChecksum(digits)
 }
 
 // isCron is the validation function for validating if the current field's value is a valid cron expression
diff --git a/doc.go b/doc.go
index bffabc4..66d5e98 100644
--- a/doc.go
+++ b/doc.go
@@ -1357,6 +1357,13 @@ This validates that a string value contains a valid credit card number using Luh
 	Usage: credit_card
 
 
+# Luhn Checksum
+
+  	Usage: luhn_checksum
+
+This validates that a string or (u)int value contains a valid checksum using the Luhn algorithm.
+
+
 #MongoDb ObjectID
 
 This validates that a string is a valid 24 character hexadecimal string.
@@ -1372,6 +1379,7 @@ This validates that a string value contains a valid cron expression.
 
 Alias Validators and Tags
 
+Alias Validators and Tags
 NOTE: When returning an error, the tag returned in "FieldError" will be
 the alias tag unless the dive tag is part of the alias. Everything after the
 dive tag is not reported as the alias tag. Also, the "ActualTag" in the before


Test patch:
diff --git a/validator_test.go b/validator_test.go
index a4beb9f..fddf79b 100644
--- a/validator_test.go
+++ b/validator_test.go
@@ -12692,6 +12692,51 @@ func TestCreditCardFormatValidation(t *testing.T) {
 	}
 }
 
+func TestLuhnChecksumValidation(t *testing.T) {
+	testsUint := []struct {
+		value    interface{} `validate:"luhn_checksum"` // the type is interface{} because the luhn_checksum works on both strings and numbers
+		tag      string
+		expected bool
+	}{
+		{uint64(586824160825533338), "luhn_checksum", true}, // credit card numbers are just special cases of numbers with luhn checksum
+		{586824160825533338, "luhn_checksum", true},
+		{"586824160825533338", "luhn_checksum", true},
+		{uint64(586824160825533328), "luhn_checksum", false},
+		{586824160825533328, "luhn_checksum", false},
+		{"586824160825533328", "luhn_checksum", false},
+		{10000000116, "luhn_checksum", true}, // but there may be shorter numbers (11 digits)
+		{"10000000116", "luhn_checksum", true},
+		{10000000117, "luhn_checksum", false},
+		{"10000000117", "luhn_checksum", false},
+		{uint64(12345678123456789011), "luhn_checksum", true}, // or longer numbers (19 digits)
+		{"12345678123456789011", "luhn_checksum", true},
+		{1, "luhn_checksum", false}, // single digits (checksum only) are not allowed
+		{"1", "luhn_checksum", false},
+		{-10, "luhn_checksum", false}, // negative ints are not allowed
+		{"abcdefghijklmnop", "luhn_checksum", false},
+	}
+
+	validate := New()
+
+	for i, test := range testsUint {
+		errs := validate.Var(test.value, test.tag)
+		if test.expected {
+			if !IsEqual(errs, nil) {
+				t.Fatalf("Index: %d luhn_checksum failed Error: %s", i, errs)
+			}
+		} else {
+			if IsEqual(errs, nil) {
+				t.Fatalf("Index: %d luhn_checksum failed Error: %s", i, errs)
+			} else {
+				val := getError(errs, "", "")
+				if val.Tag() != "luhn_checksum" {
+					t.Fatalf("Index: %d luhn_checksum failed Error: %s", i, errs)
+				}
+			}
+		}
+	}
+}
+
 func TestMultiOrOperatorGroup(t *testing.T) {
 	tests := []struct {
 		Value    int `validate:"eq=1|gte=5,eq=1|lt=7"`