saas 0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|