├── pycard ├── __init__.py ├── holder.py └── card.py ├── .gitignore ├── setup.py ├── README.md └── LICENSE /pycard/__init__.py: -------------------------------------------------------------------------------- 1 | from pycard.card import Card, ExpDate 2 | from pycard.holder import Holder 3 | 4 | 5 | VERSION = (0, 9, 11) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.pyc 4 | 5 | # Logs and databases # 6 | ###################### 7 | ##*.log 8 | ##*.sql 9 | ##*.sqlite 10 | 11 | # OS generated files # 12 | ###################### 13 | .DS_Store? 14 | ehthumbs.db 15 | Icon? 16 | Thumbs.db 17 | -------------------------------------------------------------------------------- /pycard/holder.py: -------------------------------------------------------------------------------- 1 | class Holder(object): 2 | """ 3 | A credit card holder. 4 | """ 5 | def __init__(self, first, last, street, post_code): 6 | """ 7 | Attaches holder data for later use. 8 | """ 9 | self.first = first 10 | self.last = last 11 | self.street = street 12 | self.post_code = post_code 13 | 14 | def __repr__(self): 15 | """ 16 | Returns a typical repr with a simple representation of the holder. 17 | """ 18 | return u''.format(n=self.name) 19 | 20 | @property 21 | def name(self): 22 | """ 23 | Returns the full name of the holder. 24 | """ 25 | return u'{f} {l}'.format(f=self.first, l=self.last) 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | # Calculate the version based on pycard.VERSION 5 | version = '.'.join([str(v) for v in __import__('pycard').VERSION]) 6 | 7 | setup( 8 | name='captain-pycard', 9 | description='A simple library for payment card validation', 10 | version=version, 11 | author='Michael Angeletti', 12 | author_email='michael@angelettigroup.com', 13 | url='https://github.com/orokusaki/pycard/', 14 | classifiers=[ 15 | 'Environment :: Web Environment', 16 | 'Intended Audience :: Developers', 17 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 18 | 'Operating System :: OS Independent', 19 | 'Programming Language :: Python', 20 | 'Development Status :: 4 - Beta', 21 | 'Topic :: Utilities' 22 | ], 23 | packages=find_packages(), 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pycard (pronounced picard) 2 | ========================== 3 | 4 | pycard is a payment card validation library with a simple interface and no 5 | external dependencies. 6 | 7 | Installation 8 | ------------ 9 | 10 | ```bash 11 | pip install captain-pycard # pycard was taken in PyPi 12 | ``` 13 | 14 | That's it, and there are no dependencies! 15 | 16 | Usage 17 | ----- 18 | 19 | ```python 20 | import pycard 21 | 22 | # Create a card 23 | card = pycard.Card( 24 | number='4444333322221111', 25 | month=1, 26 | year=2020, 27 | cvc=123 28 | ) 29 | 30 | # Validate the card (checks that the card isn't expired and is mod10 valid) 31 | assert card.is_valid 32 | 33 | # Perform validation checks individually 34 | assert not card.is_expired 35 | assert card.is_mod10_valid 36 | 37 | # The card is a visa 38 | assert card.brand == 'visa' 39 | assert card.friendly_brand == 'Visa' 40 | assert card.mask == 'XXXX-XXXX-XXXX-1111' 41 | 42 | # The card is a known test card 43 | assert card.is_test 44 | ``` 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Michael Angeletti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pycard/card.py: -------------------------------------------------------------------------------- 1 | import re 2 | from calendar import monthrange 3 | import datetime 4 | 5 | 6 | class Card(object): 7 | """ 8 | A credit card that may be valid or invalid. 9 | """ 10 | # A regexp for matching non-digit values 11 | non_digit_regexp = re.compile(r'\D') 12 | 13 | # A mapping from common credit card brands to their number regexps 14 | BRAND_VISA = 'visa' 15 | BRAND_MASTERCARD = 'mastercard' 16 | BRAND_AMEX = 'amex' 17 | BRAND_DISCOVER = 'discover' 18 | BRAND_DANKORT = 'dankort' 19 | BRAND_MAESTRO = 'maestro' 20 | BRAND_DINERS = 'diners' 21 | BRAND_UNKNOWN = u'unknown' 22 | BRANDS = { 23 | BRAND_VISA: re.compile(r'^4\d{12}(\d{3})?$'), 24 | BRAND_MASTERCARD: re.compile(r''' 25 | ^(5[1-5]\d{4}|677189)\d{10}$| # Traditional 5-series + RU support 26 | ^(222[1-9]|2[3-6]\d{2}|27[0-1]\d|2720)\d{12}$ # 2016 2-series 27 | ''', re.VERBOSE), 28 | BRAND_AMEX: re.compile(r'^3[47]\d{13}$'), 29 | BRAND_DISCOVER: re.compile(r'^(6011|65\d{2})\d{12}$'), 30 | BRAND_DANKORT: re.compile(r'^(5019)\d{12}$'), 31 | BRAND_MAESTRO: 32 | re.compile(r'^(?:5[0678]\d\d|6304|6390|67\d\d)\d{8,15}$'), 33 | BRAND_DINERS: 34 | re.compile(r'^3(?:0[0-5]|[68][0-9])[0-9]{11}$'), 35 | } 36 | FRIENDLY_BRANDS = { 37 | BRAND_VISA: 'Visa', 38 | BRAND_MASTERCARD: 'MasterCard', 39 | BRAND_AMEX: 'American Express', 40 | BRAND_DISCOVER: 'Discover', 41 | BRAND_DANKORT: 'Dankort', 42 | BRAND_MAESTRO: 'Maestro', 43 | BRAND_DINERS: 'Diners Club', 44 | } 45 | 46 | # Common test credit cards 47 | TESTS = ( 48 | '4444333322221111', 49 | '378282246310005', 50 | '371449635398431', 51 | '378734493671000', 52 | '30569309025904', 53 | '38520000023237', 54 | '6011111111111117', 55 | '6011000990139424', 56 | '555555555554444', 57 | '5105105105105100', 58 | '4111111111111111', 59 | '4012888888881881', 60 | '4222222222222', 61 | ) 62 | 63 | # Stripe test credit cards 64 | TESTS += ( 65 | '4242424242424242', 66 | ) 67 | 68 | def __init__(self, number, month, year, cvc, holder=None): 69 | """ 70 | Attaches the provided card data and holder to the card after removing 71 | non-digits from the provided number. 72 | """ 73 | self.number = self.non_digit_regexp.sub('', number) 74 | self.exp_date = ExpDate(month, year) 75 | self.cvc = cvc 76 | self.holder = holder 77 | 78 | def __repr__(self): 79 | """ 80 | Returns a typical repr with a simple representation of the masked card 81 | number and the exp date. 82 | """ 83 | return u''.format( 84 | b=self.brand, 85 | n=self.mask, 86 | e=self.exp_date.mmyyyy 87 | ) 88 | 89 | @property 90 | def mask(self): 91 | """ 92 | Returns the credit card number with each of the number's digits but the 93 | first six and the last four digits replaced by an X, formatted the way 94 | they appear on their respective brands' cards. 95 | """ 96 | # If the card is invalid, return an "invalid" message 97 | if not self.is_mod10_valid: 98 | return u'invalid' 99 | 100 | # If the card is an Amex, it will have special formatting 101 | if self.brand == self.BRAND_AMEX: 102 | return u'XXXX-XXXXXX-X{e}'.format(e=self.number[11:15]) 103 | 104 | # All other cards 105 | return u'XXXX-XXXX-XXXX-{e}'.format(e=self.number[12:16]) 106 | 107 | @property 108 | def brand(self): 109 | """ 110 | Returns the brand of the card, if applicable, else an "unknown" brand. 111 | """ 112 | # Check if the card is of known type 113 | for brand, regexp in self.BRANDS.items(): 114 | if regexp.match(self.number): 115 | return brand 116 | 117 | # Default to unknown brand 118 | return self.BRAND_UNKNOWN 119 | 120 | @property 121 | def friendly_brand(self): 122 | """ 123 | Returns the human-friendly brand name of the card. 124 | """ 125 | return self.FRIENDLY_BRANDS.get(self.brand, 'unknown') 126 | 127 | @property 128 | def is_test(self): 129 | """ 130 | Returns whether or not the card's number is a known test number. 131 | """ 132 | return self.number in self.TESTS 133 | 134 | @property 135 | def is_expired(self): 136 | """ 137 | Returns whether or not the card is expired. 138 | """ 139 | return self.exp_date.is_expired 140 | 141 | @property 142 | def is_valid(self): 143 | """ 144 | Returns whether or not the card is a valid card for making payments. 145 | """ 146 | return not self.is_expired and self.is_mod10_valid 147 | 148 | @property 149 | def is_mod10_valid(self): 150 | """ 151 | Returns whether or not the card's number validates against the mod10 152 | algorithm (Luhn algorithm), automatically returning False on an empty 153 | value. 154 | """ 155 | # Check for empty string 156 | if not self.number: 157 | return False 158 | 159 | # Run mod10 on the number 160 | dub, tot = 0, 0 161 | for i in range(len(self.number) - 1, -1, -1): 162 | for c in str((dub + 1) * int(self.number[i])): 163 | tot += int(c) 164 | dub = (dub + 1) % 2 165 | 166 | return (tot % 10) == 0 167 | 168 | 169 | class ExpDate(object): 170 | """ 171 | An expiration date of a credit card. 172 | """ 173 | def __init__(self, month, year): 174 | """ 175 | Attaches the last possible datetime for the given month and year, as 176 | well as the raw month and year values. 177 | """ 178 | # Attach month and year 179 | self.month = month 180 | self.year = year 181 | 182 | # Get the month's day count 183 | weekday, day_count = monthrange(year, month) 184 | 185 | # Attach the last possible datetime for the provided month and year 186 | self.expired_after = datetime.datetime( 187 | year, 188 | month, 189 | day_count, 190 | 23, 191 | 59, 192 | 59, 193 | 999999 194 | ) 195 | 196 | def __repr__(self): 197 | """ 198 | Returns a typical repr with a simple representation of the exp date. 199 | """ 200 | return u''.format( 201 | d=self.expired_after.strftime('%m/%Y') 202 | ) 203 | 204 | @property 205 | def is_expired(self): 206 | """ 207 | Returns whether or not the expiration date has passed in American Samoa 208 | (the last timezone). 209 | """ 210 | # Get the current datetime in UTC 211 | utcnow = datetime.datetime.utcnow() 212 | 213 | # Get the datetime minus 11 hours (Samoa is UTC-11) 214 | samoa_now = utcnow - datetime.timedelta(hours=11) 215 | 216 | # Return whether the exipred after time has passed in American Samoa 217 | return samoa_now > self.expired_after 218 | 219 | @property 220 | def mmyyyy(self): 221 | """ 222 | Returns the expiration date in MM/YYYY format. 223 | """ 224 | return self.expired_after.strftime('%m/%Y') 225 | 226 | @property 227 | def mmyy(self): 228 | """ 229 | Returns the expiration date in MM/YY format (the same as is printed on 230 | cards. 231 | """ 232 | return self.expired_after.strftime('%m/%y') 233 | 234 | @property 235 | def MMYY(self): 236 | """ 237 | Returns the expiration date in MMYY format 238 | """ 239 | return self.expired_after.strftime('%m%y') 240 | 241 | @property 242 | def mm(self): 243 | """ 244 | Returns the expiration date in MM format. 245 | """ 246 | return self.expired_after.strftime('%m') 247 | 248 | @property 249 | def yyyy(self): 250 | """ 251 | Returns the expiration date in YYYY format. 252 | """ 253 | return self.expired_after.strftime('%Y') 254 | --------------------------------------------------------------------------------