freemium 0.0.1
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.
- 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
|