freemium 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +53 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +121 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +67 -0
- data/Rakefile +23 -0
- data/autotest/discover.rb +2 -0
- data/config/locales/en.yml +2 -0
- data/freemium.gemspec +28 -0
- data/lib/freemium/address.rb +18 -0
- data/lib/freemium/coupon.rb +38 -0
- data/lib/freemium/coupon_redemption.rb +48 -0
- data/lib/freemium/credit_card.rb +273 -0
- data/lib/freemium/feature_set.rb +45 -0
- data/lib/freemium/gateways/base.rb +65 -0
- data/lib/freemium/gateways/brain_tree.rb +175 -0
- data/lib/freemium/gateways/test.rb +34 -0
- data/lib/freemium/manual_billing.rb +73 -0
- data/lib/freemium/railtie.tb +7 -0
- data/lib/freemium/rates.rb +33 -0
- data/lib/freemium/recurring_billing.rb +59 -0
- data/lib/freemium/response.rb +24 -0
- data/lib/freemium/subscription.rb +350 -0
- data/lib/freemium/subscription_change.rb +20 -0
- data/lib/freemium/subscription_mailer/admin_report.rhtml +4 -0
- data/lib/freemium/subscription_mailer/expiration_notice.rhtml +1 -0
- data/lib/freemium/subscription_mailer/expiration_warning.rhtml +1 -0
- data/lib/freemium/subscription_mailer/invoice.text.plain.erb +5 -0
- data/lib/freemium/subscription_mailer.rb +36 -0
- data/lib/freemium/subscription_plan.rb +32 -0
- data/lib/freemium/transaction.rb +15 -0
- data/lib/freemium/version.rb +3 -0
- data/lib/freemium.rb +75 -0
- data/lib/generators/active_record/freemium_generator.rb +28 -0
- data/lib/generators/active_record/templates/migrations/account_transactions.rb +17 -0
- data/lib/generators/active_record/templates/migrations/coupon_redemptions.rb +18 -0
- data/lib/generators/active_record/templates/migrations/coupons.rb +28 -0
- data/lib/generators/active_record/templates/migrations/credit_cards.rb +14 -0
- data/lib/generators/active_record/templates/migrations/subscription_changes.rb +18 -0
- data/lib/generators/active_record/templates/migrations/subscription_plans.rb +14 -0
- data/lib/generators/active_record/templates/migrations/subscriptions.rb +30 -0
- data/lib/generators/active_record/templates/models/account_transaction.rb +3 -0
- data/lib/generators/active_record/templates/models/coupon.rb +3 -0
- data/lib/generators/active_record/templates/models/coupon_redemption.rb +3 -0
- data/lib/generators/active_record/templates/models/credit_card.rb +3 -0
- data/lib/generators/active_record/templates/models/subscription.rb +3 -0
- data/lib/generators/active_record/templates/models/subscription_change.rb +3 -0
- data/lib/generators/active_record/templates/models/subscription_plan.rb +3 -0
- data/lib/generators/freemium/freemium_generator.rb +15 -0
- data/lib/generators/freemium/install_generator.rb +28 -0
- data/lib/generators/freemium/orm_helpers.rb +27 -0
- data/lib/generators/templates/freemium.rb +43 -0
- data/lib/generators/templates/freemium_feature_sets.yml +5 -0
- data/spec/dummy/Rakefile +7 -0
- data/spec/dummy/app/controllers/application_controller.rb +3 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/models/models.rb +32 -0
- data/spec/dummy/app/views/layouts/application.html.erb +14 -0
- data/spec/dummy/config/application.rb +45 -0
- data/spec/dummy/config/boot.rb +10 -0
- data/spec/dummy/config/database.yml +22 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +26 -0
- data/spec/dummy/config/environments/production.rb +49 -0
- data/spec/dummy/config/environments/test.rb +35 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/inflections.rb +10 -0
- data/spec/dummy/config/initializers/mime_types.rb +5 -0
- data/spec/dummy/config/initializers/secret_token.rb +7 -0
- data/spec/dummy/config/initializers/session_store.rb +8 -0
- data/spec/dummy/config/locales/en.yml +5 -0
- data/spec/dummy/config/routes.rb +58 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/db/schema.rb +92 -0
- data/spec/dummy/script/rails +6 -0
- data/spec/fixtures/credit_cards.yml +11 -0
- data/spec/fixtures/subscription_plans.yml +18 -0
- data/spec/fixtures/subscriptions.yml +29 -0
- data/spec/fixtures/users.yml +16 -0
- data/spec/freemium_feature_sets.yml +9 -0
- data/spec/freemium_spec.rb +4 -0
- data/spec/models/coupon_redemption_spec.rb +235 -0
- data/spec/models/credit_card_spec.rb +114 -0
- data/spec/models/manual_billing_spec.rb +174 -0
- data/spec/models/recurring_billing_spec.rb +92 -0
- data/spec/models/subscription_plan_spec.rb +44 -0
- data/spec/models/subscription_spec.rb +386 -0
- data/spec/spec_helper.rb +38 -0
- data/spec/support/helpers.rb +21 -0
- metadata +298 -0
@@ -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,59 @@
|
|
1
|
+
module Freemium
|
2
|
+
# adds recurring billing functionality to the Subscription class
|
3
|
+
module RecurringBilling
|
4
|
+
def self.included(base)
|
5
|
+
base.extend ClassMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
# the process you should run periodically
|
10
|
+
def run_billing
|
11
|
+
# first, synchronize transactions
|
12
|
+
transactions = process_transactions
|
13
|
+
|
14
|
+
# then, set expiration for any subscriptions that didn't process
|
15
|
+
find_expirable.each(&:expire_after_grace!)
|
16
|
+
# then, actually expire any subscriptions whose time has come
|
17
|
+
expire
|
18
|
+
|
19
|
+
# send the activity report
|
20
|
+
Freemium.mailer.deliver_admin_report(
|
21
|
+
transactions
|
22
|
+
) if Freemium.admin_report_recipients && !new_transactions.empty?
|
23
|
+
end
|
24
|
+
|
25
|
+
protected
|
26
|
+
|
27
|
+
# retrieves all transactions posted after the last known transaction
|
28
|
+
#
|
29
|
+
# please note how this works: it calculates the maximum last_transaction_at
|
30
|
+
# value and only retrieves transactions after that. so be careful that you
|
31
|
+
# don't accidentally update the last_transaction_at field for some subset
|
32
|
+
# of subscriptions, and leave the others behind!
|
33
|
+
def new_transactions
|
34
|
+
Freemium.gateway.transactions(:after => self.maximum(:last_transaction_at))
|
35
|
+
end
|
36
|
+
|
37
|
+
# updates all subscriptions with any new transactions
|
38
|
+
def process_transactions(transactions = new_transactions)
|
39
|
+
transaction do
|
40
|
+
transactions.each do |t|
|
41
|
+
subscription = ::Subscription.find_by_billing_key(t.billing_key)
|
42
|
+
subscription.transactions << t
|
43
|
+
t.success? ? subscription.receive_payment!(t) : subscription.expire_after_grace!(t)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
transactions
|
47
|
+
end
|
48
|
+
|
49
|
+
# finds all subscriptions that should have paid but didn't and need to be expired
|
50
|
+
# because of coupons we can't trust rate_cents alone and need to verify that the account is indeed paid?
|
51
|
+
def find_expirable
|
52
|
+
paid.
|
53
|
+
where(['paid_through < ?', Date.today]).
|
54
|
+
where('(expire_on IS NULL OR expire_on < paid_through)').
|
55
|
+
select { |s| s.paid? }
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
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,350 @@
|
|
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, :class_name => "SubscriptionPlan"
|
15
|
+
belongs_to :subscribable, :polymorphic => true
|
16
|
+
belongs_to :credit_card, :dependent => :destroy, :class_name => "CreditCard"
|
17
|
+
has_many :coupon_redemptions, :conditions => "coupon_redemptions.expired_on IS NULL", :class_name => "CouponRedemption", :foreign_key => :subscription_id, :dependent => :destroy
|
18
|
+
has_many :coupons, :through => :coupon_redemptions, :conditions => "coupon_redemptions.expired_on IS NULL"
|
19
|
+
|
20
|
+
# Auditing
|
21
|
+
has_many :transactions, :class_name => "AccountTransaction", :foreign_key => :subscription_id
|
22
|
+
|
23
|
+
scope :paid, includes(:subscription_plan).where("subscription_plans.rate_cents > 0")
|
24
|
+
scope :due, lambda {
|
25
|
+
where(['paid_through <= ?', Date.today]) # could use the concept of a next retry date
|
26
|
+
}
|
27
|
+
scope :expired, lambda {
|
28
|
+
where(['expire_on >= paid_through AND expire_on <= ?', Date.today])
|
29
|
+
}
|
30
|
+
|
31
|
+
before_validation :set_paid_through
|
32
|
+
before_validation :set_started_on
|
33
|
+
before_save :store_credit_card_offsite
|
34
|
+
before_save :discard_credit_card_unless_paid
|
35
|
+
before_destroy :cancel_in_remote_system
|
36
|
+
|
37
|
+
after_create :audit_create
|
38
|
+
after_update :audit_update
|
39
|
+
after_destroy :audit_destroy
|
40
|
+
|
41
|
+
validates_presence_of :subscribable
|
42
|
+
validates_associated :subscribable
|
43
|
+
validates_presence_of :subscription_plan
|
44
|
+
validates_presence_of :paid_through, :if => :paid?
|
45
|
+
validates_presence_of :started_on
|
46
|
+
validates_presence_of :credit_card, :if => :store_credit_card?
|
47
|
+
validates_associated :credit_card#, :if => :store_credit_card?
|
48
|
+
|
49
|
+
validate :gateway_validates_credit_card
|
50
|
+
validate :coupon_exist
|
51
|
+
end
|
52
|
+
base.extend ClassMethods
|
53
|
+
end
|
54
|
+
|
55
|
+
def original_plan
|
56
|
+
@original_plan ||= ::SubscriptionPlan.find_by_id(subscription_plan_id_was) unless subscription_plan_id_was.nil?
|
57
|
+
end
|
58
|
+
|
59
|
+
def gateway
|
60
|
+
Freemium.gateway
|
61
|
+
end
|
62
|
+
|
63
|
+
|
64
|
+
protected
|
65
|
+
|
66
|
+
##
|
67
|
+
## Validations
|
68
|
+
##
|
69
|
+
|
70
|
+
def gateway_validates_credit_card
|
71
|
+
if credit_card && credit_card.changed? && credit_card.valid?
|
72
|
+
response = gateway.validate(credit_card, credit_card.address)
|
73
|
+
unless response.success?
|
74
|
+
errors.add(:base, "Credit card could not be validated: #{response.message}")
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
##
|
80
|
+
## Callbacks
|
81
|
+
##
|
82
|
+
|
83
|
+
def set_paid_through
|
84
|
+
if subscription_plan_id_changed? && !paid_through_changed?
|
85
|
+
if paid?
|
86
|
+
if new_record?
|
87
|
+
# paid + new subscription = in free trial
|
88
|
+
self.paid_through = Date.today + Freemium.days_free_trial
|
89
|
+
self.in_trial = true
|
90
|
+
elsif !self.in_trial? && self.original_plan && self.original_plan.paid?
|
91
|
+
# paid + not in trial + not new subscription + original sub was paid = calculate and credit for remaining value
|
92
|
+
value = self.remaining_value(original_plan)
|
93
|
+
self.paid_through = Date.today
|
94
|
+
self.credit(value)
|
95
|
+
else
|
96
|
+
# otherwise payment is due today
|
97
|
+
self.paid_through = Date.today
|
98
|
+
self.in_trial = false
|
99
|
+
end
|
100
|
+
else
|
101
|
+
# free plans don't pay
|
102
|
+
self.paid_through = nil
|
103
|
+
end
|
104
|
+
end
|
105
|
+
true
|
106
|
+
end
|
107
|
+
|
108
|
+
def set_started_on
|
109
|
+
self.started_on = Date.today if subscription_plan_id_changed?
|
110
|
+
end
|
111
|
+
|
112
|
+
# Simple assignment of a credit card. Note that this may not be
|
113
|
+
# useful for your particular situation, especially if you need
|
114
|
+
# to simultaneously set up automated recurrences.
|
115
|
+
#
|
116
|
+
# Because of the third-party interaction with the gateway, you
|
117
|
+
# need to be careful to only use this method when you expect to
|
118
|
+
# be able to save the record successfully. Otherwise you may end
|
119
|
+
# up storing a credit card in the gateway and then losing the key.
|
120
|
+
#
|
121
|
+
# NOTE: Support for updating an address could easily be added
|
122
|
+
# with an "address" property on the credit card.
|
123
|
+
def store_credit_card_offsite
|
124
|
+
if credit_card && credit_card.changed? && credit_card.valid?
|
125
|
+
response = billing_key ? gateway.update(billing_key, credit_card, credit_card.address) : gateway.store(credit_card, credit_card.address)
|
126
|
+
raise Freemium::CreditCardStorageError.new(response.message) unless response.success?
|
127
|
+
self.billing_key = response.billing_key
|
128
|
+
self.expire_on = nil
|
129
|
+
self.credit_card.reload # to prevent needless subsequent store() calls
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def discard_credit_card_unless_paid
|
134
|
+
unless store_credit_card?
|
135
|
+
destroy_credit_card
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def destroy_credit_card
|
140
|
+
credit_card.destroy if credit_card
|
141
|
+
cancel_in_remote_system
|
142
|
+
end
|
143
|
+
|
144
|
+
def cancel_in_remote_system
|
145
|
+
if billing_key
|
146
|
+
gateway.cancel(self.billing_key)
|
147
|
+
self.billing_key = nil
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
##
|
152
|
+
## Callbacks :: Auditing
|
153
|
+
##
|
154
|
+
|
155
|
+
def audit_create
|
156
|
+
::SubscriptionChange.create(:reason => "new",
|
157
|
+
:subscribable => self.subscribable,
|
158
|
+
:new_subscription_plan_id => self.subscription_plan_id,
|
159
|
+
:new_rate => self.rate,
|
160
|
+
:original_rate => Money.empty)
|
161
|
+
end
|
162
|
+
|
163
|
+
def audit_update
|
164
|
+
if self.subscription_plan_id_changed?
|
165
|
+
return if self.original_plan.nil?
|
166
|
+
reason = self.original_plan.rate > self.subscription_plan.rate ? (self.expired? ? "expiration" : "downgrade") : "upgrade"
|
167
|
+
::SubscriptionChange.create(:reason => reason,
|
168
|
+
:subscribable => self.subscribable,
|
169
|
+
:original_subscription_plan_id => self.original_plan.id,
|
170
|
+
:original_rate => self.rate(:plan => self.original_plan),
|
171
|
+
:new_subscription_plan_id => self.subscription_plan.id,
|
172
|
+
:new_rate => self.rate)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def audit_destroy
|
177
|
+
::SubscriptionChange.create(:reason => "cancellation",
|
178
|
+
:subscribable => self.subscribable,
|
179
|
+
:original_subscription_plan_id => self.subscription_plan_id,
|
180
|
+
:original_rate => self.rate,
|
181
|
+
:new_rate => Money.empty)
|
182
|
+
end
|
183
|
+
|
184
|
+
public
|
185
|
+
|
186
|
+
##
|
187
|
+
## Class Methods
|
188
|
+
##
|
189
|
+
|
190
|
+
module ClassMethods
|
191
|
+
# expires all subscriptions that have been pastdue for too long (accounting for grace)
|
192
|
+
def expire
|
193
|
+
self.expired.select{|s| s.paid?}.each(&:expire!)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
##
|
198
|
+
## Rate
|
199
|
+
##
|
200
|
+
|
201
|
+
def rate(options = {})
|
202
|
+
options = {:date => Date.today, :plan => self.subscription_plan}.merge(options)
|
203
|
+
|
204
|
+
return nil unless options[:plan]
|
205
|
+
value = options[:plan].rate
|
206
|
+
value = self.coupon(options[:date]).discount(value) if self.coupon(options[:date])
|
207
|
+
value
|
208
|
+
end
|
209
|
+
|
210
|
+
def paid?
|
211
|
+
return false unless rate
|
212
|
+
rate.cents > 0
|
213
|
+
end
|
214
|
+
|
215
|
+
# Allow for more complex logic to decide if a card should be stored
|
216
|
+
def store_credit_card?
|
217
|
+
paid?
|
218
|
+
end
|
219
|
+
|
220
|
+
##
|
221
|
+
## Coupon Redemption
|
222
|
+
##
|
223
|
+
|
224
|
+
def coupon_key=(coupon_key)
|
225
|
+
@coupon_key = coupon_key ? coupon_key.downcase : nil
|
226
|
+
self.coupon = ::Coupon.find_by_redemption_key(@coupon_key) unless @coupon_key.blank?
|
227
|
+
end
|
228
|
+
|
229
|
+
def coupon_exist
|
230
|
+
self.errors.add :coupon, "could not be found for '#{@coupon_key}'" if !@coupon_key.blank? && ::Coupon.find_by_redemption_key(@coupon_key).nil?
|
231
|
+
end
|
232
|
+
|
233
|
+
def coupon=(coupon)
|
234
|
+
if coupon
|
235
|
+
s = ::CouponRedemption.new(:subscription => self, :coupon => coupon)
|
236
|
+
coupon_redemptions << s
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
def coupon(date = Date.today)
|
241
|
+
coupon_redemption(date).coupon rescue nil
|
242
|
+
end
|
243
|
+
|
244
|
+
def coupon_redemption(date = Date.today)
|
245
|
+
return nil if coupon_redemptions.empty?
|
246
|
+
active_coupons = coupon_redemptions.select{|c| c.active?(date)}
|
247
|
+
return nil if active_coupons.empty?
|
248
|
+
active_coupons.sort_by{|c| c.coupon.discount_percentage }.reverse.first
|
249
|
+
end
|
250
|
+
|
251
|
+
##
|
252
|
+
## Remaining Time
|
253
|
+
##
|
254
|
+
|
255
|
+
# returns the value of the time between now and paid_through.
|
256
|
+
# will optionally interpret the time according to a certain subscription plan.
|
257
|
+
def remaining_value(plan = self.subscription_plan)
|
258
|
+
self.daily_rate(:plan => plan) * remaining_days
|
259
|
+
end
|
260
|
+
|
261
|
+
# if paid through today, returns zero
|
262
|
+
def remaining_days
|
263
|
+
(self.paid_through - Date.today)
|
264
|
+
end
|
265
|
+
|
266
|
+
##
|
267
|
+
## Grace Period
|
268
|
+
##
|
269
|
+
|
270
|
+
# if under grace through today, returns zero
|
271
|
+
def remaining_days_of_grace
|
272
|
+
(self.expire_on - Date.today - 1).to_i
|
273
|
+
end
|
274
|
+
|
275
|
+
def in_grace?
|
276
|
+
remaining_days < 0 and not expired?
|
277
|
+
end
|
278
|
+
|
279
|
+
##
|
280
|
+
## Expiration
|
281
|
+
##
|
282
|
+
|
283
|
+
# sets the expiration for the subscription based on today and the configured grace period.
|
284
|
+
def expire_after_grace!(transaction = nil)
|
285
|
+
return unless self.expire_on.nil? # You only set this once subsequent failed transactions shouldn't affect expiration
|
286
|
+
self.expire_on = [Date.today, paid_through].max + Freemium.days_grace
|
287
|
+
transaction.message = "now set to expire on #{self.expire_on}" if transaction
|
288
|
+
Freemium.mailer.expiration_warning(self).deliver
|
289
|
+
transaction.save! if transaction
|
290
|
+
save!
|
291
|
+
end
|
292
|
+
|
293
|
+
# sends an expiration email, then downgrades to a free plan
|
294
|
+
def expire!
|
295
|
+
Freemium.mailer.expiration_notice(self).deliver
|
296
|
+
# downgrade to a free plan
|
297
|
+
self.expire_on = Date.today
|
298
|
+
self.subscription_plan = Freemium.expired_plan if Freemium.expired_plan
|
299
|
+
self.destroy_credit_card
|
300
|
+
self.save!
|
301
|
+
end
|
302
|
+
|
303
|
+
def expired?
|
304
|
+
expire_on and expire_on <= Date.today
|
305
|
+
end
|
306
|
+
|
307
|
+
##
|
308
|
+
## Receiving More Money
|
309
|
+
##
|
310
|
+
|
311
|
+
# receives payment and saves the record
|
312
|
+
def receive_payment!(transaction)
|
313
|
+
receive_payment(transaction)
|
314
|
+
transaction.save!
|
315
|
+
self.save!
|
316
|
+
end
|
317
|
+
|
318
|
+
# extends the paid_through period according to how much money was received.
|
319
|
+
# when possible, avoids the days-per-month problem by checking if the money
|
320
|
+
# received is a multiple of the plan's rate.
|
321
|
+
#
|
322
|
+
# really, i expect the case where the received payment does not match the
|
323
|
+
# subscription plan's rate to be very much an edge case.
|
324
|
+
def receive_payment(transaction)
|
325
|
+
self.credit(transaction.amount)
|
326
|
+
self.save!
|
327
|
+
transaction.subscription.reload # reloaded to that the paid_through date is correct
|
328
|
+
transaction.message = "now paid through #{self.paid_through}"
|
329
|
+
|
330
|
+
begin
|
331
|
+
Freemium.mailer.invoice(transaction).deliver
|
332
|
+
rescue => e
|
333
|
+
transaction.message = "error sending invoice: #{e}"
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
def credit(amount)
|
338
|
+
self.paid_through = if amount.cents % rate.cents == 0
|
339
|
+
self.paid_through + (amount.cents / rate.cents).months
|
340
|
+
else
|
341
|
+
self.paid_through + (amount.cents / daily_rate.cents).days
|
342
|
+
end
|
343
|
+
|
344
|
+
# if they've paid again, then reset expiration
|
345
|
+
self.expire_on = nil
|
346
|
+
self.in_trial = false
|
347
|
+
end
|
348
|
+
|
349
|
+
end
|
350
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Freemium
|
2
|
+
module SubscriptionChange
|
3
|
+
|
4
|
+
def self.included(base)
|
5
|
+
base.class_eval do
|
6
|
+
belongs_to :subscribable, :polymorphic => true
|
7
|
+
|
8
|
+
belongs_to :original_subscription_plan, :class_name => "SubscriptionPlan"
|
9
|
+
belongs_to :new_subscription_plan, :class_name => "SubscriptionPlan"
|
10
|
+
|
11
|
+
composed_of :new_rate, :class_name => 'Money', :mapping => [ %w(new_rate_cents cents) ], :allow_nil => true
|
12
|
+
composed_of :original_rate, :class_name => 'Money', :mapping => [ %w(original_rate_cents cents) ], :allow_nil => true
|
13
|
+
|
14
|
+
validates_presence_of :reason
|
15
|
+
validates_inclusion_of :reason, :in => %w(new upgrade downgrade expiration cancellation)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
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,36 @@
|
|
1
|
+
class SubscriptionMailer < 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.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.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.admin_report_recipients,
|
26
|
+
:subject => "Your subscription has expired")
|
27
|
+
end
|
28
|
+
|
29
|
+
def admin_report(transactions)
|
30
|
+
@amount_charged = transactions.select{|t| t.success?}.collect{|t| t.amount}.sum
|
31
|
+
@transactions = transactions
|
32
|
+
@amount_charged = @amount_charged
|
33
|
+
mail(:to => Freemium.admin_report_recipients,
|
34
|
+
:subject => "Billing report (#{@amount_charged} charged)")
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# == Attributes
|
2
|
+
# subscriptions: all subscriptions for the plan
|
3
|
+
# rate_cents: how much this plan costs, in cents
|
4
|
+
# rate: how much this plan costs, in Money
|
5
|
+
# yearly: whether this plan cycles yearly or monthly
|
6
|
+
#
|
7
|
+
module Freemium
|
8
|
+
module SubscriptionPlan
|
9
|
+
include Rates
|
10
|
+
|
11
|
+
def self.included(base)
|
12
|
+
base.class_eval do
|
13
|
+
# yes, subscriptions.subscription_plan_id may not be null, but
|
14
|
+
# this at least makes the delete not happen if there are any active.
|
15
|
+
has_many :subscriptions, :dependent => :nullify, :class_name => "Subscription", :foreign_key => :subscription_plan_id
|
16
|
+
has_and_belongs_to_many :coupons, :class_name => "SubscriptionPlan",
|
17
|
+
:join_table => :coupons_subscription_plans, :foreign_key => :subscription_plan_id, :association_foreign_key => :coupon_id
|
18
|
+
|
19
|
+
composed_of :rate, :class_name => 'Money', :mapping => [ %w(rate_cents cents) ], :allow_nil => true
|
20
|
+
|
21
|
+
validates_uniqueness_of :redemption_key, :allow_nil => true, :allow_blank => true
|
22
|
+
validates_presence_of :name
|
23
|
+
validates_presence_of :rate_cents
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def features
|
28
|
+
Freemium::FeatureSet.find(self.feature_set_id)
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Freemium
|
2
|
+
module Transaction
|
3
|
+
|
4
|
+
def self.included(base)
|
5
|
+
base.class_eval do
|
6
|
+
scope :since, lambda { |time| where(["created_at >= ?", time]) }
|
7
|
+
|
8
|
+
belongs_to :subscription, :class_name => "Subscription"
|
9
|
+
|
10
|
+
composed_of :amount, :class_name => 'Money', :mapping => [ %w(amount_cents cents) ], :allow_nil => true
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
end
|
data/lib/freemium.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'rails'
|
2
|
+
|
3
|
+
require 'money'
|
4
|
+
require "freemium/version"
|
5
|
+
require 'freemium/address'
|
6
|
+
require 'freemium/coupon'
|
7
|
+
require 'freemium/coupon_redemption'
|
8
|
+
require 'freemium/credit_card'
|
9
|
+
require 'freemium/feature_set'
|
10
|
+
require 'freemium/manual_billing'
|
11
|
+
require 'freemium/rates'
|
12
|
+
require 'freemium/recurring_billing'
|
13
|
+
require 'freemium/response'
|
14
|
+
require 'freemium/subscription'
|
15
|
+
require 'freemium/subscription_change'
|
16
|
+
require 'freemium/subscription_plan'
|
17
|
+
require 'freemium/transaction'
|
18
|
+
require 'freemium/gateways/base'
|
19
|
+
require 'freemium/gateways/brain_tree'
|
20
|
+
require 'freemium/gateways/test'
|
21
|
+
require 'freemium/subscription_mailer'
|
22
|
+
|
23
|
+
module Freemium
|
24
|
+
class CreditCardStorageError < RuntimeError; end
|
25
|
+
|
26
|
+
# Lets you configure which ActionMailer class contains appropriate
|
27
|
+
# mailings for invoices, expiration warnings, and expiration notices.
|
28
|
+
# You'll probably want to create your own, based on lib/subscription_mailer.rb.
|
29
|
+
mattr_accessor :mailer
|
30
|
+
@@mailer = SubscriptionMailer
|
31
|
+
|
32
|
+
# The gateway of choice. Default gateway is a stubbed testing gateway.
|
33
|
+
mattr_accessor :gateway
|
34
|
+
@@gateway = Freemium::Gateways::Test.new
|
35
|
+
|
36
|
+
# You need to specify whether Freemium or your gateway's ARB module will control
|
37
|
+
# the billing process. If your gateway's ARB controls the billing process, then
|
38
|
+
# Freemium will simply try and keep up-to-date on transactions.
|
39
|
+
def self.billing_handler=(val)
|
40
|
+
case val
|
41
|
+
when :manual then ::Subscription.send(:include, Freemium::ManualBilling)
|
42
|
+
when :gateway then ::Subscription.send(:include, Freemium::RecurringBilling)
|
43
|
+
else
|
44
|
+
raise "unknown billing_handler: #{val}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# How many days to keep an account active after it fails to pay.
|
49
|
+
mattr_accessor :days_grace
|
50
|
+
@@days_grace = 3
|
51
|
+
|
52
|
+
# How many days in an initial free trial?
|
53
|
+
mattr_accessor :days_free_trial
|
54
|
+
@@days_free_trial = 0
|
55
|
+
|
56
|
+
# What plan to assign to subscriptions that have expired. May be nil.
|
57
|
+
mattr_writer :expired_plan
|
58
|
+
def self.expired_plan
|
59
|
+
@@expired_plan ||= (::SubscriptionPlan.find_by_redemption_key(expired_plan_key.to_s) if expired_plan_key)
|
60
|
+
end
|
61
|
+
|
62
|
+
# It's easier to assign a plan by it's key (so you don't get errors before you run migrations)
|
63
|
+
# we will reset subscription_plan when we change the key
|
64
|
+
mattr_reader :expired_plan_key
|
65
|
+
def self.expired_plan_key=(key)
|
66
|
+
@@expired_plan_key = key
|
67
|
+
@@expired_plan = nil
|
68
|
+
end
|
69
|
+
|
70
|
+
# If you want to receive admin reports, enter an email (or list of emails) here.
|
71
|
+
# These will be bcc'd on all SubscriptionMailer emails, and will also receive the
|
72
|
+
# admin activity report.
|
73
|
+
mattr_accessor :admin_report_recipients
|
74
|
+
|
75
|
+
end
|