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,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