saas 0.1

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