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.
@@ -0,0 +1,2 @@
1
+ based on
2
+ $ script/plugin install git://github.com/linoj/saasramp.git
@@ -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,4 @@
1
+ <% @log.each do |subscription, events| %>
2
+ subscription #<%= subscription.id %> (customer key <%= subscription.profile_key %>)
3
+ <%= events.collect{|e| "- #{e.to_s}"}.join("\n") %>
4
+ <% 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,5 @@
1
+ <%#= render :partial => "/shared/email_header", :locals => {:user => @user} %>
2
+
3
+ Your subscription has expired.
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: []