saas 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/README.rdoc +2 -0
- data/app/controllers/subscriptions_controller.rb +84 -0
- data/app/models/subscription.rb +332 -0
- data/app/models/subscription_config.rb +30 -0
- data/app/models/subscription_mailer.rb +54 -0
- data/app/models/subscription_observer.rb +40 -0
- data/app/models/subscription_plan.rb +36 -0
- data/app/models/subscription_profile.rb +182 -0
- data/app/models/subscription_transaction.rb +150 -0
- data/app/views/subscription_mailer/admin_report.html.erb +4 -0
- data/app/views/subscription_mailer/charge_failure.html.erb +5 -0
- data/app/views/subscription_mailer/charge_success.html.erb +13 -0
- data/app/views/subscription_mailer/credit_success.html.erb +11 -0
- data/app/views/subscription_mailer/second_charge_failure.html.erb +5 -0
- data/app/views/subscription_mailer/subscription_expired.html.erb +5 -0
- data/app/views/subscription_mailer/trial_expiring.html.erb +17 -0
- data/app/views/subscriptions/credit_card.html.erb +33 -0
- data/app/views/subscriptions/edit.html.erb +14 -0
- data/app/views/subscriptions/history.html.erb +7 -0
- data/app/views/subscriptions/show.html.erb +59 -0
- metadata +76 -0
data/README.rdoc
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
class SubscriptionsController < ApplicationController
|
|
2
|
+
before_filter :login_required
|
|
3
|
+
before_filter :find_subscription
|
|
4
|
+
|
|
5
|
+
def show
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def edit
|
|
9
|
+
# to change plans
|
|
10
|
+
@allowed_plans = @subscription.allowed_plans
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update
|
|
14
|
+
# find the plan
|
|
15
|
+
plan = SubscriptionPlan.find params[:subscription][:plan]
|
|
16
|
+
if plan.nil?
|
|
17
|
+
flash[:notice] = "Plan not available"
|
|
18
|
+
|
|
19
|
+
# make sure its an allowed plan
|
|
20
|
+
elsif exceeded = @subscription.exceeds_plan?( plan )
|
|
21
|
+
flash[:notice] = "You cannot change to plan #{plan.name}. Resources exceeded"
|
|
22
|
+
flash[:notice] << ": #{exceeded.keys.join(', ')}" if exceeded.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
# perform the change
|
|
25
|
+
# note, use #change_plan, dont just assign it
|
|
26
|
+
elsif @subscription.change_plan(plan)
|
|
27
|
+
flash[:notice] = "Successfully changed plans. "
|
|
28
|
+
|
|
29
|
+
# after change_plan, call renew
|
|
30
|
+
case result = @subscription.renew
|
|
31
|
+
when false
|
|
32
|
+
flash[:notice] << "An error occured trying to charge your credit card. Please update your card information."
|
|
33
|
+
when Money
|
|
34
|
+
flash[:notice] << "Thank you for your payment. Your credit card has been charged #{result.format}"
|
|
35
|
+
end
|
|
36
|
+
return redirect_to subscription_path(:current)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# failed above for some reason
|
|
40
|
+
@allowed_plans = @subscription.allowed_plans
|
|
41
|
+
render :action => 'edit'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cancel
|
|
45
|
+
@subscription.cancel
|
|
46
|
+
flash[:notice] = 'Your subscription has been canceled. You still have limited access to this site.'
|
|
47
|
+
redirect_to subscription_path(:current)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# could put these in separate controllers, but keeping it simple for now
|
|
51
|
+
def credit_card
|
|
52
|
+
@profile = @subscription.profile
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def store_credit_card
|
|
56
|
+
@subscription.profile.credit_card = params[:profile][:credit_card]
|
|
57
|
+
@subscription.profile.request_ip = request.remote_ip
|
|
58
|
+
if @subscription.profile.save
|
|
59
|
+
#debugger
|
|
60
|
+
case result = @subscription.renew
|
|
61
|
+
when false
|
|
62
|
+
flash[:notice] = "An error occured trying to charge your credit card. Please update your card information."
|
|
63
|
+
when Money
|
|
64
|
+
flash[:notice] = "Thank you for your payment. Your credit card has been charged #{result.format}"
|
|
65
|
+
else
|
|
66
|
+
flash[:notice] = "Credit card info successfully updated. No charges have been made at this time."
|
|
67
|
+
end
|
|
68
|
+
return redirect_to subscription_path(:current)
|
|
69
|
+
else
|
|
70
|
+
@profile = @subscription.profile
|
|
71
|
+
render :action => 'credit_card'
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def history
|
|
76
|
+
@transactions = @subscription.transactions
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def find_subscription
|
|
82
|
+
@subscription = current_user.subscription
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
class Subscription < ActiveRecord::Base
|
|
2
|
+
|
|
3
|
+
belongs_to :subscriber, :polymorphic => true
|
|
4
|
+
belongs_to :plan, :class_name => 'SubscriptionPlan'
|
|
5
|
+
has_one :profile, :class_name => 'SubscriptionProfile', :dependent => :destroy
|
|
6
|
+
has_many :transactions, :class_name => 'SubscriptionTransaction', :dependent => :destroy, :order => 'id DESC' #created_at is in seconds not microseconds?! so assume higher id's are newer
|
|
7
|
+
composed_of :balance, :class_name => 'Money', :mapping => [ %w(balance_cents cents) ], :allow_nil => true
|
|
8
|
+
|
|
9
|
+
before_validation :initialize_defaults
|
|
10
|
+
after_create :initialize_state_from_plan
|
|
11
|
+
# if you destroy a subscription all transaction history is lost so you may not really want to do that
|
|
12
|
+
before_destroy :cancel
|
|
13
|
+
|
|
14
|
+
attr_accessor :current_password
|
|
15
|
+
|
|
16
|
+
attr_accessible # none
|
|
17
|
+
|
|
18
|
+
# ------------
|
|
19
|
+
# states: :pending, :free, :trial, :active, :past_due, :expired
|
|
20
|
+
state_machine :state, :initial => :pending do
|
|
21
|
+
# set next renewal date when entering a state
|
|
22
|
+
before_transition any => :free, :do => :setup_free
|
|
23
|
+
before_transition any => :trial, :do => :setup_trial
|
|
24
|
+
before_transition any => :active, :do => :setup_active
|
|
25
|
+
after_transition any => :expired, :do => :setup_expired
|
|
26
|
+
|
|
27
|
+
# always reset warning level when entering a different state
|
|
28
|
+
before_transition any => [any - same] do |sub| sub.warning_level = nil; end
|
|
29
|
+
|
|
30
|
+
# for simpicity, event names are the same as the state
|
|
31
|
+
event :free do
|
|
32
|
+
transition any => :free
|
|
33
|
+
end
|
|
34
|
+
event :trial do
|
|
35
|
+
transition [:pending, :free] => :trial
|
|
36
|
+
end
|
|
37
|
+
event :active do
|
|
38
|
+
transition any => :active
|
|
39
|
+
end
|
|
40
|
+
event :past_due do
|
|
41
|
+
from = any - [:expired]
|
|
42
|
+
transition from => :past_due, :if => Proc.new {|s| s.due? }
|
|
43
|
+
end
|
|
44
|
+
event :expired do
|
|
45
|
+
transition any => :expired
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def setup_free
|
|
51
|
+
self.next_renewal_on = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def setup_trial
|
|
55
|
+
start = Time.zone.today
|
|
56
|
+
self.next_renewal_on = start + SubscriptionConfig.trial_period.days
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def setup_active
|
|
60
|
+
# next renewal is from when subscription ran out (to change this behavior, set next_renewal to nil before doing renew)
|
|
61
|
+
start = next_renewal_on || Time.zone.today
|
|
62
|
+
self.next_renewal_on = start + plan.interval.months
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def setup_expired
|
|
66
|
+
change_plan SubscriptionPlan.expired_plan
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# returns nil if not past due, false for failed, true for success, or amount charged for success when card was charged
|
|
70
|
+
def renew
|
|
71
|
+
# make sure it's time
|
|
72
|
+
return nil unless due?
|
|
73
|
+
transaction do # makes this atomic
|
|
74
|
+
#debugger
|
|
75
|
+
# adjust current balance (except for re-tries)
|
|
76
|
+
self.balance += plan.rate unless past_due?
|
|
77
|
+
|
|
78
|
+
# charge the amount due
|
|
79
|
+
case charge = charge_balance
|
|
80
|
+
# transaction failed: past due and return false
|
|
81
|
+
when false then
|
|
82
|
+
Rails.logger.debug 'transaction failed: past due and return false'
|
|
83
|
+
past_due && false
|
|
84
|
+
# not charged, subtracted from current balance: update renewal and return true
|
|
85
|
+
when nil then
|
|
86
|
+
Rails.logger.debug 'not charged, subtracted from current balance: update renewal and return true'
|
|
87
|
+
active && true
|
|
88
|
+
# card was charged: update renewal and return amount
|
|
89
|
+
else
|
|
90
|
+
Rails.logger.debug 'card was charged: update renewal and return amount'
|
|
91
|
+
active && charge
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# cancelling can mean revert to a free plan and credit back their card
|
|
97
|
+
# if it also means destroying or disabling the user account, that happens elsewhere in your app
|
|
98
|
+
# returns same results as change_plan (nil, false, true)
|
|
99
|
+
def cancel
|
|
100
|
+
change_plan SubscriptionPlan.default_plan
|
|
101
|
+
# uncomment if you want to refund unused value to their credit card, otherwise it just says on balance here
|
|
102
|
+
#credit_balance
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ------------
|
|
106
|
+
# changing the subscription plan
|
|
107
|
+
# usage: e.g in a SubscriptionsController
|
|
108
|
+
# if !@subscription.exceeds_plan?( plan ) && @subscription.change_plan( plan )
|
|
109
|
+
# @subscription.renew
|
|
110
|
+
# end
|
|
111
|
+
|
|
112
|
+
# the #change_plan method sets the new current plan,
|
|
113
|
+
# prorates unused service from previous billing
|
|
114
|
+
# billing cycle for the new plan starts today
|
|
115
|
+
# if was in trial, stays in trial until the trial period runs out
|
|
116
|
+
# note, you should call #renew right after this
|
|
117
|
+
|
|
118
|
+
# returns nil if no change, false if failed, or true on success
|
|
119
|
+
|
|
120
|
+
def change_plan( new_plan )
|
|
121
|
+
# not change?
|
|
122
|
+
return if plan == new_plan
|
|
123
|
+
|
|
124
|
+
# return unused prepaid value on current plan
|
|
125
|
+
self.balance -= plan.prorated_value( days_remaining ) if SubscriptionConfig.return_unused_balance && active?
|
|
126
|
+
# or they owe the used (although unpaid) value on current plan [comment out if you want to be more forgiving]
|
|
127
|
+
self.balance -= plan.rate - plan.prorated_value( past_due_days ) if past_due?
|
|
128
|
+
|
|
129
|
+
# update the plan
|
|
130
|
+
self.plan = new_plan
|
|
131
|
+
|
|
132
|
+
# update the state and initialize the renewal date
|
|
133
|
+
if plan.free?
|
|
134
|
+
self.free
|
|
135
|
+
|
|
136
|
+
elsif (e = trial_ends_on)
|
|
137
|
+
self.trial
|
|
138
|
+
self.next_renewal_on = e #reset end date
|
|
139
|
+
|
|
140
|
+
else #active or past due
|
|
141
|
+
# note, past due grace period resets like active ones due today, ok?
|
|
142
|
+
self.active
|
|
143
|
+
self.next_renewal_on = Time.zone.today
|
|
144
|
+
self.warning_level = nil
|
|
145
|
+
end
|
|
146
|
+
# past_due and expired fall through till next renew
|
|
147
|
+
|
|
148
|
+
# save changes so far
|
|
149
|
+
save
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# list of plans this subscriber is allowed to choose
|
|
153
|
+
# use the subscription_plan_check callback in subscriber model
|
|
154
|
+
def allowed_plans
|
|
155
|
+
SubscriptionPlan.all.collect {|plan| plan unless exceeds_plan?(plan) }.compact
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# test if subscriber can use a plan, returns true or false
|
|
159
|
+
def exceeds_plan?( plan = self.plan)
|
|
160
|
+
!(plan_check(plan).blank?)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# check if subscriber can use a plan and returns list of attributes exceeded, or blank for ok
|
|
164
|
+
def plan_check( plan = self.plan)
|
|
165
|
+
subscriber.subscription_plan_check(plan)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# -------------
|
|
169
|
+
# charge the current balance against the subscribers credit card
|
|
170
|
+
# return amount charged on success, false for failure, nil for nothing happened
|
|
171
|
+
def charge_balance
|
|
172
|
+
#debugger
|
|
173
|
+
# nothing to charge? (0 or a credit)
|
|
174
|
+
return if balance_cents <= 0
|
|
175
|
+
# no cc on fle
|
|
176
|
+
return false if profile.no_info? || profile.profile_key.nil?
|
|
177
|
+
|
|
178
|
+
transaction do # makes this atomic
|
|
179
|
+
#debugger
|
|
180
|
+
# charge the card
|
|
181
|
+
tx = SubscriptionTransaction.charge( balance, profile.profile_key )
|
|
182
|
+
# save the transaction
|
|
183
|
+
transactions.push( tx )
|
|
184
|
+
# set profile state and reset balance
|
|
185
|
+
if tx.success
|
|
186
|
+
self.update_attribute :balance_cents, 0
|
|
187
|
+
profile.authorized
|
|
188
|
+
else
|
|
189
|
+
profile.error
|
|
190
|
+
end
|
|
191
|
+
tx.success && tx.amount
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# -------------
|
|
196
|
+
# charge the current balance
|
|
197
|
+
# return amount charged on success, nil for nothing happened
|
|
198
|
+
def manual_charge_balance
|
|
199
|
+
#debugger
|
|
200
|
+
# nothing to charge? (0 or a credit)
|
|
201
|
+
return if balance_cents <= 0
|
|
202
|
+
|
|
203
|
+
transaction do # makes this atomic
|
|
204
|
+
#debugger
|
|
205
|
+
# charge the card
|
|
206
|
+
tx = SubscriptionTransaction.new(:success => true, :message => 'Successfull', :action => 'Manual Charge', :amount_cents => balance_cents)
|
|
207
|
+
# save the transaction
|
|
208
|
+
transactions.push( tx )
|
|
209
|
+
self.update_attribute :balance_cents, 0
|
|
210
|
+
self.active
|
|
211
|
+
tx.success && tx.amount
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# credit a negative balance to the subscribers credit card
|
|
216
|
+
# returns amount credited on success, false for failure, nil for nothing
|
|
217
|
+
def credit_balance
|
|
218
|
+
#debugger
|
|
219
|
+
# nothing to credit?
|
|
220
|
+
return if balance_cents >= 0
|
|
221
|
+
# no cc on fle
|
|
222
|
+
return false if profile.no_info? || profile.profile_key.nil?
|
|
223
|
+
|
|
224
|
+
transaction do # makes this atomic
|
|
225
|
+
#debugger
|
|
226
|
+
# credit the card
|
|
227
|
+
tx = SubscriptionTransaction.credit( -balance_cents, profile.profile_key, :subscription => self )
|
|
228
|
+
# save the transaction
|
|
229
|
+
transactions.push( tx )
|
|
230
|
+
# set profile state and reset balance
|
|
231
|
+
if tx.success
|
|
232
|
+
self.update_attribute :balance_cents, 0
|
|
233
|
+
profile.authorized
|
|
234
|
+
else
|
|
235
|
+
profile.error
|
|
236
|
+
end
|
|
237
|
+
tx.success && tx.amount
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# -------------
|
|
242
|
+
# true if account is due today or before
|
|
243
|
+
def due?( days_from_now = 0)
|
|
244
|
+
days_remaining && (days_remaining <= days_from_now)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# date trial ends, or nil if not eligable
|
|
248
|
+
def trial_ends_on
|
|
249
|
+
# no trials?
|
|
250
|
+
return if SubscriptionConfig.trial_period.to_i==0
|
|
251
|
+
case
|
|
252
|
+
# in trial, days remaining
|
|
253
|
+
when trial? then next_renewal_on
|
|
254
|
+
# new record? would start from today
|
|
255
|
+
when plan.nil? then Time.zone.today + SubscriptionConfig.trial_period.days
|
|
256
|
+
# start or continue a trial? prorate since creation
|
|
257
|
+
#when active? :
|
|
258
|
+
else
|
|
259
|
+
d = created_at.to_date + SubscriptionConfig.trial_period.days
|
|
260
|
+
d unless d <= Time.zone.today
|
|
261
|
+
# else nil not eligable
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# number of days until next renewal
|
|
266
|
+
def days_remaining
|
|
267
|
+
(next_renewal_on - Time.zone.today) unless next_renewal_on.nil?
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# number of days account is past due (negative of days_remaining)
|
|
271
|
+
def past_due_days
|
|
272
|
+
(Time.zone.today - next_renewal_on) unless next_renewal_on.nil?
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# number of days until account expires
|
|
276
|
+
def grace_days_remaining
|
|
277
|
+
(next_renewal_on + SubscriptionConfig.grace_period.days - Time.zone.today) if past_due?
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# most recent transaction
|
|
281
|
+
def latest_transaction
|
|
282
|
+
transactions.first
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# ------------
|
|
286
|
+
# named scopes
|
|
287
|
+
# used in daily rake task
|
|
288
|
+
# note, 'due' scopes find up to and including the specified day
|
|
289
|
+
scope :due_now, lambda {
|
|
290
|
+
{ :conditions => ["next_renewal_on <= ?", Time.zone.today] }
|
|
291
|
+
}
|
|
292
|
+
scope :due_on, lambda {|date|
|
|
293
|
+
{ :conditions => ["next_renewal_on <= ?", date] }
|
|
294
|
+
}
|
|
295
|
+
scope :due_in, lambda {|days|
|
|
296
|
+
{ :conditions => ["next_renewal_on <= ?", Time.zone.today + days] }
|
|
297
|
+
}
|
|
298
|
+
scope :due_ago, lambda {|days|
|
|
299
|
+
{ :conditions => ["next_renewal_on <= ?", Time.zone.today - days] }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
scope :with_no_warnings, lambda {
|
|
303
|
+
{ :conditions => { :warning_level => nil } }
|
|
304
|
+
}
|
|
305
|
+
scope :with_warning_level, lambda {|level|
|
|
306
|
+
{ :conditions => { :warning_level => level } }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
# -----------
|
|
310
|
+
protected
|
|
311
|
+
|
|
312
|
+
def initialize_defaults
|
|
313
|
+
# default plan
|
|
314
|
+
self.plan ||= SubscriptionPlan.default_plan
|
|
315
|
+
# bug fix: when aasm sometimes doesnt initialize
|
|
316
|
+
self.state ||= 'pending'
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def initialize_state_from_plan
|
|
320
|
+
# build profile if not present
|
|
321
|
+
self.create_profile if profile.nil?
|
|
322
|
+
# initialize the state (and renewal date) [doing this after create since aasm saves]
|
|
323
|
+
if plan.free?
|
|
324
|
+
self.free
|
|
325
|
+
elsif SubscriptionConfig.trial_period > 0
|
|
326
|
+
self.trial
|
|
327
|
+
else
|
|
328
|
+
self.active
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class SubscriptionConfig
|
|
2
|
+
def self.load
|
|
3
|
+
config_file = File.join(Rails.root, "config", "subscription.yml")
|
|
4
|
+
|
|
5
|
+
if File.exists?(config_file)
|
|
6
|
+
text = ERB.new(File.read(config_file)).result
|
|
7
|
+
hash = YAML.load(text)
|
|
8
|
+
config = hash.stringify_keys[ ENV['RAILS_ENV'] || Rails.env]
|
|
9
|
+
config.keys.each do |key|
|
|
10
|
+
cattr_accessor key
|
|
11
|
+
send("#{key}=", config[key])
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# this is initialized to an instance of ActiveMerchant::Billing::Base.gateway
|
|
17
|
+
cattr_accessor :gateway
|
|
18
|
+
|
|
19
|
+
def self.bogus?
|
|
20
|
+
gateway.is_a? ActiveMerchant::Billing::BogusGateway
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.mailer
|
|
24
|
+
@mailer ||= mailer_class.constantize rescue SubscriptionMailer
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# load configuration settings
|
|
30
|
+
SubscriptionConfig.load
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
class SubscriptionMailer < ActionMailer::Base
|
|
2
|
+
self.prepend_view_path File.dirname(__FILE__)
|
|
3
|
+
|
|
4
|
+
def trial_expiring(subscription)
|
|
5
|
+
setup_email(subscription)
|
|
6
|
+
@subject += "Your trial is ending soon"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def charge_success(subscription, transaction)
|
|
10
|
+
setup_email(subscription, transaction)
|
|
11
|
+
@subject += "Service invoice"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def charge_failure(subscription, transaction)
|
|
15
|
+
setup_email(subscription, transaction)
|
|
16
|
+
@subject += "Billing error"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def second_charge_failure(subscription, transaction)
|
|
20
|
+
setup_email(subscription, transaction)
|
|
21
|
+
@subject += "Second notice: Your subscription is set to expire"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def credit_success(subscription, transaction)
|
|
25
|
+
setup_email(subscription, transaction)
|
|
26
|
+
@subject += "Credit"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def subscription_expired(subscription)
|
|
30
|
+
setup_email(subscription)
|
|
31
|
+
@subject += "Your subscription has expired"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# def admin_report(admin, activity_log)
|
|
35
|
+
# @subject = "Example app: Subscription admin report"
|
|
36
|
+
# @recipients = admin
|
|
37
|
+
# @from = "billing@example.com"
|
|
38
|
+
# @sent_on = Time.now
|
|
39
|
+
# @body[:log] = activity_log
|
|
40
|
+
# end
|
|
41
|
+
|
|
42
|
+
protected
|
|
43
|
+
|
|
44
|
+
def setup_email(subscription, transaction=nil)
|
|
45
|
+
@recipients = subscription.subscriber.email
|
|
46
|
+
@from = "billing@example.com"
|
|
47
|
+
@subject = "Example app: "
|
|
48
|
+
@sent_on = Time.now
|
|
49
|
+
@body[:transaction] = transaction
|
|
50
|
+
@body[:subscription] = subscription
|
|
51
|
+
@body[:user] = subscription.subscriber
|
|
52
|
+
@bcc = SubscriptionConfig.admin_report_recipients if SubscriptionConfig.admin_report_recipients
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# use this observer to send out email notifications when transactions are saved
|
|
2
|
+
# unclutters the models and ensures users get notified whenever their credit card is accessed
|
|
3
|
+
# tracks warning levels so the same message isnt duplicated,
|
|
4
|
+
# and handles when subscription is expired (move that out of here? but we did try to charge the card one last time)
|
|
5
|
+
# Install in environment.rb config.active_record.observers = :subscription_observer
|
|
6
|
+
|
|
7
|
+
class SubscriptionObserver < ActiveRecord::Observer
|
|
8
|
+
observe :subscription_transaction
|
|
9
|
+
|
|
10
|
+
def after_save(transaction)
|
|
11
|
+
sub = transaction.subscription
|
|
12
|
+
case transaction.action
|
|
13
|
+
when 'charge'
|
|
14
|
+
if transaction.success?
|
|
15
|
+
SubscriptionConfig.mailer.deliver_charge_success(sub, transaction)
|
|
16
|
+
else
|
|
17
|
+
sub.increment!(:warning_level)
|
|
18
|
+
case sub.warning_level
|
|
19
|
+
when 1
|
|
20
|
+
SubscriptionConfig.mailer.deliver_charge_failure(sub, transaction)
|
|
21
|
+
when 2
|
|
22
|
+
SubscriptionConfig.mailer.deliver_second_charge_failure(sub, transaction)
|
|
23
|
+
else
|
|
24
|
+
# expired: do in the app whatever it means to become expired
|
|
25
|
+
# send no mail here
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
when 'credit', 'refund'
|
|
30
|
+
if transaction.success?
|
|
31
|
+
SubscriptionConfig.mailer.deliver_credit_success(sub, transaction)
|
|
32
|
+
end
|
|
33
|
+
# else no email
|
|
34
|
+
|
|
35
|
+
else # 'validate', 'store', 'update', 'unstore'
|
|
36
|
+
# send no email
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
class SubscriptionPlan < ActiveRecord::Base
|
|
2
|
+
has_many :subscriptions
|
|
3
|
+
|
|
4
|
+
composed_of :rate, :class_name => 'Money', :mapping => [ %w(rate_cents cents) ]
|
|
5
|
+
|
|
6
|
+
validates_presence_of :name
|
|
7
|
+
validates_uniqueness_of :name
|
|
8
|
+
validates_presence_of :rate_cents
|
|
9
|
+
validates_numericality_of :interval # in months
|
|
10
|
+
|
|
11
|
+
def free?
|
|
12
|
+
rate.zero?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def prorated_value( days )
|
|
16
|
+
days ||= 0 # just in case called with nil
|
|
17
|
+
# this calculation is a little off, we're going to assume 30 days/month rather than varying it month to month
|
|
18
|
+
total_days = interval * 30
|
|
19
|
+
daily_rate = rate_cents.to_f / total_days
|
|
20
|
+
# round down to penny
|
|
21
|
+
Money.new( (days * daily_rate).to_i )
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# ---------------
|
|
25
|
+
|
|
26
|
+
def self.default_plan
|
|
27
|
+
default_plan = SubscriptionPlan.find_by_name(SubscriptionConfig.default_plan) if SubscriptionConfig.respond_to? :default_plan
|
|
28
|
+
default_plan ||= SubscriptionPlan.first( :conditions => { :rate_cents => 0 })
|
|
29
|
+
default_plan ||= SubscriptionPlan.create( :name => 'free' ) #bootstrapper and tests
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.expired_plan
|
|
33
|
+
expired_plan = SubscriptionPlan.find_by_name(SubscriptionConfig.expired_plan) if SubscriptionConfig.respond_to? :expired_plan
|
|
34
|
+
expired_plan ||= default_plan
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
class SubscriptionProfile < ActiveRecord::Base
|
|
2
|
+
belongs_to :subscription
|
|
3
|
+
#validates_presence_of :subscription_id
|
|
4
|
+
|
|
5
|
+
attr_accessor :request_ip, :credit_card
|
|
6
|
+
validate :validate_card
|
|
7
|
+
before_save :store_card
|
|
8
|
+
before_destroy :unstore_card
|
|
9
|
+
|
|
10
|
+
attr_accessible # none
|
|
11
|
+
|
|
12
|
+
# ------------
|
|
13
|
+
state_machine :state, :initial => :no_info do
|
|
14
|
+
before_transition any => :no_info, :do => :unstore_card
|
|
15
|
+
|
|
16
|
+
event :authorized do
|
|
17
|
+
transition any => :authorized
|
|
18
|
+
end
|
|
19
|
+
event :error do
|
|
20
|
+
transition any => :error
|
|
21
|
+
end
|
|
22
|
+
event :remove do
|
|
23
|
+
from = any - [:no_info]
|
|
24
|
+
transition from => :no_info
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ------------
|
|
29
|
+
# behave like it's
|
|
30
|
+
# has_one :credit_card
|
|
31
|
+
# accepts_nested_attributes_for :credit_card
|
|
32
|
+
|
|
33
|
+
def credit_card=( card_or_params )
|
|
34
|
+
@credit_card = case card_or_params
|
|
35
|
+
when ActiveMerchant::Billing::CreditCard, nil
|
|
36
|
+
card_or_params
|
|
37
|
+
else
|
|
38
|
+
ActiveMerchant::Billing::CreditCard.new(card_or_params)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def new_credit_card
|
|
43
|
+
# populate new card with some saved values
|
|
44
|
+
ActiveMerchant::Billing::CreditCard.new(
|
|
45
|
+
:first_name => card_first_name,
|
|
46
|
+
:last_name => card_last_name,
|
|
47
|
+
# :address etc too if we have it
|
|
48
|
+
:type => card_type
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# -------------
|
|
53
|
+
# move this into a test helper...
|
|
54
|
+
def self.example_credit_card_params( params = {})
|
|
55
|
+
default = {
|
|
56
|
+
:first_name => 'First Name',
|
|
57
|
+
:last_name => 'Last Name',
|
|
58
|
+
:type => 'visa',
|
|
59
|
+
:number => '4111111111111111',
|
|
60
|
+
:month => '10',
|
|
61
|
+
:year => '2012',
|
|
62
|
+
:verification_value => '999'
|
|
63
|
+
}.merge( params )
|
|
64
|
+
|
|
65
|
+
specific = case SubscriptionConfig.gateway_name
|
|
66
|
+
when 'authorize_net_cim'
|
|
67
|
+
{
|
|
68
|
+
:type => 'visa',
|
|
69
|
+
:number => '4007000000027',
|
|
70
|
+
}
|
|
71
|
+
# 370000000000002 American Express Test Card
|
|
72
|
+
# 6011000000000012 Discover Test Card
|
|
73
|
+
# 4007000000027 Visa Test Card
|
|
74
|
+
# 4012888818888 second Visa Test Card
|
|
75
|
+
# 3088000000000017 JCB
|
|
76
|
+
# 38000000000006 Diners Club/ Carte Blanche
|
|
77
|
+
|
|
78
|
+
when 'bogus'
|
|
79
|
+
{
|
|
80
|
+
:type => 'bogus',
|
|
81
|
+
:number => '1',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
else
|
|
85
|
+
{}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
default.merge(specific).merge(params)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unstore_card
|
|
92
|
+
return if no_info? || profile_key.nil?
|
|
93
|
+
transaction do # atomic
|
|
94
|
+
tx = SubscriptionTransaction.unstore( profile_key )
|
|
95
|
+
subscription.transactions.push( tx )
|
|
96
|
+
if tx.success?
|
|
97
|
+
# clear everything in case this is ever called without destroy
|
|
98
|
+
self.profile_key = nil
|
|
99
|
+
self.card_first_name = nil
|
|
100
|
+
self.card_last_name = nil
|
|
101
|
+
self.card_type = nil
|
|
102
|
+
self.card_display_number = nil
|
|
103
|
+
self.card_expires_on = nil
|
|
104
|
+
self.credit_card = nil
|
|
105
|
+
# change profile state
|
|
106
|
+
self.state = 'no_info' # can't call no_info! here, it saves
|
|
107
|
+
else
|
|
108
|
+
#errors.add(:credit_card, "failed to #{tx.action} card: #{tx.message}")
|
|
109
|
+
errors.add_to_base "Failed to #{tx.action} card: #{tx.message}"
|
|
110
|
+
end
|
|
111
|
+
tx.success
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# -------------
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# validate :validate_card
|
|
119
|
+
def validate_card
|
|
120
|
+
#debugger
|
|
121
|
+
return if credit_card.nil?
|
|
122
|
+
# first validate via ActiveMerchant local code
|
|
123
|
+
unless credit_card.valid?
|
|
124
|
+
# collect credit card error messages into the profile object
|
|
125
|
+
#errors.add(:credit_card, "must be valid")
|
|
126
|
+
credit_card.errors.full_messages.each do |message|
|
|
127
|
+
errors.add_to_base message
|
|
128
|
+
end
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if SubscriptionConfig.validate_via_transaction
|
|
133
|
+
transaction do # makes this atomic
|
|
134
|
+
tx = SubscriptionTransaction.validate_card( credit_card )
|
|
135
|
+
subscription.transactions.push( tx )
|
|
136
|
+
if ! tx.success?
|
|
137
|
+
#errors.add(:credit_card, "failed to #{tx.action} card: #{tx.message}")
|
|
138
|
+
errors.add_to_base "Failed to #{tx.action} card: #{tx.message}"
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
true
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def store_card
|
|
147
|
+
#debugger
|
|
148
|
+
return unless credit_card && credit_card.valid?
|
|
149
|
+
|
|
150
|
+
transaction do # makes this atomic
|
|
151
|
+
if profile_key
|
|
152
|
+
tx = SubscriptionTransaction.update( profile_key, credit_card)
|
|
153
|
+
else
|
|
154
|
+
tx = SubscriptionTransaction.store(credit_card)
|
|
155
|
+
end
|
|
156
|
+
subscription.transactions.push( tx )
|
|
157
|
+
if tx.success?
|
|
158
|
+
# remember the token/key/billing id (whatever)
|
|
159
|
+
self.profile_key = tx.token
|
|
160
|
+
|
|
161
|
+
# remember some non-secure params for convenience
|
|
162
|
+
self.card_first_name = credit_card.first_name
|
|
163
|
+
self.card_last_name = credit_card.last_name
|
|
164
|
+
self.card_type = credit_card.type
|
|
165
|
+
self.card_display_number = credit_card.display_number
|
|
166
|
+
self.card_expires_on = credit_card.expiry_date.expiration.to_date
|
|
167
|
+
|
|
168
|
+
# clear the card in memory
|
|
169
|
+
self.credit_card = nil
|
|
170
|
+
|
|
171
|
+
# change profile state
|
|
172
|
+
self.state = 'authorized' # can't call authorized! here, it saves
|
|
173
|
+
|
|
174
|
+
else # ! tx.success
|
|
175
|
+
#errors.add(:credit_card, "failed to #{tx.action} card: #{tx.message}")
|
|
176
|
+
errors.add_to_base "Failed to #{tx.action} card: #{tx.message}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
tx.success
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# SubscriptionTransaction encapsulates the ActiveMerchant gateway methods
|
|
2
|
+
# providing a consistent api for the rest of the SaasRamp plugin
|
|
3
|
+
|
|
4
|
+
class SubscriptionTransaction < ActiveRecord::Base
|
|
5
|
+
belongs_to :subscription
|
|
6
|
+
serialize :params
|
|
7
|
+
composed_of :amount, :class_name => 'Money', :mapping => [ %w(amount_cents cents) ], :allow_nil => true
|
|
8
|
+
attr_accessor :token
|
|
9
|
+
|
|
10
|
+
# find recent 'charge' transactions that are greater or equal to amount
|
|
11
|
+
scope :charges_at_least, lambda {|amount|
|
|
12
|
+
{ :conditions => ["action = ? AND amount_cents >= ?", 'charge', amount.cents],
|
|
13
|
+
:order => "created_at DESC" }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# note, according to peepcode pdf, many gateways require a unique order_id on each transaction
|
|
18
|
+
|
|
19
|
+
# validate card via transaction
|
|
20
|
+
def validate_card( credit_card, options ={})
|
|
21
|
+
options[:order_id] ||= unique_order_number
|
|
22
|
+
# authorize $1
|
|
23
|
+
amount = 100
|
|
24
|
+
result = process( 'validate', amount ) do |gw|
|
|
25
|
+
gw.authorize( amount, credit_card, options )
|
|
26
|
+
end
|
|
27
|
+
if result.success?
|
|
28
|
+
# void it
|
|
29
|
+
result = process( 'validate' ) do |gw|
|
|
30
|
+
gw.void( result.reference, options )
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def store( credit_card, options = {})
|
|
37
|
+
options[:order_id] ||= unique_order_number
|
|
38
|
+
process( 'store' ) do |gw|
|
|
39
|
+
gw.store( credit_card, options )
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def update( profile_key, credit_card, options = {})
|
|
44
|
+
options[:order_id] ||= unique_order_number
|
|
45
|
+
# some gateways can update, otherwise unstore/store it
|
|
46
|
+
# thus, always capture the profile key in case it changed
|
|
47
|
+
if SubscriptionConfig.gateway.respond_to?(:update)
|
|
48
|
+
process( 'update' ) do |gw|
|
|
49
|
+
gw.update( profile_key, credit_card, options )
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
process( 'update' ) do |gw|
|
|
53
|
+
gw.unstore( profile_key, options )
|
|
54
|
+
gw.store( credit_card, options )
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def unstore( profile_key, options = {})
|
|
60
|
+
options[:order_id] ||= unique_order_number
|
|
61
|
+
process( 'unstore' ) do |gw|
|
|
62
|
+
gw.unstore( profile_key, options )
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def charge( amount, profile_key, options ={})
|
|
68
|
+
options[:order_id] ||= unique_order_number
|
|
69
|
+
if SubscriptionConfig.gateway.respond_to?(:purchase)
|
|
70
|
+
process( 'charge', amount ) do |gw|
|
|
71
|
+
gw.purchase( amount, profile_key, options )
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
# do it in 2 transactions
|
|
75
|
+
process( 'charge', amount ) do |gw|
|
|
76
|
+
result = gw.authorize( amount, profile_key, options )
|
|
77
|
+
if result.success?
|
|
78
|
+
gw.capture( amount, result.reference, options )
|
|
79
|
+
else
|
|
80
|
+
result
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# credit will charge back to the credit card
|
|
87
|
+
# some gateways support doing arbitrary credits, others require a transaction id,
|
|
88
|
+
# we encapsulate this difference here, looking for a recent successful charge if necessary
|
|
89
|
+
# Note, refund expects the subscription object to be passed in options so it can find a recent charge
|
|
90
|
+
|
|
91
|
+
# Note, when using refund (vs credit), the gateway needs time to process the purchase before we can refund against it
|
|
92
|
+
# for example according to Authorize.net support, thats about every 10 minute in their test environment
|
|
93
|
+
# in production they "only settle once a day after the merchant defined Transaction Cut Off Time."
|
|
94
|
+
# so if the credit fails (and transaction was "refund") the app should tell the user to try again in a day (?!)
|
|
95
|
+
|
|
96
|
+
def credit( amount, profile_key, options = {})
|
|
97
|
+
#debugger
|
|
98
|
+
options[:order_id] ||= unique_order_number
|
|
99
|
+
if SubscriptionConfig.gateway.respond_to?(:credit)
|
|
100
|
+
process( 'credit', amount) do |gw|
|
|
101
|
+
gw.credit( amount, profile_key, options )
|
|
102
|
+
end
|
|
103
|
+
else
|
|
104
|
+
# need to refund against a previous charge (by this subscriber!)
|
|
105
|
+
subscription = options[:subscription]
|
|
106
|
+
tx = subscription.transactions.charges_at_least( amount ).first
|
|
107
|
+
if tx
|
|
108
|
+
process( 'refund', amount ) do |gw|
|
|
109
|
+
# note, syntax follows void
|
|
110
|
+
gw.refund( tx.reference, options.merge(:amount => amount) )
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def process( action, amount = nil)
|
|
119
|
+
#debugger
|
|
120
|
+
result = SubscriptionTransaction.new
|
|
121
|
+
result.amount_cents = amount.is_a?(Money) ? amount.cents : amount
|
|
122
|
+
#result.amount = amount
|
|
123
|
+
result.action = action
|
|
124
|
+
begin
|
|
125
|
+
response = yield SubscriptionConfig.gateway
|
|
126
|
+
|
|
127
|
+
result.success = response.success?
|
|
128
|
+
result.reference = response.authorization
|
|
129
|
+
result.token = response.token
|
|
130
|
+
result.message = response.message
|
|
131
|
+
result.params = response.params
|
|
132
|
+
result.test = response.test?
|
|
133
|
+
rescue ActiveMerchant::ActiveMerchantError => e
|
|
134
|
+
result.success = false
|
|
135
|
+
result.reference = nil
|
|
136
|
+
result.message = e.message
|
|
137
|
+
result.params = {}
|
|
138
|
+
result.test = SubscriptionConfig.gateway.test?
|
|
139
|
+
end
|
|
140
|
+
# TODO: LOGGING
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# maybe should make this a callback option to acts_as_subscriber
|
|
145
|
+
def unique_order_number
|
|
146
|
+
# "#{Time.now.to_i}-#{rand(1_000_000)}"
|
|
147
|
+
ActiveMerchant::Utils::generate_unique_id
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%#= render :partial => "/shared/email_header", :locals => {:user => @user} %>
|
|
2
|
+
|
|
3
|
+
We were unable to process your payment, and your subscription is set to expire in <%= pluralize @subscription.grace_days_remaining, 'day' %>. If your card information need updating, please do so.
|
|
4
|
+
|
|
5
|
+
<%#= render :partial => "/shared/email_footer" %>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%#= render :partial => "/shared/email_header", :locals => {:user => @user} %>
|
|
2
|
+
|
|
3
|
+
This email is to let you know we charged your credit card.
|
|
4
|
+
|
|
5
|
+
Your Plan: <%= @subscription.plan.name %> (<%= @subscription.plan.rate %> per <%= @subscription.plan.interval==1 ? "month" : pluralize( @subscription.plan.interval, "month") %>)
|
|
6
|
+
Paid: <%= @transaction.amount.format %>
|
|
7
|
+
Paid Through: <%= @subscription.next_renewal_on %>
|
|
8
|
+
Card number: <%= @subscription.profile.card_display_number %>
|
|
9
|
+
Date: <%= @transaction.created_at.to_date %>
|
|
10
|
+
|
|
11
|
+
Thank you!
|
|
12
|
+
|
|
13
|
+
<%#= render :partial => "/shared/email_footer" %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%#= render :partial => "/shared/email_header", :locals => {:user => @user} %>
|
|
2
|
+
|
|
3
|
+
This email is to let you know we have credited your credit card.
|
|
4
|
+
|
|
5
|
+
Card number: <%= @subscription.profile.card_display_number %>
|
|
6
|
+
Refund: <%= @transaction.amount.format %>
|
|
7
|
+
Date: <%= @transaction.created_at.to_date %>
|
|
8
|
+
|
|
9
|
+
Thank you!
|
|
10
|
+
|
|
11
|
+
<%#= render :partial => "/shared/email_footer" %>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%#= render :partial => "/shared/email_header", :locals => {:user => @user} %>
|
|
2
|
+
|
|
3
|
+
We were unable to process your payment, and your subscription is set to expire in <%= pluralize @subscription.grace_days_remaining, 'day' %>. If your card information need updating, please do so.
|
|
4
|
+
|
|
5
|
+
<%#= render :partial => "/shared/email_footer" %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%#= render :partial => "/shared/email_header", :locals => {:user => @user} %>
|
|
2
|
+
|
|
3
|
+
Just to let you know, your trial is expiring soon. We hope you are pleased with our service.
|
|
4
|
+
|
|
5
|
+
<% if @subscription.profile.authorized? %>
|
|
6
|
+
If you want to continue the service, you don't need to do anything,
|
|
7
|
+
you'll be automatically billed.
|
|
8
|
+
|
|
9
|
+
If, however, you want to discontinue the service, please cancel it before that time.
|
|
10
|
+
Otherwise you'll be charged and begin your paid subscription.
|
|
11
|
+
<% else %>
|
|
12
|
+
We do not presently have an authorized credit card on file. Please log in and update your credit card information.
|
|
13
|
+
|
|
14
|
+
If, however, you want to discontinue the service, please log in and cancel your subscription to avoid further email warnings.
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<%#= render :partial => "/shared/email_footer" %>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<h1>Credit Card</h1>
|
|
2
|
+
|
|
3
|
+
<% form_for :profile, :url => {:action => :store_credit_card} do |form| %>
|
|
4
|
+
<%= form.error_messages %>
|
|
5
|
+
<% form.fields_for :credit_card, @profile.credit_card do |f| %>
|
|
6
|
+
<p>
|
|
7
|
+
<%= f.label :first_name %><br />
|
|
8
|
+
<%= f.text_field :first_name %>
|
|
9
|
+
</p>
|
|
10
|
+
<p>
|
|
11
|
+
<%= f.label :last_name %><br />
|
|
12
|
+
<%= f.text_field :last_name %>
|
|
13
|
+
</p>
|
|
14
|
+
<p>
|
|
15
|
+
<%= f.label :type, 'Card type' %><br />
|
|
16
|
+
<%= f.select :type, SubscriptionConfig.gateway.supported_cardtypes.collect {|c| [c.to_s.titleize, c.to_s]} %>
|
|
17
|
+
</p>
|
|
18
|
+
<p>
|
|
19
|
+
<%= f.label :number, 'Card number' %><br />
|
|
20
|
+
<%= f.text_field :number %>
|
|
21
|
+
</p>
|
|
22
|
+
<p>
|
|
23
|
+
<%= f.label :verification_value, 'Card Verification Value (CVV)' %><br />
|
|
24
|
+
<%= f.text_field :verification_value, :size => 4 %>
|
|
25
|
+
</p>
|
|
26
|
+
<p>
|
|
27
|
+
<%= f.label :month, 'Card expires on:' %><br />
|
|
28
|
+
month: <%= f.select :month, (1..12).map { |i| "%02d" % i } %>
|
|
29
|
+
year: <%= f.select :year, (Date.today.year..(Date.today.year+10)) %>
|
|
30
|
+
</p>
|
|
31
|
+
<% end %>
|
|
32
|
+
<p><%= form.submit "Submit", :disable_with => "One moment please..." %></p>
|
|
33
|
+
<% end %>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<h1>Change Subscription Plan</h1>
|
|
2
|
+
<p>
|
|
3
|
+
<strong>Current Plan:</strong>
|
|
4
|
+
<%= @subscription.plan.name.titleize %>
|
|
5
|
+
</p>
|
|
6
|
+
<% form_for @subscription do |f| %>
|
|
7
|
+
<p>
|
|
8
|
+
<%= f.label :plan %><br />
|
|
9
|
+
<%= f.select :plan, @allowed_plans.collect {|p| [ p.name, p.id ] } %>
|
|
10
|
+
</p>
|
|
11
|
+
<p><%= f.submit "Change Plan" %></p>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<p style="font:small"><%= link_to "I want to cancel my subscription", cancel_subscription_path(:current) , :confirm => 'Are you sure?' %></p>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<h1>Transaction History</h1>
|
|
2
|
+
<ul>
|
|
3
|
+
<% @transactions.each do |tx| %>
|
|
4
|
+
<li> <%= tx.created_at %> : <%= tx.action.titleize %> <%= tx.amount.format if tx.amount %> <%= tx.message unless tx.success? %></li>
|
|
5
|
+
<% end %>
|
|
6
|
+
</ul>
|
|
7
|
+
<%= link_to "Back", subscription_path(:current) %>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<h1>Subscription Details</h1>
|
|
2
|
+
<fieldset>
|
|
3
|
+
<legend>Subscription</legend>
|
|
4
|
+
<p>
|
|
5
|
+
<strong>Plan:</strong>
|
|
6
|
+
<% plan = @subscription.plan %>
|
|
7
|
+
<%= plan.name.titleize %> <%= "(#{plan.rate.format} per #{ plan.interval==1 ? 'month' : pluralize(plan.interval, 'month')})" unless plan.free? %>
|
|
8
|
+
<%= button_to "Change Plan", edit_subscription_path(:current), :method => :get %>
|
|
9
|
+
</p>
|
|
10
|
+
<% unless @subscription.free? %>
|
|
11
|
+
<p>
|
|
12
|
+
<strong>Status:</strong>
|
|
13
|
+
<%= @subscription.state.titleize %>
|
|
14
|
+
<% if @subscription.past_due? %>
|
|
15
|
+
<span style="color:red">Account is past due. Please update your credit card information now.</span>
|
|
16
|
+
<% end %>
|
|
17
|
+
</p>
|
|
18
|
+
<p>
|
|
19
|
+
<% if @subscription.trial? %>
|
|
20
|
+
<strong>Trial Period Ends:</strong>
|
|
21
|
+
<% else %>
|
|
22
|
+
<strong>Paid Through:</strong>
|
|
23
|
+
<% end %>
|
|
24
|
+
<%= @subscription.next_renewal_on %>
|
|
25
|
+
</p>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% unless @subscription.balance.zero? %>
|
|
28
|
+
<p>
|
|
29
|
+
<strong>Balance on Account:</strong>
|
|
30
|
+
<%= @subscription.balance.format %>
|
|
31
|
+
</p>
|
|
32
|
+
<% end %>
|
|
33
|
+
</fieldset>
|
|
34
|
+
<fieldset>
|
|
35
|
+
<legend>Billing Info</legend>
|
|
36
|
+
<p>
|
|
37
|
+
<strong>Credit Card:</strong>
|
|
38
|
+
<% if @subscription.profile.nil? || @subscription.profile.no_info? %>
|
|
39
|
+
(no credit card on file)
|
|
40
|
+
<% else %>
|
|
41
|
+
<%= "#{@subscription.profile.card_type.titleize} #{@subscription.profile.card_display_number} Expires: #{@subscription.profile.card_expires_on}" %>
|
|
42
|
+
<% if @subscription.profile.error? %>
|
|
43
|
+
<span style="color:red">There was an error processing your credit card.</span>
|
|
44
|
+
<% end %>
|
|
45
|
+
<% end %>
|
|
46
|
+
<%= button_to "Update Credit Card", credit_card_subscription_path(:current), :method => :get %>
|
|
47
|
+
</p>
|
|
48
|
+
<% if t = @subscription.latest_transaction %>
|
|
49
|
+
<p>
|
|
50
|
+
<strong>Latest Transaction:</strong>
|
|
51
|
+
<%# make this a helper %>
|
|
52
|
+
<%= t.created_at %> :
|
|
53
|
+
<%= 'FAILED TO' unless t.success? %>
|
|
54
|
+
<%= t.action.capitalize %> card.
|
|
55
|
+
<%= "for #{t.amount.format}" unless t.amount.nil? %>
|
|
56
|
+
<%= t.message %>
|
|
57
|
+
<%= "(#{link_to 'history', history_subscription_path(:current)})" %>
|
|
58
|
+
</p>
|
|
59
|
+
<% end %>
|
metadata
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: saas
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: '0.1'
|
|
5
|
+
prerelease:
|
|
6
|
+
platform: ruby
|
|
7
|
+
authors:
|
|
8
|
+
- Luis Perichon
|
|
9
|
+
autorequire:
|
|
10
|
+
bindir: bin
|
|
11
|
+
cert_chain: []
|
|
12
|
+
date: 2012-02-24 00:00:00.000000000Z
|
|
13
|
+
dependencies:
|
|
14
|
+
- !ruby/object:Gem::Dependency
|
|
15
|
+
name: rspec
|
|
16
|
+
requirement: &83443350 !ruby/object:Gem::Requirement
|
|
17
|
+
none: false
|
|
18
|
+
requirements:
|
|
19
|
+
- - ! '>='
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '0'
|
|
22
|
+
type: :development
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: *83443350
|
|
25
|
+
description: SaaS gem
|
|
26
|
+
email:
|
|
27
|
+
- info@luisperichon.com.ar
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- app/views/subscription_mailer/charge_success.html.erb
|
|
33
|
+
- app/views/subscription_mailer/admin_report.html.erb
|
|
34
|
+
- app/views/subscription_mailer/credit_success.html.erb
|
|
35
|
+
- app/views/subscription_mailer/subscription_expired.html.erb
|
|
36
|
+
- app/views/subscription_mailer/trial_expiring.html.erb
|
|
37
|
+
- app/views/subscription_mailer/charge_failure.html.erb
|
|
38
|
+
- app/views/subscription_mailer/second_charge_failure.html.erb
|
|
39
|
+
- app/views/subscriptions/show.html.erb
|
|
40
|
+
- app/views/subscriptions/edit.html.erb
|
|
41
|
+
- app/views/subscriptions/history.html.erb
|
|
42
|
+
- app/views/subscriptions/credit_card.html.erb
|
|
43
|
+
- app/controllers/subscriptions_controller.rb
|
|
44
|
+
- app/models/subscription_transaction.rb
|
|
45
|
+
- app/models/subscription_mailer.rb
|
|
46
|
+
- app/models/subscription.rb
|
|
47
|
+
- app/models/subscription_plan.rb
|
|
48
|
+
- app/models/subscription_config.rb
|
|
49
|
+
- app/models/subscription_profile.rb
|
|
50
|
+
- app/models/subscription_observer.rb
|
|
51
|
+
- README.rdoc
|
|
52
|
+
homepage: http://luisperichon.com.ar
|
|
53
|
+
licenses: []
|
|
54
|
+
post_install_message:
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
none: false
|
|
60
|
+
requirements:
|
|
61
|
+
- - ! '>='
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0'
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
none: false
|
|
66
|
+
requirements:
|
|
67
|
+
- - ! '>='
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: 1.3.6
|
|
70
|
+
requirements: []
|
|
71
|
+
rubyforge_project:
|
|
72
|
+
rubygems_version: 1.8.6
|
|
73
|
+
signing_key:
|
|
74
|
+
specification_version: 3
|
|
75
|
+
summary: SaaS gem
|
|
76
|
+
test_files: []
|