plastic 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +9 -0
- data/HISTORY.rdoc +20 -0
- data/LICENSE.txt +7 -0
- data/README.rdoc +21 -0
- data/Rakefile +45 -0
- data/lib/plastic.rb +5 -0
- data/lib/plastic/core.rb +80 -0
- data/lib/plastic/duck_type.rb +27 -0
- data/lib/plastic/track.rb +86 -0
- data/lib/plastic/validations.rb +43 -0
- data/lib/plastic/version.rb +9 -0
- data/spec/plastic/core_spec.rb +221 -0
- data/spec/plastic/duck_type_spec.rb +62 -0
- data/spec/plastic/track_spec.rb +126 -0
- data/spec/plastic/validations_spec.rb +165 -0
- data/spec/spec.opts +4 -0
- data/spec/spec_helper.rb +6 -0
- metadata +94 -0
data/.gitignore
ADDED
data/HISTORY.rdoc
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
=== Version 0.2.1 2010-07-18
|
2
|
+
|
3
|
+
Initial public release.
|
4
|
+
|
5
|
+
|
6
|
+
=== Version 0.2.0
|
7
|
+
|
8
|
+
Updated with additional validations and brand support.
|
9
|
+
|
10
|
+
* PAN validations.
|
11
|
+
* More robust expiration year / month accessors.
|
12
|
+
* Support American Express PAN format with spaces.
|
13
|
+
* Brand detection.
|
14
|
+
|
15
|
+
|
16
|
+
=== Version 0.1.0
|
17
|
+
|
18
|
+
Internal preview release of Plastic.
|
19
|
+
|
20
|
+
* Basic gem + project framework.
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
Copyright © 2010 Square, Inc.
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
4
|
+
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
6
|
+
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
= Plastic
|
2
|
+
|
3
|
+
Credit card “plastic” library for Ruby. Parses track 1, track 2 and individual arguments. For ease of integration, Plastic duck-types an {ActiveMerchant}[http://www.activemerchant.org/] {CreditCard}[http://activemerchant.rubyforge.org/classes/ActiveMerchant/Billing/CreditCard.html] model.
|
4
|
+
|
5
|
+
== Prerequisites
|
6
|
+
|
7
|
+
* Money and a FICA score > 600.
|
8
|
+
|
9
|
+
== Testing
|
10
|
+
|
11
|
+
Testing requires the RSpec gem:
|
12
|
+
|
13
|
+
* {Rspec}[http://rspec.info/]
|
14
|
+
|
15
|
+
To test, run:
|
16
|
+
rake spec
|
17
|
+
|
18
|
+
== License
|
19
|
+
|
20
|
+
Copyright © 2010 Square, Inc.
|
21
|
+
See LICENSE.txt in this directory.
|
data/Rakefile
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
require 'spec/rake/spectask'
|
4
|
+
|
5
|
+
require File.join(File.dirname(__FILE__), 'lib', 'plastic', 'version')
|
6
|
+
|
7
|
+
begin
|
8
|
+
require 'jeweler'
|
9
|
+
Jeweler::Tasks.new do |gem|
|
10
|
+
gem.version = Plastic::VERSION::STRING
|
11
|
+
gem.name = "plastic"
|
12
|
+
gem.summary = "Credit card library for Ruby."
|
13
|
+
gem.description = "Handle credit, debit, bank and other cards."
|
14
|
+
gem.email = "github@squareup.com"
|
15
|
+
gem.homepage = "http://github.com/square/plastic"
|
16
|
+
gem.authors = [
|
17
|
+
"Randy Reddig",
|
18
|
+
"Cameron Walters",
|
19
|
+
"Chris Kampmeier",
|
20
|
+
"Erica Kwan",
|
21
|
+
"Matthew O'Connor",
|
22
|
+
"Damon McCormick",
|
23
|
+
"Brian Jenkins",
|
24
|
+
]
|
25
|
+
end
|
26
|
+
rescue LoadError
|
27
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
28
|
+
end
|
29
|
+
|
30
|
+
desc "Run all specs"
|
31
|
+
Spec::Rake::SpecTask.new do |t|
|
32
|
+
t.spec_opts = ["--options", "spec/spec.opts"]
|
33
|
+
t.spec_files = FileList["spec/**/*_spec.rb"]
|
34
|
+
t.rcov = ENV["RCOV"]
|
35
|
+
t.rcov_opts = %w{--exclude osx\/objc,gems\/,spec\/}
|
36
|
+
t.verbose = true
|
37
|
+
end
|
38
|
+
|
39
|
+
task :spec => :check_dependencies
|
40
|
+
task :default => :spec
|
41
|
+
|
42
|
+
desc "Remove trailing whitespace"
|
43
|
+
task :whitespace do
|
44
|
+
sh %{find . -name '*.rb' -exec sed -i '' 's/ *$//g' {} \\;}
|
45
|
+
end
|
data/lib/plastic.rb
ADDED
data/lib/plastic/core.rb
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
class Plastic
|
2
|
+
BRANDS = [:visa, :mastercard, :american_express, :discover]
|
3
|
+
|
4
|
+
attr_accessor :pan, :expiration
|
5
|
+
attr_accessor :track_name, :surname, :given_name, :title
|
6
|
+
attr_accessor :service_code, :cvv2
|
7
|
+
attr_accessor :track_1, :track_2
|
8
|
+
|
9
|
+
def initialize(attributes={})
|
10
|
+
if attributes.kind_of? Hash
|
11
|
+
self.update! attributes
|
12
|
+
parse_tracks!
|
13
|
+
else
|
14
|
+
parse_track! attributes
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def update!(attributes={})
|
19
|
+
attributes.each do |key, value|
|
20
|
+
setter_method_name = :"#{key}="
|
21
|
+
send(setter_method_name, value) if respond_to?(setter_method_name)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def name
|
26
|
+
[title, given_name, surname].flatten.compact.join(" ").strip
|
27
|
+
end
|
28
|
+
|
29
|
+
def expiration=(yymm)
|
30
|
+
@expiration = yymm.to_s[0..3]
|
31
|
+
end
|
32
|
+
|
33
|
+
def expiration_year
|
34
|
+
DateTime.strptime(expiration_yy, "%y").year
|
35
|
+
end
|
36
|
+
|
37
|
+
def expiration_month
|
38
|
+
expiration_mm.to_i
|
39
|
+
end
|
40
|
+
|
41
|
+
def brand
|
42
|
+
case pan
|
43
|
+
when /^4/ then :visa
|
44
|
+
when /^5[1-5]/ then :mastercard
|
45
|
+
when /^677189/ then :mastercard
|
46
|
+
when /^6011/ then :discover
|
47
|
+
when /^65/ then :discover
|
48
|
+
when /^3[47]/ then :american_express
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def valid?
|
53
|
+
value_is_present?(pan) && value_is_present?(expiration) && valid_pan? && valid_expiration?
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def expiration_yy
|
59
|
+
@expiration.to_s[0..1]
|
60
|
+
end
|
61
|
+
|
62
|
+
def expiration_mm
|
63
|
+
@expiration.to_s[2..3]
|
64
|
+
end
|
65
|
+
|
66
|
+
def value_is_present?(value)
|
67
|
+
!value_is_blank?(value)
|
68
|
+
end
|
69
|
+
|
70
|
+
def value_is_blank?(value)
|
71
|
+
if value.respond_to?(:blank?)
|
72
|
+
value.blank?
|
73
|
+
elsif value.respond_to?(:empty?)
|
74
|
+
value.empty?
|
75
|
+
else
|
76
|
+
value.nil?
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class Plastic
|
2
|
+
# ActiveMerchant::Billing::CreditCard
|
3
|
+
DUCK_TYPE_INTERFACE = [
|
4
|
+
[:number, :pan],
|
5
|
+
[:first_name, :given_name],
|
6
|
+
[:last_name, :surname],
|
7
|
+
[:verification_value, :cvv2],
|
8
|
+
[:security_code, :cvv2],
|
9
|
+
[:expiration_date, :expiration],
|
10
|
+
[:track1, :track_1],
|
11
|
+
[:track2, :track_2],
|
12
|
+
]
|
13
|
+
|
14
|
+
DUCK_TYPE_INTERFACE.each do |_alias, attribute_name|
|
15
|
+
alias_method _alias, attribute_name
|
16
|
+
alias_method :"#{_alias}=", :"#{attribute_name}="
|
17
|
+
define_method :"#{_alias}?", lambda { value_is_present?(send(_alias)) }
|
18
|
+
end
|
19
|
+
|
20
|
+
def year
|
21
|
+
expiration ? DateTime.new(expiration_year).strftime("%y") : nil
|
22
|
+
end
|
23
|
+
|
24
|
+
def month
|
25
|
+
expiration ? "%02d" % expiration_month : nil
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# http://en.wikipedia.org/wiki/Magnetic_stripe
|
2
|
+
|
3
|
+
class Plastic
|
4
|
+
def parse_tracks!
|
5
|
+
parse_track_2!
|
6
|
+
parse_track_1!
|
7
|
+
end
|
8
|
+
|
9
|
+
def parse_track!(value)
|
10
|
+
parse_track_2! value
|
11
|
+
parse_track_1! value
|
12
|
+
end
|
13
|
+
|
14
|
+
# Track 1, Format B
|
15
|
+
#
|
16
|
+
# Start sentinel — one character (generally '%')
|
17
|
+
# Format code="B" — one character (alpha only)
|
18
|
+
# Primary account number (PAN) — up to 19 characters. Usually, but not always, matches the credit card number printed on the front of the card.
|
19
|
+
# Field Separator — one character (generally '^')
|
20
|
+
# Name — two to 26 characters
|
21
|
+
# Field Separator — one character (generally '^')
|
22
|
+
# Expiration date — four characters in the form YYMM.
|
23
|
+
# Service code — three characters
|
24
|
+
# Discretionary data — may include Pin Verification Key Indicator (PVKI, 1 character), PIN Verification Value (PVV, 4 characters), Card Verification Value or Card Verification Code (CVV or CVK, 3 characters)
|
25
|
+
# End sentinel — one character (generally '?')
|
26
|
+
|
27
|
+
TRACK_1_PARSER = /
|
28
|
+
\A # Start of string
|
29
|
+
%? # Start sentinel
|
30
|
+
[bB] # Format code
|
31
|
+
(\d{12,19}|\d{4}\ \d{6}\ \d{5}) # PAN
|
32
|
+
\^ # Field separator
|
33
|
+
( # Name field
|
34
|
+
(?=[^^]{2,26}) # Lookahead assertion
|
35
|
+
([^\/]+) # Surname
|
36
|
+
(?:\/?([^.]+)(?:\.?([^^]+))?)? # Given name and title
|
37
|
+
) #
|
38
|
+
\^ # Field separator
|
39
|
+
(\d{4}) # Expiration
|
40
|
+
(\d{3}) # Service code
|
41
|
+
([^?]*) # Discretionary data
|
42
|
+
\?? # End sentinel
|
43
|
+
\z # End of string
|
44
|
+
/x.freeze
|
45
|
+
|
46
|
+
def self.track_1_parser
|
47
|
+
TRACK_1_PARSER
|
48
|
+
end
|
49
|
+
|
50
|
+
def parse_track_1!(value=nil)
|
51
|
+
value ||= track_1
|
52
|
+
if matched = self.class.track_1_parser.match(value.to_s)
|
53
|
+
self.pan = matched[1].delete(' ')
|
54
|
+
self.track_name = matched[2]
|
55
|
+
self.surname = matched[3]
|
56
|
+
self.given_name = matched[4]
|
57
|
+
self.title = matched[5]
|
58
|
+
self.expiration = matched[6]
|
59
|
+
self.service_code = matched[7]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Track 2
|
64
|
+
#
|
65
|
+
# Start sentinel — one character (generally ';')
|
66
|
+
# Primary account number (PAN) — up to 19 characters. Usually, but not always, matches the credit card number printed on the front of the card.
|
67
|
+
# Separator — one char (generally '=')
|
68
|
+
# Expiration date — four characters in the form YYMM.
|
69
|
+
# Service code — three characters
|
70
|
+
# Discretionary data — as in track one
|
71
|
+
# End sentinel — one character (generally '?')
|
72
|
+
|
73
|
+
TRACK_2_PARSER = /\A;?(\d{12,19})\=(\d{4})(.{3})([^\?]*)\??\z/.freeze
|
74
|
+
|
75
|
+
def self.track_2_parser
|
76
|
+
TRACK_2_PARSER
|
77
|
+
end
|
78
|
+
|
79
|
+
def parse_track_2!(value=nil)
|
80
|
+
value ||= track_2
|
81
|
+
if matched = self.class.track_2_parser.match(value.to_s)
|
82
|
+
self.pan = matched[1]
|
83
|
+
self.expiration = matched[2]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
class Plastic
|
4
|
+
def valid_pan?
|
5
|
+
valid_pan_length? && valid_pan_checksum?
|
6
|
+
end
|
7
|
+
|
8
|
+
def valid_expiration?
|
9
|
+
return false unless valid_expiration_year? && valid_expiration_month?
|
10
|
+
this = Time.now.utc
|
11
|
+
if this.year == expiration_year
|
12
|
+
(this.month..12).include?(expiration_month)
|
13
|
+
elsif expiration_year > this.year
|
14
|
+
true
|
15
|
+
else
|
16
|
+
false
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def valid_pan_length?
|
23
|
+
pan.to_s.length >= 12
|
24
|
+
end
|
25
|
+
|
26
|
+
def valid_pan_checksum?
|
27
|
+
odd = false
|
28
|
+
sum = pan.reverse.chars.inject(0) do |checksum, d|
|
29
|
+
d = d.to_i
|
30
|
+
checksum + ((odd = !odd) ? d : (d * 2 > 9 ? d * 2 - 9 : d * 2))
|
31
|
+
end
|
32
|
+
sum % 10 == 0
|
33
|
+
end
|
34
|
+
|
35
|
+
def valid_expiration_month?
|
36
|
+
(1..12).include?(expiration_month)
|
37
|
+
end
|
38
|
+
|
39
|
+
def valid_expiration_year?
|
40
|
+
this = Time.now.utc
|
41
|
+
(this.year..this.year + 20).include?(expiration_year)
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
|
3
|
+
class OtherHash < Hash
|
4
|
+
end
|
5
|
+
|
6
|
+
describe Plastic do
|
7
|
+
subject { described_class.new }
|
8
|
+
|
9
|
+
[
|
10
|
+
:pan, :expiration,
|
11
|
+
:track_name, :surname, :given_name, :title,
|
12
|
+
:service_code, :cvv2,
|
13
|
+
:track_1, :track_2,
|
14
|
+
].each do |accessor|
|
15
|
+
it "has accessor :#{accessor} and :#{accessor}=" do
|
16
|
+
subject.should respond_to(:"#{accessor}")
|
17
|
+
subject.should respond_to(:"#{accessor}=")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
describe "new" do
|
22
|
+
it "returns an instance of #{described_class.name}" do
|
23
|
+
subject.should be_instance_of(described_class)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
describe "#initialize" do
|
28
|
+
context "with no arguments" do
|
29
|
+
it "calls update! with an empty hash" do
|
30
|
+
subject.should_receive(:update!).with({})
|
31
|
+
subject.send :initialize
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
context "with a nil argument" do
|
36
|
+
it "calls parse_track!" do
|
37
|
+
subject.should_receive(:parse_track!).with(nil)
|
38
|
+
subject.send :initialize, nil
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context "with a hash argument" do
|
43
|
+
subject { described_class.new(:track_1 => "%B123456789012345^Dorsey/Jack.Dr^1010123?") }
|
44
|
+
|
45
|
+
it "calls update! with the hash argument" do
|
46
|
+
arg = {:foo => "bar"}
|
47
|
+
subject.should_receive(:update!).with(arg)
|
48
|
+
subject.send :initialize, arg
|
49
|
+
end
|
50
|
+
|
51
|
+
it "parses the track data if given" do
|
52
|
+
subject.track_1.should == "%B123456789012345^Dorsey/Jack.Dr^1010123?"
|
53
|
+
subject.pan.should == "123456789012345"
|
54
|
+
subject.expiration.should == "1010"
|
55
|
+
subject.name.should == "Dr Jack Dorsey"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
context "with a string argument" do
|
60
|
+
it "calls parse_track! with the string argument" do
|
61
|
+
arg = "foo=bar"
|
62
|
+
subject.should_receive(:parse_track!).with(arg)
|
63
|
+
subject.send :initialize, arg
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
describe "#update!" do
|
69
|
+
context "with no arguments" do
|
70
|
+
it "calls update! with an empty hash" do
|
71
|
+
subject.should_not_receive(:send)
|
72
|
+
subject.update!
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
context "with a nil argument" do
|
77
|
+
it "raises an exception" do
|
78
|
+
expect { subject.update! nil }.to raise_error(NoMethodError)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
context "with a hash argument" do
|
83
|
+
it "assigns the passed keys" do
|
84
|
+
arg = {:pan => "bar"}
|
85
|
+
subject.update! arg
|
86
|
+
subject.pan.should == "bar"
|
87
|
+
end
|
88
|
+
|
89
|
+
it "ignores parameters that do not correspond to a setter" do
|
90
|
+
subject.should_not respond_to(:foo=)
|
91
|
+
expect { subject.update! :foo => 97 }.to_not raise_error
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
context "with a subclass of hash" do
|
96
|
+
before do
|
97
|
+
@other_hash = OtherHash.new
|
98
|
+
@other_hash[:pan] = "bar"
|
99
|
+
@other_hash.should be_kind_of(Hash)
|
100
|
+
end
|
101
|
+
|
102
|
+
it "assigns the passed keys" do
|
103
|
+
subject.update! @other_hash
|
104
|
+
subject.pan.should == "bar"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
describe "#name" do
|
110
|
+
it "returns a string" do
|
111
|
+
subject.name.should be_instance_of(String)
|
112
|
+
end
|
113
|
+
|
114
|
+
[
|
115
|
+
["Prince", nil, nil, "Prince"],
|
116
|
+
["Walters", "Cameron", nil, "Cameron Walters"],
|
117
|
+
["Howser", "Doogie", "Dr", "Dr Doogie Howser"],
|
118
|
+
].each do |surname, given_name, title, name|
|
119
|
+
it "returns #{name} for the given input" do
|
120
|
+
subject.surname = surname
|
121
|
+
subject.given_name = given_name
|
122
|
+
subject.title = title
|
123
|
+
subject.name.should == name
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
describe "#expiration_year" do
|
129
|
+
it "returns a year 2000s when two digit expiration is in the range 00-68, inclusive" do
|
130
|
+
(0..68).each do |y|
|
131
|
+
yy = "%02d" % y
|
132
|
+
Plastic.new(:expiration => "#{yy}01").expiration_year.should == 2000 + y
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
it "returns a year in 1900s when when two digit expiration is in the range 69-99, inclusive" do
|
137
|
+
(69..99).each do |y|
|
138
|
+
yy = "%02d" % y
|
139
|
+
Plastic.new(:expiration => "#{yy}01").expiration_year.should == 1900 + y
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
describe "expiration_month" do
|
145
|
+
it "returns an integer month" do
|
146
|
+
subject.expiration = "9901"
|
147
|
+
subject.expiration_month.should == 1
|
148
|
+
subject.expiration = "9912"
|
149
|
+
subject.expiration_month.should == 12
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
describe "#brand" do
|
154
|
+
it "recognizes Visa cards" do
|
155
|
+
Plastic.new(:pan => "4111111111111111").brand.should == :visa
|
156
|
+
end
|
157
|
+
|
158
|
+
it "recognizes MasterCard cards" do
|
159
|
+
Plastic.new(:pan => "5100000000000000").brand.should == :mastercard
|
160
|
+
Plastic.new(:pan => "5200000000000000").brand.should == :mastercard
|
161
|
+
Plastic.new(:pan => "5300000000000000").brand.should == :mastercard
|
162
|
+
Plastic.new(:pan => "5400000000000000").brand.should == :mastercard
|
163
|
+
Plastic.new(:pan => "5500000000000000").brand.should == :mastercard
|
164
|
+
Plastic.new(:pan => "6771890000000000").brand.should == :mastercard # TODO: confirm that 6771- is really MasterCard
|
165
|
+
end
|
166
|
+
|
167
|
+
it "returns nil for bogus pseudo-MasterCard cards" do
|
168
|
+
Plastic.new(:pan => "5000000000000000").brand.should be_nil
|
169
|
+
Plastic.new(:pan => "5600000000000000").brand.should be_nil
|
170
|
+
Plastic.new(:pan => "5700000000000000").brand.should be_nil
|
171
|
+
Plastic.new(:pan => "5800000000000000").brand.should be_nil
|
172
|
+
Plastic.new(:pan => "5900000000000000").brand.should be_nil
|
173
|
+
end
|
174
|
+
|
175
|
+
it "recognizes Discover cards" do
|
176
|
+
Plastic.new(:pan => "6011000000000000").brand.should == :discover
|
177
|
+
Plastic.new(:pan => "6500000000000000").brand.should == :discover
|
178
|
+
end
|
179
|
+
|
180
|
+
it "recognizes American Express cards" do
|
181
|
+
Plastic.new(:pan => "340000000000000").brand.should == :american_express
|
182
|
+
Plastic.new(:pan => "370000000000000").brand.should == :american_express
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
describe "BRANDS constant" do
|
187
|
+
it "returns a list of the brands as symbols" do
|
188
|
+
Plastic::BRANDS.should == [:visa, :mastercard, :american_express, :discover]
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
describe "#valid?" do
|
193
|
+
before do
|
194
|
+
@plastic = Plastic.new(:pan => "5480020605154711", :expiration => "1501")
|
195
|
+
end
|
196
|
+
|
197
|
+
it "is valid if it has both a valid pan and an expiration" do
|
198
|
+
@plastic.should be_valid
|
199
|
+
end
|
200
|
+
|
201
|
+
it "is not valid if the pan is missing" do
|
202
|
+
@plastic.pan = nil
|
203
|
+
@plastic.should_not be_valid
|
204
|
+
end
|
205
|
+
|
206
|
+
it "is not valid if the expiration is missing" do
|
207
|
+
@plastic.expiration = nil
|
208
|
+
@plastic.should_not be_valid
|
209
|
+
end
|
210
|
+
|
211
|
+
it "is not valid if the card is expired" do
|
212
|
+
@plastic.expiration = "0901"
|
213
|
+
@plastic.should_not be_valid
|
214
|
+
end
|
215
|
+
|
216
|
+
it "is not valid if the pan does not pass its checksum" do
|
217
|
+
@plastic.pan = "4001111111111"
|
218
|
+
@plastic.should_not be_valid
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
|
3
|
+
describe Plastic do
|
4
|
+
subject { described_class.new }
|
5
|
+
|
6
|
+
[
|
7
|
+
[:number, :pan],
|
8
|
+
[:first_name, :given_name],
|
9
|
+
[:last_name, :surname],
|
10
|
+
[:verification_value, :cvv2],
|
11
|
+
[:security_code, :cvv2],
|
12
|
+
[:expiration_date, :expiration],
|
13
|
+
[:track1, :track_1],
|
14
|
+
[:track2, :track_2],
|
15
|
+
].each do |_alias, method|
|
16
|
+
it "##{method} is aliased as ##{_alias}" do
|
17
|
+
subject.method(_alias).should == subject.method(method)
|
18
|
+
end
|
19
|
+
|
20
|
+
it "##{method} is aliased as ##{_alias}" do
|
21
|
+
subject.method("#{_alias}=").should == subject.method("#{method}=")
|
22
|
+
end
|
23
|
+
|
24
|
+
it "##{method}? checks blankness of ##{_alias}" do
|
25
|
+
subject.send(:"#{_alias}?").should be_false
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe "when expiration is set" do
|
30
|
+
before do
|
31
|
+
subject.expiration = "1501"
|
32
|
+
end
|
33
|
+
|
34
|
+
describe "#year" do
|
35
|
+
it "returns the two digit year as a string" do
|
36
|
+
subject.expiration_year.should == 2015
|
37
|
+
subject.year.should == "15"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe "#month" do
|
42
|
+
it "returns the two digit month as a string" do
|
43
|
+
subject.expiration_month.should == 1
|
44
|
+
subject.month.should == "01"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
describe "when expiration is not set" do
|
50
|
+
describe "#year" do
|
51
|
+
it "returns nil" do
|
52
|
+
subject.year.should be_nil
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe "#month" do
|
57
|
+
it "returns nil" do
|
58
|
+
subject.month.should be_nil
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
|
3
|
+
describe Plastic do
|
4
|
+
subject { described_class.new }
|
5
|
+
|
6
|
+
describe "#parse_tracks!" do
|
7
|
+
it "calls #parse_track_2, then #parse_track_1" do
|
8
|
+
subject.should_receive(:parse_track_2!).with().once
|
9
|
+
subject.should_receive(:parse_track_1!).with().once.and_raise(StandardError)
|
10
|
+
expect { subject.parse_tracks! }.to raise_error(StandardError)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
describe "#parse_track!" do
|
15
|
+
it "calls #parse_track_2, then #parse_track_1" do
|
16
|
+
arg = "foo"
|
17
|
+
subject.should_receive(:parse_track_2!).with(arg).once
|
18
|
+
subject.should_receive(:parse_track_1!).with(arg).once.and_raise(StandardError)
|
19
|
+
expect { subject.parse_track! arg }.to raise_error(StandardError)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe "self.track_1_parser" do
|
24
|
+
it "returns a regular expression" do
|
25
|
+
described_class.track_1_parser.should be_instance_of(Regexp)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe "#parse_track_1!" do
|
30
|
+
def mock_track_1_parser
|
31
|
+
subject
|
32
|
+
parser = mock()
|
33
|
+
described_class.should_receive(:track_1_parser).once.and_return(parser)
|
34
|
+
parser
|
35
|
+
end
|
36
|
+
|
37
|
+
it "with no arguments parses the contents of #track_1" do
|
38
|
+
arg = "foo"
|
39
|
+
subject.should_receive(:track_1).once.and_return(arg)
|
40
|
+
mock_track_1_parser.should_receive(:match).with(arg).once
|
41
|
+
subject.parse_track_1!
|
42
|
+
end
|
43
|
+
|
44
|
+
it "with nil parses the contents of #track_1" do
|
45
|
+
arg = "foo"
|
46
|
+
subject.should_receive(:track_1).once.and_return(arg)
|
47
|
+
mock_track_1_parser.should_receive(:match).with(arg).once
|
48
|
+
subject.parse_track_1! nil
|
49
|
+
end
|
50
|
+
|
51
|
+
[0, 1, "foo", "bar", StandardError].each do |value|
|
52
|
+
it "with #{value} attempts to parse the string representation" do
|
53
|
+
mock_track_1_parser.should_receive(:match).with(value.to_s).once
|
54
|
+
subject.parse_track_1! value
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
describe "self.track_2_parser" do
|
60
|
+
it "returns a regular expression" do
|
61
|
+
described_class.track_2_parser.should be_instance_of(Regexp)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
describe "#parse_track_2!" do
|
66
|
+
def mock_track_2_parser
|
67
|
+
subject
|
68
|
+
parser = mock()
|
69
|
+
described_class.should_receive(:track_2_parser).once.and_return(parser)
|
70
|
+
parser
|
71
|
+
end
|
72
|
+
|
73
|
+
it "with no arguments parses the contents of #track_2" do
|
74
|
+
arg = "foo"
|
75
|
+
subject.should_receive(:track_2).once.and_return(arg)
|
76
|
+
mock_track_2_parser.should_receive(:match).with(arg).once
|
77
|
+
subject.parse_track_2!
|
78
|
+
end
|
79
|
+
|
80
|
+
it "with nil parses the contents of #track_2" do
|
81
|
+
arg = "foo"
|
82
|
+
subject.should_receive(:track_2).once.and_return(arg)
|
83
|
+
mock_track_2_parser.should_receive(:match).with(arg).once
|
84
|
+
subject.parse_track_2! nil
|
85
|
+
end
|
86
|
+
|
87
|
+
[0, 1, "foo", "bar", StandardError].each do |value|
|
88
|
+
it "with #{value} attempts to parse the string representation" do
|
89
|
+
mock_track_2_parser.should_receive(:match).with(value.to_s).once
|
90
|
+
subject.parse_track_2! value
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
[
|
96
|
+
# Track 1
|
97
|
+
["", nil, nil, ""],
|
98
|
+
["foobar", nil, nil, ""],
|
99
|
+
["B1^N^1230", nil, nil, ""],
|
100
|
+
["%B1^N^1230?", nil, nil, ""],
|
101
|
+
["B12345678901234567890^CW^1010123", nil, nil, ""],
|
102
|
+
["B123456789012^CW^0909123", "123456789012", "0909", "CW"],
|
103
|
+
["B123456789012345^Dorsey/Jack^1010123", "123456789012345", "1010", "Jack Dorsey"],
|
104
|
+
["%B123456789012345^Dorsey/Jack^1010123?", "123456789012345", "1010", "Jack Dorsey"],
|
105
|
+
["B123456789012345^Dorsey/Jack.Dr^1010123", "123456789012345", "1010", "Dr Jack Dorsey"],
|
106
|
+
["%B123456789012345^Dorsey/Jack.Dr^1010123?", "123456789012345", "1010", "Dr Jack Dorsey"],
|
107
|
+
["%B1234 567890 12345^Dorsey/Jack.Dr^1010123?", "123456789012345", "1010", "Dr Jack Dorsey"],
|
108
|
+
|
109
|
+
# Track 2
|
110
|
+
["", nil, nil, ""],
|
111
|
+
["foobar", nil, nil, ""],
|
112
|
+
["1=1230", nil, nil, ""],
|
113
|
+
["?1=1230?", nil, nil, ""],
|
114
|
+
["12345678901234567890=1010123", nil, nil, ""],
|
115
|
+
["123456789012=0909123", "123456789012", "0909", ""],
|
116
|
+
["123456789012345=1010123", "123456789012345", "1010", ""],
|
117
|
+
[";123456789012345=1010123?", "123456789012345", "1010", ""],
|
118
|
+
].each do |value, pan, expiration, name|
|
119
|
+
it "#parse_track!(\"#{value}\") correctly parses the track data" do
|
120
|
+
subject.parse_track! value
|
121
|
+
subject.pan.should == pan
|
122
|
+
subject.expiration.should == expiration
|
123
|
+
subject.name.should == name
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,165 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Plastic, "validations" do
|
4
|
+
describe "#valid_pan?" do
|
5
|
+
subject { Plastic.new(:pan => "4111111111111111") }
|
6
|
+
|
7
|
+
it "is true when #valid_pan_length? and #valid_pan_checksum? are true" do
|
8
|
+
subject.should be_valid_pan
|
9
|
+
end
|
10
|
+
|
11
|
+
it "is false when #valid_pan_length? is false" do
|
12
|
+
subject.should_receive(:valid_pan_length?).and_return(false)
|
13
|
+
subject.should_not be_valid_pan
|
14
|
+
end
|
15
|
+
|
16
|
+
it "is false when #valid_pan_checksum? is false" do
|
17
|
+
subject.should_receive(:valid_pan_checksum?).and_return(false)
|
18
|
+
subject.should_not be_valid_pan
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
describe "#valid_expiration?" do
|
23
|
+
subject { Plastic.new(:expiration => "1312") }
|
24
|
+
|
25
|
+
describe "when expiration_year is next year" do
|
26
|
+
it "is true for all values of expiration_month" do
|
27
|
+
next_year = (DateTime.now.year + 1).to_s[-2..-1]
|
28
|
+
(1..12).each do |month|
|
29
|
+
subject.expiration = "%02d%02d" % [next_year, month]
|
30
|
+
subject.should be_valid_expiration
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe "when expiration_year is this year" do
|
36
|
+
before do
|
37
|
+
@this_year = DateTime.now.year.to_s[-2..-1]
|
38
|
+
end
|
39
|
+
|
40
|
+
it "is true when expiration_month is this month and after" do
|
41
|
+
(DateTime.now.month..12).each do |month|
|
42
|
+
subject.expiration = "%02d%02d" % [@this_year, month]
|
43
|
+
subject.should be_valid_expiration
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
it "is false when expiration_month is earlier than this month" do
|
48
|
+
this_year = DateTime.now.year.to_s[-2..-1]
|
49
|
+
(1...DateTime.now.month).each do |month|
|
50
|
+
subject.expiration = "%02d%02d" % [@this_year, month]
|
51
|
+
subject.should_not be_valid_expiration
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
it "is false when #valid_expiration_year? is false" do
|
57
|
+
subject.should_receive(:valid_expiration_year?).and_return(false)
|
58
|
+
subject.should_not be_valid_expiration
|
59
|
+
end
|
60
|
+
|
61
|
+
it "is false when #valid_expiration_month? is false" do
|
62
|
+
subject.should_receive(:valid_expiration_month?).and_return(false)
|
63
|
+
subject.should_not be_valid_expiration
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe "valid_pan_length?" do
|
68
|
+
it "is true when card number is 12 or more digits" do
|
69
|
+
extra_numbers = %w[11 234 5678 99999999].each do |n|
|
70
|
+
Plastic.new(:pan => "0123456789#{n}").should be_valid_pan_length
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
it "is false when card number is less than 12 digits" do
|
75
|
+
Plastic.new(:pan => "0123456789").should_not be_valid_pan_length
|
76
|
+
Plastic.new(:pan => "01234567890").should_not be_valid_pan_length
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe "valid_pan_checksum?" do
|
81
|
+
%w[
|
82
|
+
5454545454545454
|
83
|
+
5480020605154711
|
84
|
+
3566002020190001
|
85
|
+
4111111111111111
|
86
|
+
4005765777003
|
87
|
+
371449635398456
|
88
|
+
6011000995504101
|
89
|
+
36438999910011
|
90
|
+
4055011111111111
|
91
|
+
5581111111111119
|
92
|
+
].each do |pan|
|
93
|
+
it "is true with valid test PAN #{pan}" do
|
94
|
+
Plastic.new(:pan => pan).should be_valid_pan_checksum
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
%w[
|
99
|
+
5451666666666666
|
100
|
+
5480000000000000
|
101
|
+
3566333333333333
|
102
|
+
4222222222222222
|
103
|
+
4001111111111
|
104
|
+
371433333333333
|
105
|
+
6011444444444444
|
106
|
+
36435555555555
|
107
|
+
4055999999999999
|
108
|
+
5581777777777777
|
109
|
+
].each do |pan|
|
110
|
+
it "is false with invalid PAN #{pan}" do
|
111
|
+
Plastic.new(:pan => pan).should_not be_valid_pan_checksum
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
describe "#valid_expiration_month?" do
|
117
|
+
it "is true when the month is any of 1-12" do
|
118
|
+
(1..12).each do |month|
|
119
|
+
expiration = "YY%02d" % month
|
120
|
+
expiration.should match(/^YY[01]\d$/)
|
121
|
+
Plastic.new(:expiration => expiration).should be_valid_expiration_month
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
it "is false when the month is outside the range 1-12" do
|
126
|
+
[0, 17, 31].each do |month|
|
127
|
+
expiration = "YY%02d" % month
|
128
|
+
expiration.should match(/^YY\d\d$/)
|
129
|
+
Plastic.new(:expiration => expiration).should_not be_valid_expiration_month
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
describe "#valid_expiration_year?" do
|
135
|
+
def format_expiration_year_as_track(year)
|
136
|
+
two_digit_year = year.to_s[-2..-1].to_i % 99
|
137
|
+
expiration = "%02dMM" % two_digit_year
|
138
|
+
expiration.should match(/^\d\dMM$/)
|
139
|
+
expiration
|
140
|
+
end
|
141
|
+
|
142
|
+
before do
|
143
|
+
@this = DateTime.now
|
144
|
+
end
|
145
|
+
|
146
|
+
it "is true when set to the current year and up to 20 years later" do
|
147
|
+
(@this.year..@this.year + 20).each do |year|
|
148
|
+
expiration = format_expiration_year_as_track(year)
|
149
|
+
Plastic.new(:expiration => expiration).should be_valid_expiration_year
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
it "is false when set to last year" do
|
154
|
+
invalid_year = DateTime.now.year - 1
|
155
|
+
expiration = format_expiration_year_as_track(invalid_year)
|
156
|
+
Plastic.new(:expiration => expiration).should_not be_valid_expiration_year
|
157
|
+
end
|
158
|
+
|
159
|
+
it "is false when set 21 years in the future" do
|
160
|
+
invalid_year = DateTime.now.year + 21
|
161
|
+
expiration = format_expiration_year_as_track(invalid_year)
|
162
|
+
Plastic.new(:expiration => expiration).should_not be_valid_expiration_year
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
data/spec/spec.opts
ADDED
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: plastic
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 21
|
5
|
+
prerelease: false
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 2
|
9
|
+
- 1
|
10
|
+
version: 0.2.1
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Randy Reddig
|
14
|
+
- Cameron Walters
|
15
|
+
- Chris Kampmeier
|
16
|
+
- Erica Kwan
|
17
|
+
- Matthew O'Connor
|
18
|
+
- Damon McCormick
|
19
|
+
- Brian Jenkins
|
20
|
+
autorequire:
|
21
|
+
bindir: bin
|
22
|
+
cert_chain: []
|
23
|
+
|
24
|
+
date: 2010-07-18 00:00:00 -07:00
|
25
|
+
default_executable:
|
26
|
+
dependencies: []
|
27
|
+
|
28
|
+
description: Handle credit, debit, bank and other cards.
|
29
|
+
email: github@squareup.com
|
30
|
+
executables: []
|
31
|
+
|
32
|
+
extensions: []
|
33
|
+
|
34
|
+
extra_rdoc_files:
|
35
|
+
- LICENSE.txt
|
36
|
+
- README.rdoc
|
37
|
+
files:
|
38
|
+
- .gitignore
|
39
|
+
- HISTORY.rdoc
|
40
|
+
- LICENSE.txt
|
41
|
+
- README.rdoc
|
42
|
+
- Rakefile
|
43
|
+
- lib/plastic.rb
|
44
|
+
- lib/plastic/core.rb
|
45
|
+
- lib/plastic/duck_type.rb
|
46
|
+
- lib/plastic/track.rb
|
47
|
+
- lib/plastic/validations.rb
|
48
|
+
- lib/plastic/version.rb
|
49
|
+
- spec/plastic/core_spec.rb
|
50
|
+
- spec/plastic/duck_type_spec.rb
|
51
|
+
- spec/plastic/track_spec.rb
|
52
|
+
- spec/plastic/validations_spec.rb
|
53
|
+
- spec/spec.opts
|
54
|
+
- spec/spec_helper.rb
|
55
|
+
has_rdoc: true
|
56
|
+
homepage: http://github.com/square/plastic
|
57
|
+
licenses: []
|
58
|
+
|
59
|
+
post_install_message:
|
60
|
+
rdoc_options:
|
61
|
+
- --charset=UTF-8
|
62
|
+
require_paths:
|
63
|
+
- lib
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ">="
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
hash: 3
|
70
|
+
segments:
|
71
|
+
- 0
|
72
|
+
version: "0"
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
74
|
+
none: false
|
75
|
+
requirements:
|
76
|
+
- - ">="
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
hash: 3
|
79
|
+
segments:
|
80
|
+
- 0
|
81
|
+
version: "0"
|
82
|
+
requirements: []
|
83
|
+
|
84
|
+
rubyforge_project:
|
85
|
+
rubygems_version: 1.3.7
|
86
|
+
signing_key:
|
87
|
+
specification_version: 3
|
88
|
+
summary: Credit card library for Ruby.
|
89
|
+
test_files:
|
90
|
+
- spec/plastic/core_spec.rb
|
91
|
+
- spec/plastic/duck_type_spec.rb
|
92
|
+
- spec/plastic/track_spec.rb
|
93
|
+
- spec/plastic/validations_spec.rb
|
94
|
+
- spec/spec_helper.rb
|