freemium-ajb 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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