freemium-ajb 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. data/.coveralls.yml +1 -0
  2. data/.gitignore +27 -0
  3. data/.rspec +1 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +6 -0
  7. data/Gemfile +4 -0
  8. data/LICENSE.md +20 -0
  9. data/README.md +1 -0
  10. data/app/mailers/freemium_mailer.rb +36 -0
  11. data/app/views/subscription_mailer/admin_report.text.erb +4 -0
  12. data/app/views/subscription_mailer/expiration_notice.text.erb +1 -0
  13. data/app/views/subscription_mailer/expiration_warning.text.erb +1 -0
  14. data/app/views/subscription_mailer/invoice.text.erb +5 -0
  15. data/freemium.gemspec +29 -0
  16. data/lib/freemium.rb +25 -0
  17. data/lib/freemium/configuration.rb +27 -0
  18. data/lib/freemium/coupon.rb +37 -0
  19. data/lib/freemium/coupon_redemption.rb +59 -0
  20. data/lib/freemium/credit_card.rb +222 -0
  21. data/lib/freemium/engine.rb +7 -0
  22. data/lib/freemium/gateways/base.rb +65 -0
  23. data/lib/freemium/gateways/brain_tree.rb +175 -0
  24. data/lib/freemium/gateways/test.rb +36 -0
  25. data/lib/freemium/rates.rb +33 -0
  26. data/lib/freemium/response.rb +24 -0
  27. data/lib/freemium/subscription.rb +384 -0
  28. data/lib/freemium/subscription_change.rb +20 -0
  29. data/lib/freemium/subscription_plan.rb +26 -0
  30. data/lib/freemium/testing/app/controllers/application_controller.rb +7 -0
  31. data/lib/freemium/testing/application.rb +46 -0
  32. data/lib/freemium/testing/config/database.yml +11 -0
  33. data/lib/freemium/testing/config/routes.rb +3 -0
  34. data/lib/freemium/transaction.rb +15 -0
  35. data/lib/freemium/version.rb +3 -0
  36. data/lib/generators/freemium/install/install_generator.rb +58 -0
  37. data/lib/generators/freemium/install/templates/db/migrate/create_coupon_redemptions.rb +18 -0
  38. data/lib/generators/freemium/install/templates/db/migrate/create_coupons.rb +28 -0
  39. data/lib/generators/freemium/install/templates/db/migrate/create_credit_cards.rb +14 -0
  40. data/lib/generators/freemium/install/templates/db/migrate/create_subscription_changes.rb +21 -0
  41. data/lib/generators/freemium/install/templates/db/migrate/create_subscription_plans.rb +14 -0
  42. data/lib/generators/freemium/install/templates/db/migrate/create_subscriptions.rb +31 -0
  43. data/lib/generators/freemium/install/templates/db/migrate/create_transactions.rb +17 -0
  44. data/lib/generators/freemium/install/templates/freemium.rb +16 -0
  45. data/lib/generators/freemium/install/templates/models/coupon.rb +3 -0
  46. data/lib/generators/freemium/install/templates/models/coupon_redemption.rb +3 -0
  47. data/lib/generators/freemium/install/templates/models/credit_card.rb +3 -0
  48. data/lib/generators/freemium/install/templates/models/subscription.rb +3 -0
  49. data/lib/generators/freemium/install/templates/models/subscription_change.rb +3 -0
  50. data/lib/generators/freemium/install/templates/models/subscription_plan.rb +3 -0
  51. data/lib/generators/freemium/install/templates/models/transaction.rb +3 -0
  52. data/lib/generators/views/USAGE +3 -0
  53. data/lib/generators/views/views_generator.rb +39 -0
  54. data/lib/tasks/freemium.rake +17 -0
  55. data/spec/dummy/Rakefile +7 -0
  56. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  57. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  58. data/spec/dummy/app/mailers/mailers.rb +1 -0
  59. data/spec/dummy/app/models/models.rb +31 -0
  60. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  61. data/spec/dummy/config.ru +4 -0
  62. data/spec/dummy/config/application.rb +45 -0
  63. data/spec/dummy/config/boot.rb +10 -0
  64. data/spec/dummy/config/database.yml +21 -0
  65. data/spec/dummy/config/environment.rb +6 -0
  66. data/spec/dummy/config/environments/development.rb +26 -0
  67. data/spec/dummy/config/environments/production.rb +49 -0
  68. data/spec/dummy/config/environments/test.rb +36 -0
  69. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  70. data/spec/dummy/config/initializers/freemium.rb +16 -0
  71. data/spec/dummy/config/initializers/inflections.rb +10 -0
  72. data/spec/dummy/config/initializers/mem_db.rb +12 -0
  73. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  74. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  75. data/spec/dummy/config/initializers/session_store.rb +8 -0
  76. data/spec/dummy/config/locales/en.yml +5 -0
  77. data/spec/dummy/config/routes.rb +58 -0
  78. data/spec/dummy/db/schema.rb +90 -0
  79. data/spec/dummy/script/rails +6 -0
  80. data/spec/fixtures/credit_cards.yml +11 -0
  81. data/spec/fixtures/subscription_plans.yml +15 -0
  82. data/spec/fixtures/subscriptions.yml +28 -0
  83. data/spec/fixtures/users.yml +16 -0
  84. data/spec/lib/tasks/run_billing_rake_spec.rb +14 -0
  85. data/spec/models/coupon_redemption_spec.rb +287 -0
  86. data/spec/models/credit_card_spec.rb +124 -0
  87. data/spec/models/manual_billing_spec.rb +165 -0
  88. data/spec/models/subscription_plan_spec.rb +46 -0
  89. data/spec/models/subscription_spec.rb +386 -0
  90. data/spec/spec_helper.rb +19 -0
  91. data/spec/support/helpers.rb +18 -0
  92. data/spec/support/shared_contexts/rake.rb +19 -0
  93. metadata +270 -0
@@ -0,0 +1 @@
1
+ service_name: travis-ci
@@ -0,0 +1,27 @@
1
+ # yard generated
2
+ doc
3
+ .yardoc
4
+
5
+
6
+ *.gem
7
+ *.rbc
8
+ .bundle
9
+ .config
10
+ .yardoc
11
+ Gemfile.lock
12
+ InstalledFiles
13
+ _yardoc
14
+ coverage
15
+ doc/
16
+ lib/bundler/man
17
+ pkg
18
+ rdoc
19
+ spec/reports
20
+ test/tmp
21
+ test/version_tmp
22
+ tmp
23
+
24
+ spec/dummy/db/*.sqlite3
25
+ spec/dummy/log/*.log
26
+ spec/dummy/tmp/
27
+ spec/dummy/public/
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1 @@
1
+ freemium
@@ -0,0 +1 @@
1
+ 1.9.3-p194
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - '1.9.2'
4
+ - '1.9.3'
5
+ - '2.0.0'
6
+ script: bundle exec rspec
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in freemium.gemspec
4
+ gemspec
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2007 Lance Ivy
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ ## Freemium [![](https://codeclimate.com/github/adamjacobbecker/freemium.png)](https://codeclimate.com/github/adamjacobbecker/freemium) [![](https://travis-ci.org/adamjacobbecker/freemium.png)](https://travis-ci.org/adamjacobbecker/freemium) [![](https://coveralls.io/repos/adamjacobbecker/freemium/badge.png)](https://coveralls.io/r/adamjacobbecker/freemium)
@@ -0,0 +1,36 @@
1
+ class FreemiumMailer < ActionMailer::Base
2
+ prepend_view_path(File.dirname(__FILE__))
3
+
4
+ default :from => 'billing@example.com',
5
+ :return_path => 'no-reply@example.com'
6
+
7
+ def invoice(transaction)
8
+ @amount = transaction.amount
9
+ @subscription = transaction.subscription
10
+ mail(:to => transaction.subscription.subscribable.email,
11
+ :bcc => Freemium.configuration.admin_report_recipients,
12
+ :subject => "Your invoice")
13
+ end
14
+
15
+ def expiration_warning(subscription)
16
+ @subscription = subscription
17
+ mail(:to => subscription.subscribable.email,
18
+ :bcc => Freemium.configuration.admin_report_recipients,
19
+ :subject => "Your subscription is set to expire")
20
+ end
21
+
22
+ def expiration_notice(subscription)
23
+ @subscription = subscription
24
+ mail(:to => subscription.subscribable.email,
25
+ :bcc => Freemium.configuration.admin_report_recipients,
26
+ :subject => "Your subscription has expired")
27
+ end
28
+
29
+ def admin_report(transactions)
30
+ @amount_charged = transactions.select{|t| t && t.success?}.collect{|t| t.amount}.sum
31
+ @transactions = transactions
32
+ @amount_charged = @amount_charged
33
+ mail(:to => Freemium.configuration.admin_report_recipients,
34
+ :subject => "Billing report (#{@amount_charged} charged)")
35
+ end
36
+ end
@@ -0,0 +1,4 @@
1
+ <% @transactions.each do |subscription, events| %>
2
+ subscription #<%= subscription.id %> (billing key <%= subscription.billing_key %>)
3
+ <%= events.collect{|e| "- #{e.to_s}"}.join("\n") %>
4
+ <% end %>
@@ -0,0 +1 @@
1
+ Your subscription has expired.
@@ -0,0 +1 @@
1
+ We were unable to process your payment, and your subscription is set to expire in <%= @subscription.remaining_days_of_grace %> days. Please contact us to correct this.
@@ -0,0 +1,5 @@
1
+ Thanks for paying!
2
+
3
+ Your Plan: <%= @subscription.subscription_plan.name %> (<%= @subscription.subscription_plan.rate %> / month)
4
+ Paid: <%= @amount %>
5
+ Paid Through: <%= @subscription.paid_through.to_s %>
@@ -0,0 +1,29 @@
1
+ $LOAD_PATH.push File.expand_path('../lib', __FILE__)
2
+ require 'freemium/version'
3
+ require 'date'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'freemium-ajb'
7
+ s.version = Freemium::VERSION
8
+ s.authors = ['Lance Ivy', 'Anton Oryol', 'Christian Trosclair', 'Adam Becker']
9
+ s.email = %q{adam@dobt.co}
10
+ s.homepage = %q{http://github.com/adamjacobbecker/freemium}
11
+ s.summary = ""
12
+
13
+ s.files = `git ls-files`.split("\n")
14
+ s.test_files = `git ls-files -- {spec}/*`.split("\n")
15
+ s.extra_rdoc_files = %w(LICENSE.md README.md)
16
+ s.rdoc_options = ['--charset=UTF-8']
17
+ s.require_paths = ['lib']
18
+
19
+ s.required_ruby_version = Gem::Requirement.new('>= 1.9.2')
20
+
21
+ s.add_dependency 'rails', '>= 3.0'
22
+ s.add_dependency 'money'
23
+ s.add_development_dependency 'rspec-rails'
24
+ s.add_development_dependency 'bundler'
25
+ s.add_development_dependency 'rspec-rails'
26
+ s.add_development_dependency 'sqlite3'
27
+ s.add_development_dependency 'bundler'
28
+ s.add_development_dependency 'coveralls'
29
+ end
@@ -0,0 +1,25 @@
1
+ require 'money'
2
+ require_relative '../app/mailers/freemium_mailer'
3
+ require "freemium/version"
4
+ require 'freemium/configuration'
5
+ require 'freemium/coupon'
6
+ require 'freemium/coupon_redemption'
7
+ require 'freemium/credit_card'
8
+ require 'freemium/engine'
9
+ require 'freemium/rates'
10
+ require 'freemium/response'
11
+ require 'freemium/subscription'
12
+ require 'freemium/subscription_change'
13
+ require 'freemium/subscription_plan'
14
+ require 'freemium/transaction'
15
+ require 'freemium/gateways/base'
16
+ require 'freemium/gateways/brain_tree'
17
+ require 'freemium/gateways/test'
18
+
19
+ module Freemium
20
+ class CreditCardStorageError < RuntimeError; end
21
+
22
+ def self.root
23
+ File.expand_path('../..', __FILE__)
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ module Freemium
2
+ class Configuration
3
+ attr_accessor \
4
+ :mailer,
5
+ :gateway,
6
+ :days_grace,
7
+ :days_free_trial,
8
+ :expired_plan,
9
+ :admin_report_recipients
10
+
11
+ def initialize
12
+ @mailer = FreemiumMailer
13
+ @gateway = Freemium::Gateways::Test.new
14
+ @days_grace = 3
15
+ @days_free_trial = 0
16
+ end
17
+ end
18
+
19
+ class << self
20
+ attr_accessor :configuration
21
+ end
22
+
23
+ def self.configure
24
+ self.configuration ||= Configuration.new
25
+ yield configuration
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ module Freemium
2
+ module Coupon
3
+
4
+ def self.included(base)
5
+ base.class_eval do
6
+ has_many :coupon_redemptions, :dependent => :destroy
7
+ has_many :subscriptions, :through => :coupon_redemptions
8
+ has_and_belongs_to_many :subscription_plans
9
+
10
+ validates :description, presence: true
11
+ validates :discount_percentage, presence: true, :inclusion => 0..100
12
+
13
+ before_save :normalize_redemption_key
14
+ end
15
+ end
16
+
17
+ def discount(rate)
18
+ rate * (1 - self.discount_percentage.to_f / 100)
19
+ end
20
+
21
+ def expired?
22
+ (self.redemption_expiration && Date.today > self.redemption_expiration) || (self.redemption_limit && self.coupon_redemptions.count >= self.redemption_limit)
23
+ end
24
+
25
+ def applies_to_plan?(subscription_plan)
26
+ return true if self.subscription_plans.blank? # applies to all plans
27
+ self.subscription_plans.include?(subscription_plan)
28
+ end
29
+
30
+ protected
31
+
32
+ def normalize_redemption_key
33
+ self.redemption_key.downcase! unless self.redemption_key.blank?
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,59 @@
1
+ module Freemium
2
+ module CouponRedemption
3
+
4
+ def self.included(base)
5
+ base.class_eval do
6
+ belongs_to :subscription, :class_name => "Subscription"
7
+ belongs_to :coupon, :class_name => "Coupon"
8
+
9
+ before_validation :upgrade_plan_automatically
10
+ before_create :set_redeemed_on
11
+
12
+ validates_presence_of :coupon
13
+ validates_presence_of :subscription
14
+ validates_uniqueness_of :coupon_id, :scope => :subscription_id, :message => "has already been applied"
15
+ validate :custom_validation, :on => :create
16
+ end
17
+ end
18
+
19
+ def expire!(date = Date.today)
20
+ self.update_attribute :expired_on, date
21
+ end
22
+
23
+ def active?(date = Date.today)
24
+ expires_on ? date <= self.expires_on : true
25
+ end
26
+
27
+ def expires_on
28
+ return nil unless self.coupon.duration_in_months
29
+ self.redeemed_on + self.coupon.duration_in_months.months
30
+ end
31
+
32
+ def redeemed_on
33
+ self['redeemed_on'] || Date.today
34
+ end
35
+
36
+ protected
37
+
38
+ def set_redeemed_on
39
+ self.redeemed_on = Date.today
40
+ end
41
+
42
+ def upgrade_plan_automatically
43
+ if self.coupon &&
44
+ self.coupon.discount_percentage == 100 &&
45
+ self.coupon.subscription_plans.count == 1 &&
46
+ self.coupon.subscription_plans.first.rate > self.subscription.subscription_plan.rate
47
+
48
+ self.subscription.subscription_plan = self.coupon.subscription_plans.first
49
+ end
50
+ end
51
+
52
+ def custom_validation
53
+ errors.add :subscription, "must be paid" if self.subscription && !self.subscription.subscription_plan.paid?
54
+ errors.add :coupon, "has expired" if self.coupon && (self.coupon.expired? || self.coupon.expired?)
55
+ errors.add :coupon, "is not valid for selected plan" if self.coupon && self.subscription && !self.coupon.applies_to_plan?(self.subscription.subscription_plan)
56
+ end
57
+
58
+ end
59
+ end
@@ -0,0 +1,222 @@
1
+ module Freemium
2
+ module CreditCard
3
+
4
+ CARD_COMPANIES = {
5
+ :visa => /^4[0-9]{12}(?:[0-9]{3})?$/,
6
+ :master => /^(5[1-5]\d{4}|677189)\d{10}$/,
7
+ :discover => /^(6011|65\d{2})\d{12}$/,
8
+ :american_express => /^3[47]\d{13}$/,
9
+ :diners_club => /^3(0[0-5]|[68]\d)\d{11}$/,
10
+ :jcb => /^3528\d{12}$/,
11
+ :switch => /^6759\d{12}(\d{2,3})?$/,
12
+ :solo => /^6767\d{12}(\d{2,3})?$/,
13
+ :dankort => /^5019\d{12}$/,
14
+ :maestro => /(^6759[0-9]{2}([0-9]{10})$)|(^6759[0-9]{2}([0-9]{12})$)|(^6759[0-9]{2}([0-9]{13})$)/,
15
+ :forbrugsforeningen => /^600722\d{10}$/,
16
+ :laser => /^(6304[89]\d{11}(\d{2,3})?|670695\d{13})$/
17
+ }
18
+
19
+ def self.included(base)
20
+ base.class_eval do
21
+ # Essential attributes for a valid, non-bogus creditcards
22
+ attr_accessor :number, :month, :year, :name
23
+
24
+ # Required for Switch / Solo cards
25
+ attr_accessor :start_month, :start_year, :issue_number
26
+
27
+ # Optional verification_value (CVV, CVV2 etc). Gateways will try their best to
28
+ # run validation on the passed in value if it is supplied
29
+ attr_accessor :verification_value
30
+
31
+ has_one :subscription
32
+
33
+ before_validation :sanitize_data, :if => :new_or_changed?
34
+
35
+ validate :validate_card, :if => :new_or_changed?
36
+
37
+ validates :name, presence: true, :if => :new_or_changed?
38
+ validates :month, presence: true, inclusion: 1..12, :if => :new_or_changed?
39
+ validates :year, presence: true, inclusion: (Time.now.year..Time.now.year + 20), :if => :new_or_changed?
40
+ validates :number, presence: true, :if => :new_or_changed?
41
+ validates :verification_value, presence: true, :if => :new_or_changed?
42
+ end
43
+
44
+ base.extend(ClassMethods)
45
+ end
46
+
47
+ ##
48
+ ## Callbacks
49
+ ##
50
+
51
+ protected
52
+
53
+ def sanitize_data #:nodoc:
54
+ self.month = month.to_i if month
55
+ self.year = year.to_i if year
56
+ self.number = number.to_s.gsub(/[^\d]/, "")
57
+ self.card_type = self.class.card_type?(number) if card_type.blank?
58
+ self.display_number = display_number
59
+ end
60
+
61
+ public
62
+
63
+ ##
64
+ ## Class Methods
65
+ ##
66
+
67
+ module ClassMethods
68
+ # Returns true if it validates. Optionally, you can pass a card type as an argument and
69
+ # make sure it is of the correct type.
70
+ #
71
+ # References:
72
+ # - http://perl.about.com/compute/perl/library/nosearch/P073000.htm
73
+ # - http://www.beachnet.com/~hstiles/cardtype.html
74
+ def valid_number?(number)
75
+ valid_card_number_length?(number) &&
76
+ valid_checksum?(number)
77
+ end
78
+
79
+ # Regular expressions for the known card companies.
80
+ #
81
+ # References:
82
+ # - http://en.wikipedia.org/wiki/Credit_card_number
83
+ # - http://www.barclaycardbusiness.co.uk/information_zone/processing/bin_rules.html
84
+ def card_companies
85
+ CARD_COMPANIES
86
+ end
87
+
88
+ # Returns a string containing the type of card from the list of known information below.
89
+ # Need to check the cards in a particular order, as there is some overlap of the allowable ranges
90
+ #--
91
+ # TODO Refactor this method. We basically need to tighten up the Maestro Regexp.
92
+ #
93
+ # Right now the Maestro regexp overlaps with the MasterCard regexp (IIRC). If we can tighten
94
+ # things up, we can boil this whole thing down to something like...
95
+ #
96
+ # def type?(number)
97
+ # return 'visa' if valid_test_mode_card_number?(number)
98
+ # card_companies.find([nil]) { |type, regexp| number =~ regexp }.first.dup
99
+ # end
100
+ #
101
+ def card_type?(number)
102
+ card_companies.each do |company, pattern|
103
+ return company if number =~ pattern
104
+ end
105
+
106
+ return nil
107
+ end
108
+
109
+ def last_digits(number)
110
+ number.to_s.length <= 4 ? number : number.to_s.slice(-4..-1)
111
+ end
112
+
113
+ def mask(number)
114
+ "XXXX-XXXX-XXXX-#{last_digits(number)}"
115
+ end
116
+
117
+ # Checks to see if the calculated type matches the specified type
118
+ def matching_card_type?(number, card_type)
119
+ card_type?(number) == card_type
120
+ end
121
+
122
+ private
123
+
124
+ def valid_card_number_length?(number) #:nodoc:
125
+ number.to_s.length >= 12
126
+ end
127
+
128
+ # Checks the validity of a card number by use of the the Luhn Algorithm.
129
+ # Please see http://en.wikipedia.org/wiki/Luhn_algorithm for details.
130
+ def valid_checksum?(number) #:nodoc:
131
+ sum = 0
132
+ for i in 0..number.length
133
+ weight = number[-1 * (i + 2), 1].to_i * (2 - (i % 2))
134
+ sum += (weight < 10) ? weight : weight - 9
135
+ end
136
+
137
+ (number[-1,1].to_i == (10 - sum % 10) % 10)
138
+ end
139
+
140
+ end
141
+
142
+ ##
143
+ ## From ActiveMerchant::Billing::CreditCard
144
+ ##
145
+
146
+ # Provides proxy access to an expiry date object
147
+ def expiration_date
148
+ if @month && @year
149
+ month_days = [nil,31,28,31,30,31,30,31,31,30,31,30,31]
150
+ begin
151
+ month_days[2] = 29 if Date.leap?(@year)
152
+ str = "#{year}/#{@month}/#{month_days[@month]} 23:59:59"
153
+ self['expiration_date'] = Time.parse(str)
154
+ end
155
+ else
156
+ self['expiration_date']
157
+ end
158
+ end
159
+
160
+ def expired?
161
+ return false unless expiration_date
162
+ Time.now > expiration_date
163
+ end
164
+
165
+ # Show the card number, with all but last 4 numbers replace with "X". (XXXX-XXXX-XXXX-4338)
166
+ def display_number
167
+ self['display_number'] ||= self.class.mask(number)
168
+ self['display_number']
169
+ end
170
+
171
+ def last_digits
172
+ self.class.last_digits(number)
173
+ end
174
+
175
+ ##
176
+ ## Overrides
177
+ ##
178
+
179
+ # We're overriding AR#changed? to include instance vars that aren't persisted to see if a new card is being set
180
+ def changed?
181
+ card_type_changed? || [:number, :month, :year, :name, :start_month, :start_year, :issue_number, :verification_value].any? {|attr| !self.send(attr).nil?}
182
+ end
183
+
184
+ def new_or_changed?
185
+ new_record? || changed?
186
+ end
187
+
188
+ ##
189
+ ## Validation
190
+ ##
191
+
192
+ def validate_card
193
+ errors.add :year, "expired" if expired?
194
+ validate_card_number
195
+ validate_switch_or_solo_attributes
196
+ end
197
+
198
+ private
199
+
200
+ def validate_card_number #:nodoc:
201
+ errors.add :number, "is not a valid credit card number" unless self.class.valid_number?(number)
202
+ end
203
+
204
+ def validate_switch_or_solo_attributes #:nodoc:
205
+ if %w[switch solo].include?(card_type)
206
+ unless valid_month?(@start_month) && valid_start_year?(@start_year) || valid_issue_number?(@issue_number)
207
+ errors.add :start_month, "is invalid" unless valid_month?(@start_month)
208
+ errors.add :start_year, "is invalid" unless valid_start_year?(@start_year)
209
+ errors.add :issue_number, "cannot be empty" unless valid_issue_number?(@issue_number)
210
+ end
211
+ end
212
+ end
213
+
214
+ def valid_start_year?(year)
215
+ year.to_s =~ /^\d{4}$/ && year.to_i > 1987
216
+ end
217
+
218
+ def valid_issue_number?(number)
219
+ number.to_s =~ /^\d{1,2}$/
220
+ end
221
+ end
222
+ end