├── random.go ├── random-crypto.go ├── badwords_test.go ├── example_test.go ├── badwords.go ├── couponcode.go ├── couponcode_test.go └── readme.md /random.go: -------------------------------------------------------------------------------- 1 | // +build !coupon-crypto 2 | 3 | package couponcode 4 | 5 | import ( 6 | "math/rand" 7 | ) 8 | 9 | func randString(n int) string { 10 | var bytes = make([]byte, n) 11 | for i := 0; i < n; i++ { 12 | bytes[i] = symbols[rand.Int()%int(length)] 13 | } 14 | return string(bytes) 15 | } 16 | -------------------------------------------------------------------------------- /random-crypto.go: -------------------------------------------------------------------------------- 1 | // +build coupon-crypto 2 | 3 | package couponcode 4 | 5 | import ( 6 | "crypto/rand" 7 | ) 8 | 9 | func randString(n int) string { 10 | var bytes = make([]byte, n) 11 | rand.Read(bytes) 12 | for i, b := range bytes { 13 | bytes[i] = symbols[b%byte(length)] 14 | } 15 | return string(bytes) 16 | } -------------------------------------------------------------------------------- /badwords_test.go: -------------------------------------------------------------------------------- 1 | package couponcode 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBadWordsNegative(t *testing.T) { 8 | if hasBadWord("LOVE") { 9 | t.Error("LOVE shouldn't be a bad word") 10 | } 11 | } 12 | 13 | func TestBadWordsPositive(t *testing.T) { 14 | if !hasBadWord("BOOBIES") { 15 | t.Error("BOOB should be a bad word") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package couponcode_test 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | 7 | "github.com/captaincodeman/couponcode" 8 | ) 9 | 10 | func ExampleGenerate() { 11 | rand.Seed(42) // to force consistent values for example 12 | code := couponcode.Generate() 13 | fmt.Println(code) 14 | // Output: RCMD-CRVF-FK36 15 | } 16 | 17 | func ExampleGenerateCustom() { 18 | rand.Seed(42) // to force consistent values for example 19 | cc := couponcode.New(4, 6) 20 | code := cc.Generate() 21 | fmt.Println(code) 22 | // Output: RCMCRH-VFK3TD-V4D182-U0VGHE 23 | } 24 | -------------------------------------------------------------------------------- /badwords.go: -------------------------------------------------------------------------------- 1 | package couponcode 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | var ( 8 | // ROT13 ... because it doesn't need to be enigma 9 | badwords = makeBadWords("SHPX PHAG JNAX JNAT CVFF PBPX FUVG GJNG GVGF SNEG URYY ZHSS QVPX XABO NEFR FUNT GBFF FYHG GHEQ FYNT PENC CBBC OHGG SRPX OBBO WVFZ WVMM CUNG") 10 | ) 11 | 12 | func hasBadWord(code string) bool { 13 | for _, badword := range badwords { 14 | if strings.Contains(code, badword) { 15 | return true 16 | } 17 | } 18 | return false 19 | } 20 | 21 | func makeBadWords(badwords string) []string { 22 | words := make([]rune, len(badwords)) 23 | for i, x := range badwords { 24 | c := mapRune(x) 25 | words[i] = c 26 | } 27 | return strings.Split(string(words), " ") 28 | } 29 | 30 | func mapRune(r rune) rune { 31 | switch { 32 | case 65 <= r && r <= 77: 33 | return r + 13 34 | case 78 <= r && r <= 90: 35 | return r - 13 36 | default: 37 | return r 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /couponcode.go: -------------------------------------------------------------------------------- 1 | package couponcode // import "github.com/captaincodeman/couponcode" 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | type ( 10 | generator struct { 11 | parts int 12 | partLen int 13 | } 14 | ) 15 | 16 | const ( 17 | symbols = "0123456789ABCDEFGHJKLMNPQRTUVWXY" 18 | length = len(symbols) - 1 19 | separator = "-" 20 | ) 21 | 22 | var ( 23 | Default = New(3, 4) 24 | removeInvalidRe = regexp.MustCompile(`[^0-9A-Z]+`) 25 | ) 26 | 27 | func Generate() string { 28 | return Default.Generate() 29 | } 30 | 31 | func Validate(code string) (string, error) { 32 | return Default.Validate(code) 33 | } 34 | 35 | func New(parts, partLen int) *generator { 36 | return &generator{ 37 | parts: parts, 38 | partLen: partLen, 39 | } 40 | } 41 | 42 | func (g *generator) Generate() string { 43 | parts := make([]string, g.parts) 44 | i := 0 45 | for i < g.parts { 46 | code := randString(g.partLen - 1) 47 | check := checkCharacter(code, i+1) 48 | parts[i] = code + check 49 | if !hasBadWord(strings.Join(parts, "")) { 50 | i += 1 51 | } 52 | } 53 | return strings.Join(parts, separator) 54 | } 55 | 56 | func (g *generator) Validate(code string) (string, error) { 57 | // make uppercase 58 | code = strings.ToUpper(code) 59 | 60 | // remove invalid characters 61 | code = removeInvalidRe.ReplaceAllLiteralString(code, "") 62 | 63 | // convert special letters to numbers 64 | code = strings.Replace(code, "O", "0", -1) 65 | code = strings.Replace(code, "I", "1", -1) 66 | code = strings.Replace(code, "Z", "2", -1) 67 | code = strings.Replace(code, "S", "5", -1) 68 | 69 | // split into parts 70 | parts := []string{} 71 | tmp := code 72 | for len(tmp) > 0 { 73 | max := g.partLen 74 | if max > len(tmp) { 75 | max = len(tmp) 76 | } 77 | parts = append(parts, tmp[:max]) 78 | tmp = tmp[max:len(tmp)] 79 | } 80 | 81 | // join with separator (shouldn't we test that) 82 | code = strings.Join(parts, separator) 83 | 84 | if len(parts) != g.parts { 85 | return code, fmt.Errorf("wrong number of parts (%d)", len(parts)) 86 | } 87 | for i, part := range parts { 88 | if len(part) != g.partLen { 89 | return code, fmt.Errorf("wrong length of part %d", i) 90 | } 91 | check := checkCharacter(part[:len(part)-1], i+1) 92 | if !strings.HasSuffix(part, check) { 93 | return code, fmt.Errorf("wrong part %d (%s) check character %s", i+1, part, check) 94 | } 95 | } 96 | 97 | return code, nil 98 | } 99 | 100 | func checkCharacter(code string, check int) string { 101 | for _, r := range code { 102 | k := strings.IndexRune(symbols, r) 103 | check = check*19 + k 104 | } 105 | return string(symbols[check%int(length)]) 106 | } -------------------------------------------------------------------------------- /couponcode_test.go: -------------------------------------------------------------------------------- 1 | package couponcode 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestDefault(t *testing.T) { 10 | if Default.parts != 3 && Default.partLen != 4 { 11 | t.Error("wrong defaults") 12 | } 13 | } 14 | 15 | func TestNew(t *testing.T) { 16 | g := New(5, 3) 17 | if g.parts != 5 && g.partLen != 3 { 18 | t.Error("wonky constructor") 19 | } 20 | } 21 | 22 | func TestCheckDigit(t *testing.T) { 23 | var runs = []struct { 24 | code string 25 | part int 26 | }{ 27 | {"55G2", 1}, 28 | {"DHM0", 2}, 29 | {"50NN", 3}, 30 | {"U5H9", 1}, 31 | {"HKDH", 2}, 32 | {"8RNX", 3}, 33 | {"1EX7", 4}, 34 | {"WYLKQM", 1}, 35 | {"U35V40", 2}, 36 | {"9N84DA", 3}, 37 | } 38 | for _, run := range runs { 39 | check := checkCharacter(run.code[:len(run.code)-1], run.part) 40 | if !strings.HasSuffix(run.code, check) { 41 | t.Errorf("check digit failed for %s got %s", run.code, check) 42 | } 43 | } 44 | } 45 | 46 | func TestValidCodes(t *testing.T) { 47 | var runs = []struct { 48 | g *generator 49 | code string 50 | }{ 51 | {Default, "55G2-DHM0-50NN"}, 52 | {New(4, 4), "U5H9-HKDH-8RNX-1EX7"}, 53 | {New(3, 6), "WYLKQM-U35V40-9N84DA"}, 54 | {Default, "55g2-dhm0-50nn"}, 55 | {Default, "SSGZ-DHMO-SONN"}, 56 | {New(7, 12), "QBXA5CV4Q85E-HNYV4U3UD69M-B7XU1BHF3FYE-HXT9LD4Q0DAH-U6WMKC1WNF4N-5PCG5C4JF0GL-5DTUNJ40LRB5"}, 57 | {New(1, 4), "1K7Q"}, 58 | {New(2, 4), "1K7Q-CTFM"}, 59 | {New(3, 4), "1K7Q-CTFM-LMTC"}, 60 | {New(4, 4), "7YQH-1FU7-E1HX-0BG9"}, 61 | {New(5, 4), "YENH-UPJK-PTE0-20U6-QYME"}, 62 | {New(6, 4), "YENH-UPJK-PTE0-20U6-QYME-RBK1"}, 63 | } 64 | for _, run := range runs { 65 | validated, err := run.g.Validate(run.code) 66 | if err != nil { 67 | t.Errorf("code %s should be valid got %s %s", run.code, validated, err) 68 | } 69 | } 70 | } 71 | 72 | func TestInvalidCodes(t *testing.T) { 73 | var runs = []struct { 74 | g *generator 75 | code string 76 | }{ 77 | {Default, "55G2-DHM0-50NK"}, // wrong check 78 | {Default, "55G2-DHM-50NN"}, // not enough characters 79 | {New(3, 4), "1K7Q-CTFM"}, // too short 80 | {New(1, 4), "1K7C"}, 81 | {New(2, 4), "1K7Q-CTFW"}, 82 | {New(3, 4), "1K7Q-CTFM-LMT1"}, 83 | {New(4, 4), "7YQH-1FU7-E1HX-0BGP"}, 84 | {New(5, 4), "YENH-UPJK-PTE0-20U6-QYMT"}, 85 | {New(6, 4), "YENH-UPJK-PTE0-20U6-QYME-RBK2"}, 86 | } 87 | for _, run := range runs { 88 | validated, err := run.g.Validate(run.code) 89 | if err == nil { 90 | t.Errorf("code %s should be invalid got %s", run.code, validated) 91 | } 92 | } 93 | } 94 | 95 | func TestCodesNormalized(t *testing.T) { 96 | var runs = []struct { 97 | g *generator 98 | code string 99 | exp string 100 | }{ 101 | {Default, "i9oD/V467/8Dsz", "190D-V467-8D52"}, // alternate separator 102 | {Default, " i9oD V467 8Dsz ", "190D-V467-8D52"}, // whitespace accepted 103 | {Default, " i9oD_V467_8Dsz ", "190D-V467-8D52"}, // underscores accepted 104 | {Default, "i9oDV4678Dsz", "190D-V467-8D52"}, // no separator required 105 | } 106 | for _, run := range runs { 107 | validated, err := run.g.Validate(run.code) 108 | if err != nil || validated != run.exp { 109 | t.Errorf("code %s should be %s got %s %s", run.code, run.exp, validated, err) 110 | } 111 | } 112 | } 113 | 114 | func TestPattern(t *testing.T) { 115 | code := Generate() 116 | matched, _ := regexp.MatchString(`^[0-9A-Z-]+$`, code) 117 | if !matched { 118 | t.Error("should only contain uppercase letters, digits, and dashes") 119 | } 120 | matched, _ = regexp.MatchString(`^\w{4}-\w{4}-\w{4}$`, code) 121 | if !matched { 122 | t.Error("should look like XXXX-XXXX-XXXX") 123 | } 124 | g := New(2, 5) 125 | code = g.Generate() 126 | matched, _ = regexp.MatchString(`^\w{5}-\w{5}$`, code) 127 | if !matched { 128 | t.Error("should generate an arbitrary number of parts") 129 | } 130 | } 131 | 132 | func TestDefaultSelfContained(t *testing.T) { 133 | for i := 0; i < 10; i++ { 134 | code := Generate() 135 | validated, err := Validate(code) 136 | if err != nil { 137 | t.Errorf("generated %s got %s %s", code, validated, err) 138 | } 139 | } 140 | } 141 | 142 | func TestCustomSelfContained(t *testing.T) { 143 | g := New(4, 6) 144 | for i := 0; i < 10; i++ { 145 | code := g.Generate() 146 | validated, err := g.Validate(code) 147 | if err != nil { 148 | t.Errorf("generated %s got %s %s", code, validated, err) 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # CouponCode for Go 2 | 3 | An implementation of Perl's [Algorithm::CouponCode][couponcode] for Golang. 4 | 5 | # Synopsis # 6 | 7 | This package helps generate and validate coupon codes which could be used for ecommerce coupons. It avoids creating 8 | coupons containing certain 'bad words' which may be offensive. 9 | 10 | The package provides a default configuration for codes of 3 parts of 4 characters separated by a '-'. The default 11 | configuration can be accessed using the `Default` package field or, for convenience, two top level functions: 12 | 13 | `Generate() string` to generate a coupon code 14 | `Validate(code string) (string, error)` to normalize and validate a code 15 | 16 | ``` 17 | package couponcode_test 18 | 19 | import ( 20 | "fmt" 21 | 22 | "github.com/captaincodeman/couponcode" 23 | ) 24 | 25 | func main() { 26 | code := couponcode.Generate() 27 | fmt.Println(code) 28 | // Output: RCMD-CRVF-FK36 29 | } 30 | ``` 31 | 32 | To use a custom part and part length, create a new generator using the constructor and then use the functions 33 | provided by that instance: 34 | 35 | ``` 36 | cc := couponcode.New(4, 6) 37 | 38 | code := cc.Generate() // note function of cc, not couponcode package 39 | 40 | validated, err := cc.Validate(code) 41 | ``` 42 | 43 | Now, when someone types their code in, you can check that it is valid. This means that letters like `O` 44 | are converted to `0` prior to checking. The validate command returns a normalized code (useful for any 45 | consistent database lookups of the coupon code) and an error to indicate if the code was valid. 46 | 47 | ``` 48 | // same code, just lowercased 49 | code, err := couponcode.Validate('55g2-dhm0-50nn'); 50 | // '55G2-DHM0-50NN' 51 | 52 | // various letters instead of numbers 53 | code, err := couponcode.Validate('SSGZ-DHMO-SONN'); 54 | // '55G2-DHM0-50NN' 55 | 56 | // wrong last character 57 | code, err := couponcode.Validate('55G2-DHM0-50NK'); 58 | // err != nil 59 | 60 | // not enough chars in the 2nd part 61 | code, err := couponcode.Validate('55G2-DHM-50NN'); 62 | // err != nil 63 | ``` 64 | 65 | The first thing we do to each code is uppercase it. Then we convert the following letters to numbers: 66 | 67 | * O -> 0 68 | * I -> 1 69 | * Z -> 2 70 | * S -> 5 71 | 72 | This means [oizs], [OIZS] and [0125] are considered the same code. 73 | 74 | # Example # 75 | 76 | Let's say you want a user to verify they got something, whether that is an email, letter, fax or carrier pigeon. To 77 | prove they received it, they have to type the code you sent them into a certain page on your website. You create a code 78 | which they have to type in: 79 | 80 | ``` 81 | code := couponcode.Generate(); 82 | // 55G2-DHM0-50NN 83 | ``` 84 | 85 | Time passes, letters get wet, carrier pigeons go on adventures and faxes are just as bad as they ever were. Now the 86 | user has to type their code into your website. The problem is, they can hardly read what the code was. Luckily we're 87 | somewhat forgiving since Z's and 2's are considered the same, O's and 0's, I's and 1's and S's and 5's are also mapped 88 | to each other. But even more than that, the 4th character of each group is a checkdigit which can determine if the 89 | other three in that group are correct. The user types this: 90 | 91 | ``` 92 | [s5g2-dhmo-50nn] 93 | ``` 94 | 95 | Because our codes are case insensitive and have good conversions for similar chars, the code is accepted as correct. 96 | 97 | Also, since we have a checkdigit, we can use a client-side plugin to highlight to the user any mistake in their code 98 | before they submit it. Please see the original project ([Algorithm::CouponCode][couponcode]) for more details of client 99 | side validation. 100 | 101 | # Installation 102 | 103 | The easiest way to get it is via `go get`: 104 | 105 | ``` bash 106 | $ go get -u github.com/captaincodeman/couponcode 107 | ``` 108 | 109 | # Build 110 | 111 | The default build uses the random number generator from the `math/rand` package which generates consistent random numbers 112 | for a given seed which can be changed. This is helpful for examples and when testing and, assuming you use a random seed 113 | it may be good enough for live use if you store codes in a database and mark them as consumed. 114 | 115 | There is a risk though that someone could work out the seed value and use it to generate their own valid codes so there is 116 | an implementation that uses the `crypto/rand` package for more secure random number generation. This can be used by adding 117 | the conditional compilation build tag `coupon-crypto` to your app build command, e.g. 118 | 119 | go build -tags coupon-crypto 120 | 121 | # Tests 122 | 123 | To run the tests, use go test: 124 | 125 | ``` 126 | $ go test 127 | ``` 128 | 129 | # Author 130 | 131 | * Written by [Simon Green](http://captaincodeman.com) 132 | 133 | # Inspired By 134 | 135 | [Grant McLean](grant)'s [Algorithm::CouponCode][couponcode] - with thanks. :) 136 | 137 | # License 138 | 139 | MIT. --------------------------------------------------------------------------------