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,7 @@
1
+ require 'freemium'
2
+ require 'rails'
3
+
4
+ module Freemium
5
+ class Engine < Rails::Engine
6
+ end
7
+ end
@@ -0,0 +1,65 @@
1
+ module Freemium
2
+ module Gateways
3
+ class Base #:nodoc:
4
+ superclass_delegating_accessor :open_timeout
5
+ self.open_timeout = 60
6
+
7
+ superclass_delegating_accessor :read_timeout
8
+ self.read_timeout = 60
9
+
10
+ # cancels the subscription identified by the given billing key.
11
+ # this might mean removing it from the remote system, or halting the remote
12
+ # recurring billing.
13
+ #
14
+ # should return a Freemium::Response
15
+ def cancel(billing_key)
16
+ raise MethodNotImplemented
17
+ end
18
+
19
+ # stores a credit card with the gateway.
20
+ # should return a Freemium::Response
21
+ def store(credit_card, address = nil)
22
+ raise MethodNotImplemented
23
+ end
24
+
25
+ # updates a credit card in the gateway.
26
+ # should return a Freemium::Response
27
+ def update(billing_key, credit_card = nil, address = nil)
28
+ raise MethodNotImplemented
29
+ end
30
+
31
+ # validates a credit card with the gateway.
32
+ # should return a Freemium::Response
33
+ def validate(credit_card, address = nil)
34
+ raise MethodNotImplemented
35
+ end
36
+
37
+ ##
38
+ ## Only needed to support Freemium.billing_handler = :gateway
39
+ ##
40
+
41
+ # only needed to support an ARB module. otherwise, the manual billing process will
42
+ # take care of processing transaction information as it happens.
43
+ #
44
+ # concrete classes need to support these options:
45
+ # :billing_key : - only retrieve transactions for this specific billing key
46
+ # :after : - only retrieve transactions after this datetime (non-inclusive)
47
+ # :before : - only retrieve transactions before this datetime (non-inclusive)
48
+ #
49
+ # return value should be a collection of Freemium::Transaction objects.
50
+ def transactions(options = {})
51
+ raise MethodNotImplemented
52
+ end
53
+
54
+ ##
55
+ ## Only needed to support Freemium.billing_handler = :manual
56
+ ##
57
+
58
+ # charges money against the given billing key.
59
+ # should return a Freemium::Transaction
60
+ def charge(billing_key, amount)
61
+ raise MethodNotImplemented
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,175 @@
1
+ require 'net/http'
2
+ require 'net/https'
3
+ module Freemium
4
+ module Gateways
5
+ # = Setup and Configuration
6
+ # In your config/initializers/freemium.rb, configure Freemium to use BrainTree:
7
+ #
8
+ # Freemium.gateway = Freemium::Gateways::BrainTree.new
9
+ # Freemium.gateway.username = "my_username"
10
+ # Freemium.gateway.password = "my_password"
11
+ #
12
+ # Note that if you want to use demo/password credentials when not in production mode, this is the place.
13
+ #
14
+ # = Data Structures
15
+ # All amounts should use the Money class (from eponymous gem).
16
+ # All credit cards should use Freemium::CreditCard class (currently just an alias for ActiveMerchant::Billing::CreditCard).
17
+ # All addresses should use Freemium::Address class.
18
+ #
19
+ # = For Testing
20
+ # The URL does not change. If your account is in test mode, no charges will be processed. Otherwise,
21
+ # configure the username and password to be "demo" and "password", respectively.
22
+ class BrainTree < Base
23
+ URL = 'https://secure.braintreepaymentgateway.com/api/transact.php'
24
+ attr_accessor :username, :password
25
+
26
+ # using BrainTree's recurring billing is not possible until I have their reporting API
27
+ #def transactions(options = {}); end
28
+
29
+ # Stores a card in SecureVault.
30
+ def store(credit_card, address = nil)
31
+ p = Post.new(URL, {
32
+ :username => self.username,
33
+ :password => self.password,
34
+ :customer_vault => "add_customer"
35
+ })
36
+ p.params.merge! params_for_credit_card(credit_card)
37
+ p.params.merge! params_for_address(address) if address
38
+ p.commit(open_timeout, read_timeout)
39
+ return p.response
40
+ end
41
+
42
+ # Updates a card in SecureVault.
43
+ def update(vault_id, credit_card = nil, address = nil)
44
+ p = Post.new(URL, {
45
+ :username => self.username,
46
+ :password => self.password,
47
+ :customer_vault => "update_customer",
48
+ :customer_vault_id => vault_id
49
+ })
50
+ p.params.merge! params_for_credit_card(credit_card) if credit_card
51
+ p.params.merge! params_for_address(address) if address
52
+ p.commit(open_timeout, read_timeout)
53
+ return p.response
54
+ end
55
+
56
+ # Manually charges a card in SecureVault. Called automatically as part of manual billing process.
57
+ def charge(vault_id, amount)
58
+ p = Post.new(URL, {
59
+ :username => self.username,
60
+ :password => self.password,
61
+ :customer_vault_id => vault_id,
62
+ :amount => sprintf("%.2f", amount.cents.to_f / 100)
63
+ })
64
+ p.commit(open_timeout, read_timeout)
65
+ transaction = AccountTransaction.new(:billing_key => vault_id, :amount => amount, :success => p.response.success?)
66
+ transaction.response = p.response if transaction.respond_to?(:response=)
67
+ return transaction
68
+ end
69
+
70
+ # Removes a card from SecureVault. Called automatically when the subscription expires.
71
+ def cancel(vault_id)
72
+ p = Post.new(URL, {
73
+ :username => self.username,
74
+ :password => self.password,
75
+ :customer_vault => 'delete_customer',
76
+ :customer_vault_id => vault_id
77
+ })
78
+ p.commit(open_timeout, read_timeout)
79
+ return p.response
80
+ end
81
+
82
+ # Validates the card.
83
+ def validate(credit_card, address = nil)
84
+ # Assume we validated if we're using a demo account
85
+ return Freemium::Response.new(true) if self.username == 'demo'
86
+
87
+ p = Post.new(URL, {
88
+ :username => self.username,
89
+ :password => self.password,
90
+ :type => 'validate'
91
+ })
92
+ p.params.merge! params_for_credit_card(credit_card)
93
+ if address
94
+ p.params.merge! params_for_address(address)
95
+ end
96
+
97
+ p.commit(open_timeout, read_timeout)
98
+ return p.response
99
+ end
100
+
101
+ protected
102
+ def params_for_credit_card(card)
103
+ params = {
104
+ :payment => 'creditcard',
105
+ :firstname => card.first_name,
106
+ :lastname => card.last_name,
107
+ :ccnumber => card.number,
108
+ :ccv => card.verification_value,
109
+ :ccexp => ["%.2i" % card.month, ("%.4i" % card.year)[-2..-1]].join # MMYY
110
+ }
111
+ end
112
+
113
+ def params_for_address(address)
114
+ params = {
115
+ :email => address.email,
116
+ :address1 => address.address1,
117
+ :address2 => address.address2,
118
+ :city => address.city,
119
+ :state => address.state, # TODO: two-digit code!
120
+ :zip => address.zip,
121
+ :country => address.country, # TODO: two digit code! (ISO-3166)
122
+ :phone => address.phone_number,
123
+ :ipaddress => address.ip_address
124
+ }
125
+ end
126
+
127
+ class Post
128
+ attr_accessor :url
129
+ attr_accessor :params
130
+ attr_reader :response
131
+
132
+ def initialize(url, params = {})
133
+ self.url = url
134
+ self.params = params
135
+ end
136
+
137
+ def commit(open_timeout, read_timeout)
138
+ data = parse(post(open_timeout, read_timeout))
139
+ # from BT API: 1 means approved, 2 means declined, 3 means error
140
+ success = data['response'].to_i == 1
141
+ @response = Freemium::Response.new(success, data)
142
+ @response.billing_key = data['customer_vault_id']
143
+ @response.message = data['responsetext']
144
+ return self
145
+ end
146
+
147
+ protected
148
+
149
+ # BrainTree returns a body of parameters in GET query format, so convert that into a simple hash.
150
+ def parse(data)
151
+ {}.tap do |results|
152
+ data.split('&').each do |pair|
153
+ key, value = pair.split('=', 2).collect { |v| CGI::unescape(v) }
154
+ results[key] = value
155
+ end
156
+ end
157
+ end
158
+
159
+ # cf. ActiveMerchant's PostsData module.
160
+ def post(open_timeout, read_timeout)
161
+ uri = URI.parse(self.url)
162
+
163
+ http = Net::HTTP.new(uri.host, uri.port)
164
+ http.open_timeout = open_timeout
165
+ http.read_timeout = read_timeout
166
+ http.use_ssl = true
167
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
168
+
169
+ data = self.params.collect { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
170
+ http.post(uri.request_uri, data).body
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,36 @@
1
+ module Freemium
2
+ module Gateways
3
+ class Test < Base
4
+ attr_accessor :username, :password
5
+
6
+ def transactions(options = {})
7
+ options
8
+ end
9
+
10
+ def charge(*args)
11
+ args
12
+ end
13
+
14
+ def store(*args)
15
+ response = Freemium::Response.new(true)
16
+ response.billing_key = Time.now.to_i.to_s
17
+ response
18
+ end
19
+
20
+ def update(billing_key, *args)
21
+ response = Freemium::Response.new(true)
22
+ response.billing_key = billing_key
23
+ response
24
+ end
25
+
26
+ def cancel(*args)
27
+ args
28
+ end
29
+
30
+ def validate(*args)
31
+ response = Freemium::Response.new(true)
32
+ response
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ module Freemium
2
+ module Rates
3
+
4
+ # returns the daily cost of this plan.
5
+ def daily_rate(options = {})
6
+ yearly_rate(options) / 365
7
+ end
8
+
9
+ # returns the yearly cost of this plan.
10
+ def yearly_rate(options = {})
11
+ begin
12
+ rate(options) * 12
13
+ rescue
14
+ rate * 12
15
+ end
16
+ end
17
+
18
+ # returns the monthly cost of this plan.
19
+ def monthly_rate(options = {})
20
+ begin
21
+ rate(options)
22
+ rescue
23
+ rate
24
+ end
25
+ end
26
+
27
+ def paid?
28
+ return false unless rate
29
+ rate.cents > 0
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,24 @@
1
+ module Freemium
2
+ # used to encapsulate the success/failure/details of a response from some gateway.
3
+ # intended to be independent of the details of communication (e.g. Freemium::Gateways::BrainTree::Post).
4
+ class Response
5
+ # a gateway-specific hash of raw data related to the request.
6
+ attr_reader :raw_data
7
+ # may contain a description of the response. should contain an explanation if the response was not a success.
8
+ attr_accessor :message
9
+ # the related billing key, if appropriate
10
+ attr_accessor :billing_key
11
+
12
+ def initialize(success, raw_data = {})
13
+ @success, @raw_data = success, raw_data
14
+ end
15
+
16
+ def success?
17
+ @success
18
+ end
19
+
20
+ def [](key)
21
+ raw_data[key]
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,384 @@
1
+ # == Attributes
2
+ # subscribable: the model in your system that has the subscription. probably a User.
3
+ # subscription_plan: which service plan this subscription is for. affects how payment is interpreted.
4
+ # paid_through: when the subscription currently expires, assuming no further payment. for manual billing, this also determines when the next payment is due.
5
+ # billing_key: the id for this user in the remote billing gateway. may not exist if user is on a free plan.
6
+ # last_transaction_at: when the last gateway transaction was for this account. this is used by your gateway to find "new" transactions.
7
+ #
8
+ module Freemium
9
+ module Subscription
10
+ include Rates
11
+
12
+ def self.included(base)
13
+ base.class_eval do
14
+ belongs_to :subscription_plan
15
+ belongs_to :subscribable, polymorphic: true
16
+
17
+ belongs_to :credit_card, dependent: :destroy
18
+ has_many :coupon_redemptions, conditions: "coupon_redemptions.expired_on IS NULL", dependent: :destroy
19
+ has_many :coupons, conditions: "coupon_redemptions.expired_on IS NULL", through: :coupon_redemptions
20
+
21
+ has_many :transactions
22
+
23
+ scope :paid, -> { includes(:subscription_plan).where("subscription_plans.rate_cents > 0") }
24
+ scope :due, -> { where('paid_through <= ?', Date.today) }
25
+ scope :expired, -> { where('expire_on >= paid_through AND expire_on <= ?', Date.today) }
26
+
27
+ before_save :remove_coupon_if_no_longer_applies
28
+ before_save :set_paid_through
29
+ before_save :set_started_on
30
+ before_save :store_credit_card_offsite
31
+ before_save :discard_credit_card_unless_paid
32
+ before_destroy :cancel_in_remote_system
33
+
34
+ after_create :audit_create
35
+ after_update :audit_update
36
+ after_destroy :audit_destroy
37
+
38
+ validates_presence_of :subscribable
39
+ validates_associated :subscribable
40
+ validates_presence_of :subscription_plan
41
+ validates_presence_of :credit_card, :if => :store_credit_card?
42
+ validates_associated :credit_card
43
+
44
+ validate :gateway_validates_credit_card
45
+ validate :coupon_exist
46
+ end
47
+ base.extend ClassMethods
48
+ end
49
+
50
+ def original_plan
51
+ @original_plan ||= ::SubscriptionPlan.find_by_id(subscription_plan_id_was) unless subscription_plan_id_was.nil?
52
+ end
53
+
54
+ def gateway
55
+ Freemium.configuration.gateway
56
+ end
57
+
58
+
59
+ protected
60
+
61
+ ##
62
+ ## Validations
63
+ ##
64
+
65
+ def gateway_validates_credit_card
66
+ if credit_card && credit_card.changed? && credit_card.valid?
67
+ response = gateway.validate(credit_card)
68
+ unless response.success?
69
+ errors.add(:base, "Credit card could not be validated: #{response.message}")
70
+ end
71
+ end
72
+ end
73
+
74
+ ##
75
+ ## Callbacks
76
+ ##
77
+
78
+ def set_paid_through(force = false)
79
+ if subscription_plan_id_changed? && (!paid_through_changed? || force)
80
+ if paid?
81
+ if new_record?
82
+ # paid + new subscription = in free trial
83
+ self.paid_through = Date.today + Freemium.configuration.days_free_trial
84
+ self.in_trial = true
85
+ elsif !self.in_trial? && self.original_plan && self.original_plan.paid?
86
+ # paid + not in trial + not new subscription + original sub was paid = calculate and credit for remaining value
87
+ value = self.remaining_value(original_plan)
88
+ self.paid_through = Date.today
89
+ self.credit(value)
90
+ else
91
+ # otherwise payment is due today
92
+ self.paid_through = Date.today
93
+ self.in_trial = false
94
+ end
95
+ else
96
+ # free plans don't pay
97
+ self.paid_through = nil
98
+ end
99
+ end
100
+ true
101
+ end
102
+
103
+ def set_started_on
104
+ self.started_on = Date.today if subscription_plan_id_changed?
105
+ end
106
+
107
+ def discard_credit_card_unless_paid
108
+ unless store_credit_card?
109
+ destroy_credit_card
110
+ end
111
+ end
112
+
113
+ def destroy_credit_card
114
+ credit_card.destroy if credit_card
115
+ cancel_in_remote_system
116
+ end
117
+
118
+ def cancel_in_remote_system
119
+ if billing_key
120
+ gateway.cancel(self.billing_key)
121
+ self.billing_key = nil
122
+ end
123
+ end
124
+
125
+ ##
126
+ ## Callbacks :: Auditing
127
+ ##
128
+
129
+ def audit_create
130
+ ::SubscriptionChange.create(:reason => "new",
131
+ :subscribable => self.subscribable,
132
+ :new_subscription_plan_id => self.subscription_plan_id,
133
+ :new_rate => self.rate,
134
+ :original_rate => Money.empty)
135
+ end
136
+
137
+ def audit_update
138
+ if self.subscription_plan_id_changed?
139
+ return if self.original_plan.nil?
140
+ reason = self.original_plan.rate > self.subscription_plan.rate ? (self.expired? ? "expiration" : "downgrade") : "upgrade"
141
+ ::SubscriptionChange.create(:reason => reason,
142
+ :subscribable => self.subscribable,
143
+ :original_subscription_plan_id => self.original_plan.id,
144
+ :original_rate => self.rate(:plan => self.original_plan),
145
+ :new_subscription_plan_id => self.subscription_plan.id,
146
+ :new_rate => self.rate)
147
+ end
148
+ end
149
+
150
+ def remove_coupon_if_no_longer_applies
151
+ return unless self.subscription_plan_id_changed?
152
+ return unless self.coupon
153
+
154
+ if !self.coupon.applies_to_plan?(self.subscription_plan)
155
+ self.coupon_redemption.expire!(Date.yesterday)
156
+ self.coupon_redemptions.reload
157
+ self.set_paid_through(true)
158
+ end
159
+ end
160
+
161
+ def audit_destroy
162
+ ::SubscriptionChange.create(:reason => "cancellation",
163
+ :subscribable => self.subscribable,
164
+ :original_subscription_plan_id => self.subscription_plan_id,
165
+ :original_rate => self.rate,
166
+ :new_rate => Money.empty)
167
+ end
168
+
169
+ public
170
+
171
+ ##
172
+ ## Class Methods
173
+ ##
174
+
175
+ module ClassMethods
176
+ # expires all subscriptions that have been pastdue for too long (accounting for grace)
177
+ def find_expired
178
+ self.expired.select{ |s| s.paid? }
179
+ end
180
+
181
+ def find_billable
182
+ self.paid.due.select { |s| s.paid? }
183
+ end
184
+ end
185
+
186
+ ##
187
+ ## Rate
188
+ ##
189
+
190
+ def rate(options = {})
191
+ options = {:date => Date.today, :plan => self.subscription_plan}.merge(options)
192
+
193
+ return nil unless options[:plan]
194
+ value = options[:plan].rate
195
+ value = self.coupon(options[:date]).discount(value) if self.coupon(options[:date])
196
+ value
197
+ end
198
+
199
+ # is this a paid plan?
200
+ def paid?
201
+ return false unless rate
202
+ rate.cents > 0
203
+ end
204
+
205
+ # Allow for more complex logic to decide if a card should be stored
206
+ def store_credit_card?
207
+ paid?
208
+ end
209
+
210
+ ##
211
+ ## Coupon Redemption
212
+ ##
213
+
214
+ def coupon_key=(coupon_key)
215
+ @coupon_key = coupon_key ? coupon_key.downcase : nil
216
+ self.coupon = ::Coupon.find_by_redemption_key(@coupon_key) unless @coupon_key.blank?
217
+ end
218
+
219
+ def coupon_exist
220
+ if !@coupon_key.blank? && ::Coupon.find_by_redemption_key(@coupon_key).nil?
221
+ self.errors.add :coupon, "could not be found for '#{@coupon_key}'"
222
+ end
223
+ end
224
+
225
+ def coupon=(coupon)
226
+ if coupon
227
+ s = ::CouponRedemption.new(:subscription => self, :coupon => coupon)
228
+ coupon_redemptions << s
229
+ end
230
+ end
231
+
232
+ def coupon(date = Date.today)
233
+ coupon_redemption(date).coupon rescue nil
234
+ end
235
+
236
+ def coupon_redemption(date = Date.today)
237
+ return nil if coupon_redemptions.empty?
238
+ active_coupons = coupon_redemptions.select{|c| c.active?(date)}
239
+ return nil if active_coupons.empty?
240
+ active_coupons.sort_by{|c| c.coupon.discount_percentage }.reverse.first
241
+ end
242
+
243
+ ##
244
+ ## Remaining Time
245
+ ##
246
+
247
+ # returns the value of the time between now and paid_through.
248
+ # will optionally interpret the time according to a certain subscription plan.
249
+ def remaining_value(plan = self.subscription_plan)
250
+ self.daily_rate(:plan => plan) * remaining_days
251
+ end
252
+
253
+ # if paid through today, returns zero
254
+ def remaining_days
255
+ if self.paid_through
256
+ (self.paid_through - Date.today)
257
+ else
258
+ 0
259
+ end
260
+ end
261
+
262
+ ##
263
+ ## Grace Period
264
+ ##
265
+
266
+ # if under grace through today, returns zero
267
+ def remaining_days_of_grace
268
+ (self.expire_on - Date.today - 1).to_i
269
+ end
270
+
271
+ def in_grace?
272
+ remaining_days < 0 and not expired?
273
+ end
274
+
275
+ ##
276
+ ## Expiration
277
+ ##
278
+
279
+ # sets the expiration for the subscription based on today and the configured grace period.
280
+ def expire_after_grace!(transaction = nil)
281
+ return unless self.expire_on.nil? # You only set this once subsequent failed transactions shouldn't affect expiration
282
+ self.expire_on = [Date.today, paid_through].max + Freemium.configuration.days_grace
283
+ transaction.message = "now set to expire on #{self.expire_on}" if transaction
284
+ Freemium.configuration.mailer.expiration_warning(self).deliver
285
+ transaction.save! if transaction
286
+ save!
287
+ end
288
+
289
+ # sends an expiration email, then downgrades to a free plan
290
+ def expire!
291
+ Freemium.configuration.mailer.expiration_notice(self).deliver
292
+ # downgrade to a free plan
293
+ self.expire_on = Date.today
294
+ self.subscription_plan = Freemium.configuration.expired_plan if Freemium.configuration.expired_plan
295
+ self.destroy_credit_card
296
+ self.save!
297
+ end
298
+
299
+ def expired?
300
+ expire_on and expire_on <= Date.today
301
+ end
302
+
303
+ ##
304
+ ## Receiving More Money
305
+ ##
306
+
307
+ # receives payment and saves the record
308
+ def receive_payment!(transaction)
309
+ receive_payment(transaction)
310
+ transaction.save!
311
+ self.save!
312
+ end
313
+
314
+ # extends the paid_through period according to how much money was received.
315
+ # when possible, avoids the days-per-month problem by checking if the money
316
+ # received is a multiple of the plan's rate.
317
+ #
318
+ # really, i expect the case where the received payment does not match the
319
+ # subscription plan's rate to be very much an edge case.
320
+ def receive_payment(transaction)
321
+ self.credit(transaction.amount)
322
+ self.save!
323
+ transaction.subscription.reload # reloaded to that the paid_through date is correct
324
+ transaction.message = "now paid through #{self.paid_through}"
325
+
326
+ begin
327
+ Freemium.configuration.mailer.invoice(transaction).deliver
328
+ rescue => e
329
+ transaction.message = "error sending invoice: #{e}"
330
+ end
331
+ end
332
+
333
+ def credit(amount)
334
+ self.paid_through = if amount.cents % rate.cents == 0
335
+ self.paid_through + (amount.cents / rate.cents).months
336
+ else
337
+ self.paid_through + (amount.cents / daily_rate.cents).days
338
+ end
339
+
340
+ # if they've paid again, then reset expiration
341
+ self.expire_on = nil
342
+ self.in_trial = false
343
+ end
344
+
345
+ # Override if you need to charge something different than the rate (ex: yearly billing option)
346
+ def installment_amount(options = {})
347
+ self.rate(options)
348
+ end
349
+
350
+ # charges this subscription.
351
+ # assumes, of course, that this module is mixed in to the Subscription model
352
+ def charge!
353
+ # Save the transaction immediately
354
+
355
+ @transaction = gateway.charge(billing_key, self.installment_amount)
356
+ self.transactions << @transaction
357
+ self.last_transaction_at = Time.now # TODO this could probably now be inferred from the list of transactions
358
+ self.last_transaction_success = @transaction.success?
359
+
360
+ self.save(:validate => false)
361
+
362
+ begin
363
+ if @transaction.success?
364
+ receive_payment!(@transaction)
365
+ elsif !@transaction.subscription.in_grace?
366
+ expire_after_grace!(@transaction)
367
+ end
368
+ rescue
369
+ end
370
+
371
+ @transaction
372
+ end
373
+
374
+ def store_credit_card_offsite
375
+ if credit_card && credit_card.changed? && credit_card.valid?
376
+ response = billing_key ? gateway.update(billing_key, credit_card) : gateway.store(credit_card)
377
+ raise Freemium::CreditCardStorageError.new(response.message) unless response.success?
378
+ self.billing_key = response.billing_key
379
+ self.expire_on = nil if last_transaction_success
380
+ self.credit_card.reload # to prevent needless subsequent store() calls
381
+ end
382
+ end
383
+ end
384
+ end